tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-wire-escena/LEEME.md Markdown · 134 líneas · 5.7 KB Historia crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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:

fpscatálogopeso por app
Kernel puro (llimphi-wire-view)685~15-18 de 41 entries12 KB
IR de escena (este crate)77los 69 widgets~12 KB + geometría
Jaula pura (rasterizar adentro)18los 69 widgets476 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 vivoapp shapea (IR con glifos)kernel shapea (IR con texto)
total45,92 ms → 21,8 fps14,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

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órdenesbytesKBvs mandar píxeles
501004.7294,6162x menos
20040018.93018,541x menos
5001.00047.43046,316x 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óndequé hace
encoderllimphi-jaulacorre el compositor en la jaula y graba acá
reproductorllimphi-wawadeserializa, 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.