1# llimphi-wire-escena
2
3El IR de **escena** que cruza la frontera WASM: una lista plana de formas,
4pinceles y **texto sin shapear**, serializable con postcard, `no_std + alloc`.
5
6W4 de [`03_ukupacha/wawa/PLAN-LLIMPHI.md`](../../../03_ukupacha/wawa/PLAN-LLIMPHI.md).
7
8## El problema
9
10Una app de wawa es un módulo `.wasm` en una jaula, y la penalidad del intérprete
11está medida: **30,3x**. La pregunta de W4 era dónde poner la frontera entre lo
12que corre enjaulado y lo que corre nativo. Las tres respuestas posibles, a 500
13widgets:
14
15| | fps | catálogo | peso por app |
16|---|---|---|---|
17| Kernel puro (`llimphi-wire-view`) | 685 | ~15-18 de 41 entries | 12 KB |
18| **IR de escena** (este crate) | **77** | **los 69 widgets** | ~12 KB + geometría |
19| Jaula pura (rasterizar adentro) | 18 | los 69 widgets | 476 KB |
20
21El IR de escena no da velocidad nativa, pero da 77 fps **con el catálogo
22completo** — cómodo sobre 60, y 4x mejor que rasterizar en la jaula. La razón:
23el trabajo caro (aplanar curvas, generar strips, rasterizar) es el 79-93% del
24fotograma y cae entero del lado nativo. La app sólo arma geometría, que aguanta
25el 30,3x sin despeinarse.
26
27## El texto es lo único que no es un trazo
28
29Y es a propósito — es la conclusión que W4.1 midió:
30
31| 500 widgets, texto vivo | app shapea (IR con glifos) | **kernel shapea (IR con texto)** |
32|---|---|---|
33| total | 45,92 ms → **21,8 fps** | 14,06 ms → **71 fps** |
34
35Si la app shapeara y mandara glifos, `parley` pagaría el 30,3x y se llevaría el
3672% del fotograma. Mandando **la cadena**, el kernel shapea nativo y el texto
37cuesta 1,09 ms en vez de 32,94. Por eso `OrdenWire::Texto` lleva cadena + estilo
38+ rect, y **no hay variante de glifos**.
39
40El costo aceptado: `parley` y `swash` viven del lado nativo — o sea en Ring 0
41cuando ese lado es el kernel. Es el mismo trato que ya se hizo con `vello_cpu`.
42
43## Cuál de los dos IR usar
44
45```
46llimphi-wire-view árbol de CAJAS el host corre layout Y pinta
47llimphi-wire-escena lista de TRAZOS el host sólo rasteriza
48```
49
50`wire-view` es más chico en el cable y deja al host decidir el aspecto, pero sólo
51expresa lo que su vocabulario nombra: sin gradientes, sombras, clip, z-index ni
52paint custom. Nodegraph, table, terminal y text-editor quedan afuera **por
53construcción**. `wire-escena` no tiene ese techo porque no habla de widgets:
54habla de trazos, y todo widget termina en trazos.
55
56## Forma
57
58`EscenaWire` es a la vez el formato del cable y una **grabadora con las ocho
59firmas de `vello::Scene`** — las mismas que espeja `llimphi_cpu::Escena`. Que las
60tres coincidan es lo que deja elegir backend con un alias de tipo en vez de con
61tres caminos de código paralelos.
62
63```rust
64let mut escena = EscenaWire::new();
65escena.fill(Fill::NonZero, Affine::IDENTITY, Color::BLACK, None, &rect);
66escena.texto(Texto {
67 cadena: "hola".into(),
68 estilo: EstiloTextoWire { tamano_px: 14.0, ..Default::default() },
69 color: [255, 255, 255, 255],
70 ancho_max: None,
71 xf: UbicacionWire::Punto([10.0, 4.0]),
72 hint: true,
73});
74let bytes = postcard::to_allocvec(&escena)?; // esto es lo que cruza
75```
76
77Del otro lado, `llimphi_wawa::reproducir` lo shapea y rasteriza nativo.
78
79## Dos decisiones que la medición corrigió
80
81**El estilo se interna.** La primera versión metía los 17 campos de estilo en
82cada orden de texto; a 500 etiquetas el texto se llevaba **la mitad del cable**
83(31.890 de 64.393 B) y no por las cadenas —que son ~8 B— sino por repetir el
84estilo. En una UI real el estilo varía por *rol*, no por etiqueta, así que 500
85etiquetas dan **una** entrada de tabla y un `u16` por orden. El precio: `append`
86tiene que remapear los índices de la hija, o un subárbol cacheado se repinta con
87la tipografía de otro.
88
89**La ubicación del texto elige representación.** `UbicacionWire` guarda un punto
90cuando el afín es una traslación pura —el caso normal, 8 B— y la matriz entera
91cuando hay rotación o escala —24 B, exacto—. Guardar siempre el punto habría
92pintado derecho el texto de un subárbol rotado *sin que nada avisara*; guardar
93siempre la matriz cuesta +17% de fotograma por un caso que casi nunca ocurre.
94
95**La imagen no viaja.** `OrdenWire::Imagen` lleva una huella FNV-1a de 64 bits
96del blob, no los píxeles — mandar píxeles sería exactamente el 768 KB/fotograma
97de la «jaula pura» que W4 descarta. Una imagen de 4 MB pesa menos de 64 B en el
98cable. Transferir el blob la primera vez es un canal aparte.
99
100## Peso medido
101
102`cargo test -p llimphi-wire-escena --release -- --nocapture`, @480x400:
103
104| widgets | órdenes | bytes | KB | vs mandar píxeles |
105|---|---|---|---|---|
106| 50 | 100 | 4.729 | 4,6 | **162x** menos |
107| 200 | 400 | 18.930 | 18,5 | **41x** menos |
108| 500 | 1.000 | 47.430 | 46,3 | **16x** menos |
109
110## Bare-metal
111
112```bash
113cargo +nightly check -p llimphi-wire-escena --target x86_64-unknown-none \
114 --no-default-features --features libm -Z build-std=core,alloc
115```
116
117Mismo trato de features que `llimphi-cpu`, y por la misma razón: el perfil del
118crate tiene que coincidir con el de `kurbo` en el mismo build. En el workspace
119`vello` fuerza `std`; en wawa no hay vello que unifique nada.
120
121## Los dos extremos del cable
122
123Éste es el cable; los enchufes viven aparte, y cada uno en el lado al que
124pertenece:
125
126| | dónde | qué hace |
127|---|---|---|
128| **encoder** | [`llimphi-jaula`](../llimphi-jaula/LEEME.md) | corre el compositor en la jaula y graba acá |
129| **reproductor** | [`llimphi-wawa`](../llimphi-wawa/LEEME.md) | deserializa, shapea nativo y rasteriza |
130
131Que ninguno de los dos esté en este crate no es prolijidad: **no pueden
132compilarse juntos**. `llimphi-text` tiene un `compile_error!` que lo impide,
133porque son dos procesos en dos lados de una frontera y fingir lo contrario
134escondería el único error que importa — shapear del lado equivocado.
LEEME.md
llimphi-wire-escena
El IR de escena que cruza la frontera WASM: una lista plana de formas,
pinceles y texto sin shapear, serializable con postcard, no_std + alloc.
W4 de 03_ukupacha/wawa/PLAN-LLIMPHI.md.
El problema
Una app de wawa es un módulo .wasm en una jaula, y la penalidad del intérprete
está medida: 30,3x. La pregunta de W4 era dónde poner la frontera entre lo
que corre enjaulado y lo que corre nativo. Las tres respuestas posibles, a 500
widgets:
| fps | catálogo | peso por app |
Kernel puro (llimphi-wire-view) | 685 | ~15-18 de 41 entries | 12 KB |
| IR de escena (este crate) | 77 | los 69 widgets | ~12 KB + geometría |
| Jaula pura (rasterizar adentro) | 18 | los 69 widgets | 476 KB |
El IR de escena no da velocidad nativa, pero da 77 fps con el catálogo
completo — cómodo sobre 60, y 4x mejor que rasterizar en la jaula. La razón:
el trabajo caro (aplanar curvas, generar strips, rasterizar) es el 79-93% del
fotograma y cae entero del lado nativo. La app sólo arma geometría, que aguanta
el 30,3x sin despeinarse.
El texto es lo único que no es un trazo
Y es a propósito — es la conclusión que W4.1 midió:
| 500 widgets, texto vivo | app shapea (IR con glifos) | kernel shapea (IR con texto) |
| total | 45,92 ms → 21,8 fps | 14,06 ms → 71 fps |
Si la app shapeara y mandara glifos, parley pagaría el 30,3x y se llevaría el
72% del fotograma. Mandando la cadena, el kernel shapea nativo y el texto
cuesta 1,09 ms en vez de 32,94. Por eso OrdenWire::Texto lleva cadena + estilo
- rect, y no hay variante de glifos.
El costo aceptado: parley y swash viven del lado nativo — o sea en Ring 0
cuando ese lado es el kernel. Es el mismo trato que ya se hizo con vello_cpu.
Cuál de los dos IR usar
llimphi-wire-view árbol de CAJAS el host corre layout Y pinta
llimphi-wire-escena lista de TRAZOS el host sólo rasteriza
wire-view es más chico en el cable y deja al host decidir el aspecto, pero sólo
expresa lo que su vocabulario nombra: sin gradientes, sombras, clip, z-index ni
paint custom. Nodegraph, table, terminal y text-editor quedan afuera por
construcción. wire-escena no tiene ese techo porque no habla de widgets:
habla de trazos, y todo widget termina en trazos.
Forma
EscenaWire es a la vez el formato del cable y una grabadora con las ocho
firmas de vello::Scene — las mismas que espeja llimphi_cpu::Escena. Que las
tres coincidan es lo que deja elegir backend con un alias de tipo en vez de con
tres caminos de código paralelos.
let mut escena = EscenaWire::new();
escena.fill(Fill::NonZero, Affine::IDENTITY, Color::BLACK, None, &rect);
escena.texto(Texto {
cadena: "hola".into(),
estilo: EstiloTextoWire { tamano_px: 14.0, ..Default::default() },
color: [255, 255, 255, 255],
ancho_max: None,
xf: UbicacionWire::Punto([10.0, 4.0]),
hint: true,
});
let bytes = postcard::to_allocvec(&escena)?; // esto es lo que cruza
Del otro lado, llimphi_wawa::reproducir lo shapea y rasteriza nativo.
Dos decisiones que la medición corrigió
El estilo se interna. La primera versión metía los 17 campos de estilo en
cada orden de texto; a 500 etiquetas el texto se llevaba la mitad del cable
(31.890 de 64.393 B) y no por las cadenas —que son ~8 B— sino por repetir el
estilo. En una UI real el estilo varía por rol, no por etiqueta, así que 500
etiquetas dan una entrada de tabla y un u16 por orden. El precio: append
tiene que remapear los índices de la hija, o un subárbol cacheado se repinta con
la tipografía de otro.
La ubicación del texto elige representación. UbicacionWire guarda un punto
cuando el afín es una traslación pura —el caso normal, 8 B— y la matriz entera
cuando hay rotación o escala —24 B, exacto—. Guardar siempre el punto habría
pintado derecho el texto de un subárbol rotado sin que nada avisara; guardar
siempre la matriz cuesta +17% de fotograma por un caso que casi nunca ocurre.
La imagen no viaja. OrdenWire::Imagen lleva una huella FNV-1a de 64 bits
del blob, no los píxeles — mandar píxeles sería exactamente el 768 KB/fotograma
de la «jaula pura» que W4 descarta. Una imagen de 4 MB pesa menos de 64 B en el
cable. Transferir el blob la primera vez es un canal aparte.
Peso medido
cargo test -p llimphi-wire-escena --release -- --nocapture, @480x400:
| widgets | órdenes | bytes | KB | vs mandar píxeles |
| 50 | 100 | 4.729 | 4,6 | 162x menos |
| 200 | 400 | 18.930 | 18,5 | 41x menos |
| 500 | 1.000 | 47.430 | 46,3 | 16x menos |
Bare-metal
cargo +nightly check -p llimphi-wire-escena --target x86_64-unknown-none \
--no-default-features --features libm -Z build-std=core,alloc
Mismo trato de features que llimphi-cpu, y por la misma razón: el perfil del
crate tiene que coincidir con el de kurbo en el mismo build. En el workspace
vello fuerza std; en wawa no hay vello que unifique nada.
Los dos extremos del cable
Éste es el cable; los enchufes viven aparte, y cada uno en el lado al que
pertenece:
| dónde | qué hace |
| encoder | llimphi-jaula | corre el compositor en la jaula y graba acá |
| reproductor | llimphi-wawa | deserializa, shapea nativo y rasteriza |
Que ninguno de los dos esté en este crate no es prolijidad: no pueden
compilarse juntos. llimphi-text tiene un compile_error! que lo impide,
porque son dos procesos en dos lados de una frontera y fingir lo contrario
escondería el único error que importa — shapear del lado equivocado.