# Láminas: dibujos que se revelan

> Una lámina es un dibujo a mano listo para que cualquier motor lo haga aparecer a pluma sin volver a calcular la mano: la imagen se rasteriza una vez y un atlas de tiempo dice cuándo llega la pluma a cada píxel.

URL: https://torculo.joseluissaorin.com/laminas · Revisado: 2026-10-13 · English: https://torculo.joseluissaorin.com/en/plates.md

Una lámina es un dibujo a mano listo para que cualquier motor lo haga aparecer a pluma sin volver a calcular la mano: la imagen se rasteriza una vez y un atlas de tiempo dice cuándo llega la pluma a cada píxel.

- Una manícula dibujada a pluma: https://torculo.joseluissaorin.com/medios/laminas/manicula.es.lamina (Una mano con el índice extendido, como las que los lectores antiguos dibujaban en el margen de los libros, sale de una manga con puños de encaje y señala hacia la derecha. Debajo se adivinan las líneas de lápiz con que se encajó el dibujo y, al lado, una nota a mano: «señala; no contesta».)
- Un tintero con una pluma de ave: https://torculo.joseluissaorin.com/medios/laminas/tintero.es.lamina (Un tintero bajo y ancho dibujado a pluma, con una pluma de ave metida en el cuello. Junto a él cae una gota y hay un pequeño charco de tinta azul. Al margen, a mano: «empieza por una frase».)
- Una lupa sobre una página: https://torculo.joseluissaorin.com/medios/laminas/lupa.es.lamina (Una lupa de mango torneado, dibujada a pluma, sobre una página de renglones garabateados con una esquina doblada. Dentro de la lente, dos renglones se ven más grandes. Al margen, a mano: «nada por aquí… todavía».)


Una lámina es un dibujo a mano listo para que cualquier motor (la web, una app,
un juego, el vídeo) lo haga aparecer a pluma **sin volver a calcular la mano**.
Sigue la idea de *Ballpoint Gambit*: la imagen se rasteriza una vez y, junto a
ella, un **atlas de tiempo** dice en qué instante llega la pluma a cada píxel;
un shader de cuatro líneas hace el resto.

La produce `torculo_pluma::lamina::hacer` (Rust), `torculo_pluma.lamina()`
(Python), `lamina()` del wasm y, en la skill, `scripts/lamina.ts`. La lee el
visor de `pluma/visor/` y `lamina::fotograma` (para sacar fotogramas sin GPU).

## 1. Dos envoltorios, el mismo contenido

- **Directorio** `nombre.lamina/`, con los archivos de la sección 2 tal cual.
- **Empaquetada**, un solo archivo `nombre.lamina`:

  | bytes | contenido |
  |---|---|
  | 0 a 7 | la firma `LAMINA\0\x01` (`4C 41 4D 49 4E 41 00 01`) |
  | 8 a 11 | `n`, largo del índice, u32 little-endian |
  | 12 a 12+n | el índice, JSON UTF-8: `{"archivos":[{"ruta","inicio","largo"}…]}` |
  | desde 12+n | los archivos uno tras otro; `inicio` cuenta desde aquí |

  Las rutas del índice son las mismas que las del directorio. Un lector
  distingue los dos casos por la firma.

## 2. Archivos

```
manifiesto.json                 lo que hay y cómo se compone (siempre el primero)
geometria.json                  la geometría vectorial de todas las poses (opcional)
vista.png                       el dibujo terminado sobre el papel, pose 0 (vista previa)
capas/<capa>-<pose>.png         atlas de color de una capa en una pose
capas/<capa>-<pose>-tiempo.png  atlas de tiempo de esa capa en esa pose
```

Todas las imágenes de una lámina miden lo mismo: `tamano.ancho` ×
`tamano.alto` píxeles, que son la `caja` del dibujo por la `escala`
(2 por defecto: retina).

### 2.1 Atlas de color

PNG RGBA de 8 bits **con la alfa premultiplicada** (el PNG la declara recta; el
lector debe cargarlo sin tocar los valores: en WebGL,
`createImageBitmap(blob, {premultiplyAlpha: 'none', colorSpaceConversion: 'none'})`
y `UNPACK_PREMULTIPLY_ALPHA_WEBGL = false`). Sin papel: lo que no lleva tinta
es transparente. Colores sRGB del canon (`canon.toml`, papel claro).

### 2.2 Atlas de tiempo

PNG RGB de 8 bits. Por píxel:

- `R·256 + G` es el instante normalizado en 16 bits: `t = (R·256 + G) / 65535`,
  donde 0 es el arranque de la lámina y 1 su `duracion`;
- `B` vale 255 si el píxel tiene tinta en esa capa y 0 si no (y entonces R y G
  no significan nada).

Se muestrea **sin filtrar** (`NEAREST` o `texelFetch`): interpolar mezclaría el
byte alto y el bajo. Si en un píxel se cruzan dos piezas de la misma capa, vale
el instante más temprano.

Cómo se calcula el instante de cada píxel, según la pieza que lo cubre:

| pieza | instante |
|---|---|
| tinta y color (pluma) | el del punto más cercano de su línea central; a lo largo de la línea, `inicio + duracion · B⁻¹(arco/largo)`, con `B` = cubic-bezier(0.55, 0.1, 0.45, 0.95), la curva `--ease-tinta` de `dibujos.css` |
| lápiz | igual, pero lineal: `inicio + duracion · arco/largo` |
| plano | un pincel en diagonal: `inicio + 0,7 s · f`, con `f` la posición en la diagonal de su caja (el SVG lo funde en 0,7 s) |
| nota | se escribe de izquierda a derecha, renglón tras renglón, en 0,9 s (el SVG la funde en 0,9 s) |

`inicio` y `duracion` de cada pieza son exactamente los de `aSvg` (`--d` y
`--t`): el lápiz primero y deprisa (28 % del tiempo), luego la tinta y el color
en el orden en que se escribieron, los planos escalonados y las notas al 85 %.

### 2.3 El revelado

Por cada capa, de abajo arriba, con `progreso` de 0 a 1:

```glsl
float t = (R * 255.0 * 256.0 + G * 255.0) / 65535.0;
float reveal = B > 0.5 ? clamp((progreso * 1.03 - t) * 60.0, 0.0, 1.0) : 1.0;
vec4 c = color * reveal;                       // premultiplicado
dst = mezcla == "multiplicar" ? dst * (1.0 - c.a) + dst * c.rgb
                              : c.rgb + dst * (1.0 - c.a);
```

`dst` empieza con el `papel`. El adelanto de 1,03 garantiza que con
`progreso = 1` todo está dibujado; la pendiente de 60 da un frente de tinta de
1/60 de la duración. Los dos números están en `revelado` del manifiesto.

## 3. Capas

Las del SVG de `boceto.ts`, en el mismo orden; solo aparecen las que tienen algo.

| capa | mezcla | desplazamiento | hierve |
|---|---|---|---|
| `lapiz` | normal | ninguno | sí |
| `planos` | multiplicar | ninguno | sí (el borde) |
| `tinta` | normal | ninguno | sí |
| `color` | multiplicar | el `desregistro` | sí |
| `encima` | multiplicar | el `desregistro` | sí |
| `notas` | normal | ninguno | no: todas las poses apuntan a la misma imagen |

El desregistro ya está aplicado en los píxeles; `desplazamiento` se da para
quien dibuje con la geometría.

## 4. El hervor

`hervor.variantes` poses (3 por defecto, `hervor_poses` del canon). La pose 0
es el dibujo de siempre, idéntico al SVG; las demás tienen la misma geometría y
el mismo ritmo de mano y solo cambian el temblor (las tres ondas de `temblar`
se re-siembran con `semilla_de_pose`; el desborde de los extremos no cambia,
así que cada trazo tiene los mismos puntos en todas las poses).

En reproducción:

```
pose = floor(segundos_en_reposo * fps + desfase) mod variantes
```

con `fps` = 8 (`hervor_fps` del canon) y `desfase` = `hash32(semilla(id)) ·
variantes`, que viene escrito en el manifiesto. Regla de la casa
(`al_reposar: true`): mientras la pluma dibuja se ve la pose 0; el hervor
empieza cuando el dibujo está entero.

## 5. `manifiesto.json`

```jsonc
{
  "formato": "lamina", "version": 1,
  "id": "manicula",                       // el id del dibujo (y su semilla)
  "titulo": "…", "descripcion": "…",      // para lectores de pantalla, en la lengua de la lámina
  "lengua": "es",
  "tamano": { "ancho": 1280, "alto": 600, "escala": 2, "caja": [0, 0, 640, 300] },
  "duracion": 3.96,                       // segundos hasta que acaba la última pieza
  "papel": "#F5F0E8",
  "alfa": "premultiplicada",
  "revelado": { "formula": "…", "adelanto": 1.03, "pendiente": 60 },
  "hervor": { "variantes": 3, "fps": 8, "desfase": 1.62, "semilla": 3294479249, "al_reposar": true },
  "capas": [
    { "nombre": "lapiz", "mezcla": "normal", "desplazamiento": [0, 0], "hierve": true,
      "color": ["capas/lapiz-0.png", "capas/lapiz-1.png", "capas/lapiz-2.png"],
      "tiempo": ["capas/lapiz-0-tiempo.png", "…"] }
  ],
  "trazos": [                             // el orden de la mano: una entrada por pieza
    { "indice": 0, "capa": "lapiz", "elemento": 0, "pasada": 0, "modo": "lineal",
      "inicio": 0, "duracion": 0.28, "t0": 0, "t1": 0.0707 }
  ],
  "trayectoria": {                        // la punta: [ms, x, y, abajo]
    "lapiz": [[0, 0.1, 98.2, 0], [0, 0.1, 98.2, 1], …],
    "pluma": […]
  },
  "geometria": "geometria.json",          // o null
  "vista": "vista.png",
  "canon": { "version": 1, "semilla": "fnv1a32-utf8", "serie": "mulberry32" }
}
```

- `trazos[].modo`: `lineal` (lápiz), `pluma` (tinta y color), `pincel` (planos)
  o `escritura` (notas). `elemento` es el índice en `Dibujo.elementos`;
  `pasada`, la pasada de la pluma o, en un sombreado, la raya.
- `trayectoria` lleva **dos herramientas**, porque el lápiz y la pluma se
  solapan en el tiempo (la tinta arranca al 60 % del lápiz). Cada punto es
  `[ms, x, y, abajo]` en unidades del dibujo (no en píxeles), con `abajo` a 1
  mientras la punta toca el papel y a 0 en el arranque y el final de cada
  trazo; los milisegundos nunca retroceden (si dos trazos cortos se solapan, el
  segundo espera). Sirve para un cursor de pluma, para el sonido del plumín
  (foley `rotulador`) o para un robot de dibujo.

## 6. `geometria.json`

```jsonc
{ "poses": [ { "id", "pose": 0, "caja", "duracion", "trazos": [ … ] }, … ] }
```

Cada trazo, en el orden de la mano:

| campo | qué es |
|---|---|
| `tipo` | `cinta` (pluma con presión), `linea` (lápiz, grosor fijo, remates redondos), `relleno` (plano) o `nota` |
| `capa`, `elemento`, `pasada`, `indice` | como en `trazos` del manifiesto |
| `tinta`, `color`, `opacidad`, `mezcla`, `desplazamiento` | cómo se pinta |
| `inicio`, `duracion`, `modo` | cuándo, en segundos |
| `puntos` | la línea central temblada, sin simplificar |
| `arco` | longitud de arco acumulada en cada punto |
| `ancho` | ancho de la pluma en cada punto, de lado a lado, con la presión (en `linea`, constante) |
| `llegada` | segundo en que la pluma llega a cada punto |
| `contorno` | en `cinta`, el polígono simplificado que rellena el SVG; en `relleno`, el polígono del plano |
| `nota` | en `nota`: `texto`, `x`, `y`, `tam`, `giro`, `ancla` (letra: `fuente/mano.ttf`) |

Con `puntos` y `ancho` un motor dibuja el trazo nativo de grosor variable
(media anchura a cada lado de la normal, remates redondos en los extremos de
las cintas abiertas); con `llegada` lo revela al ritmo de la pluma. Los números
van redondeados a milésimas.

## 7. Garantías

- **Determinismo.** La misma lámina sale idéntica, byte a byte, en Rust nativo,
  en Python y en wasm: el azar es el del canon (FNV-1a de 32 bits sobre UTF-8 y
  Mulberry32) y nada toca el reloj ni `Math.random`.
- **Fidelidad.** La pose 0 es el mismo dibujo que `aSvg` (ver
  `pruebas/equivalencia.ts`): `vista.png` y la captura de Chrome del SVG
  difieren en menos de medio nivel de gris de media (el suavizado de bordes).
- **Ids.** `azar.ts` de la skill siembra por unidades UTF-16 y el canon por
  bytes UTF-8: coinciden en ASCII. Un `id` con «ñ» o tildes daría otro pulso
  en la lámina que en el SVG; por eso los `id` van en ASCII.

## 8. Memoria

Cada capa en cada pose son dos texturas de `ancho × alto × 4` bytes. Una lámina
de 340 × 240 a escala 2 con cinco capas y tres poses ocupa unos 40 MB de GPU;
la constelación (900 × 680) a escala 2, unos 300 MB. Para láminas grandes,
escala 1 o 1,5, o menos variantes (`variantes: 1` quita el hervor).
