gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definitions

⎇ main llimphi-3d/src/ser.rs Rust · 612 lines · 24.2 KB History Who touched what raw

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

1//! **Seres SDF** — la segunda técnica de graficación del motor: personajes orgánicos
2//! definidos como un ensamble de **primitivas analíticas** (esferas / cápsulas /
3//! elipsoides) fundidas por **unión suave** (`smin`), trazadas por *sphere tracing*
4//! dentro del MISMO fragment shader del ray-march voxel (la receta de *Dreams* de
5//! Media Molecule).
6//!
7//! ## Por qué existe (lección 2026-07-27)
8//!
9//! El camino `humanoide_voxel` + `trace_smooth` modela al ser con elipsoides suaves,
10//! lo **voxeliza a una grilla binaria** y el shader intenta *reconstruir* la superficie
11//! lisa desde ese campo 0/1 — la información de la forma se destruye en la voxelización
12//! y la fidelidad queda en ~1 voxel (bultos, anillos). Acá el ensamble se evalúa
13//! **analíticamente**: silueta matemáticamente lisa a cualquier distancia, normales
14//! exactas (gradiente del campo), y el redondeo es parámetro de diseño (`blend`), no
15//! reconstrucción.
16//!
17//! ## Composición con el mundo
18//!
19//! `trace_sers` compite por `t` contra el mundo voxel y las [`Entity3d`]
20//! (cajas analíticas) en el fs; el hit escribe `frag_depth` → oclusión mutua con
21//! voxels Y mallas (depth compartido de `Scene3d`). Los seres proyectan sombra
22//! (el shadow ray del sol y de las puntuales también los marcha) y se auto-sombrean.
23//! Sombreado propio: **rampa toon** (bandas cuantizadas con AA) + **rim light** —
24//! el ser lleva su respuesta de luz "de personaje", el mundo conserva la suya.
25//!
26//! ## Cara = decals (kind `Decal*`)
27//!
28//! Las primitivas decal **no aportan geometría**: re-pintan el color de la superficie
29//! donde su campo es negativo (ojos como discos 2D expresivos sobre la cabeza 3D,
30//! sonrisa como arco fino, rubor) — el truco de *Animal Crossing*/*Wind Waker*. Son
31//! datos por frame → parpadeo/expresión = mover/achicar decals desde CPU.
32//!
33//! Todo va en un uniform propio (binding 5), gateado por `count == 0` → costo ~cero
34//! cuando no hay seres (retrocompat byte-idéntico).
35
36/// Tope de seres SDF por frame.
37///
38/// **Subido de 16 a 40 el 2026-09-06** (el tope viejo era la bandada del studio, y
39/// una CALLE con gente caminando no entra en dieciséis): el uniform pasa de 16 144 a
40/// 42 896 bytes. Eso deja atrás el mínimo de 16 KiB de un UBO de WebGL2/GLES3 y se
41/// para en el límite de WebGPU, `maxUniformBufferBindingSize = 65 536` — el camino
42/// que este renderer usa de verdad (ray-march con `texture_3d` y bucles de 80 pasos;
43/// nunca corrió sobre el fallback WebGL, y `check-llimphi-web.sh` no lo compila).
44/// Si algún día hace falta el fallback GL, la salida no es bajar el tope sino mudar
45/// las primitivas a un storage buffer.
46pub const VOXEL_MAX_SERS: usize = 40;
47/// Tope de primitivas SDF TOTALES por frame (repartidas entre los seres).
48/// El uniform completo debe quedar bajo el límite de WebGPU:
49/// `16 + 40×48 + 640×64 = 42 896` bytes (de 65 536).
50///
51/// Con LOD (los de lejos pierden la cara, que a esa distancia es sub-píxel) 640
52/// primitivas dan para una vereda con veinte personas, o para una selva con seis
53/// árboles (~40 prims cada uno) y su fauna. Ver `llimphi_voxel::DetalleSer`.
54pub const VOXEL_MAX_SER_PRIMS: usize = 640;
55
56/// Clase de primitiva SDF. Las `Decal*` no tienen volumen: sólo re-pintan color.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum SdfKind {
59    /// Esfera: `a` = centro, `ra` = radio.
60    Esfera,
61    /// Cápsula/cono redondeado: segmento `a→b`, radio `ra` en `a` → `rb` en `b`.
62    Capsula,
63    /// Elipsoide: `a` = centro, `b` = semiejes (aprox. de iq, suficiente para trace).
64    Elipsoide,
65    /// Decal esférico: pinta `color` donde `|p−a| < ra` sobre la superficie ya trazada.
66    DecalEsfera,
67    /// Decal capsular: pinta `color` a distancia `< ra` del segmento `a→b`.
68    DecalCapsula,
69}
70
71impl SdfKind {
72    fn code(self) -> f32 {
73        match self {
74            SdfKind::Esfera => 0.0,
75            SdfKind::Capsula => 1.0,
76            SdfKind::Elipsoide => 2.0,
77            SdfKind::DecalEsfera => 3.0,
78            SdfKind::DecalCapsula => 4.0,
79        }
80    }
81}
82
83/// Una primitiva del ensamble. Coordenadas en espacio de voxel `[0, dim]` (como
84/// [`Entity3d`](crate::Entity3d)); color RGB lineal `[0,1]`; `blend` = radio de
85/// unión suave (voxels) con lo acumulado ANTES en la lista (orden importa).
86///
87/// Un color con algún canal **mayor que 1,5** es **luz propia**: la pieza se ve
88/// con su tono (normalizado al canal más alto) aunque esté en sombra o sea de
89/// noche.
90#[derive(Debug, Clone, Copy)]
91pub struct SdfPrim {
92    pub kind: SdfKind,
93    pub a: [f32; 3],
94    pub ra: f32,
95    pub b: [f32; 3],
96    pub rb: f32,
97    pub color: [f32; 3],
98    pub blend: f32,
99}
100
101/// Un ser = lista ordenada de primitivas (geometría primero, decals después) en
102/// espacio LOCAL (pies en el origen, mirando `+Z`), posado en el mundo por
103/// `pos` + `yaw`. El shader lleva cada punto a espacio local antes de evaluar el
104/// campo — así el elipsoide (semiejes alineados a ejes) rota sin deformarse.
105#[derive(Debug, Clone, Default)]
106pub struct SdfSer {
107    pub prims: Vec<SdfPrim>,
108    /// Posición del origen local en coords de voxel `[0, dim]`.
109    pub pos: [f32; 3],
110    /// Rumbo (radianes alrededor de +Y; `0` = mirando `+Z`).
111    pub yaw: f32,
112}
113
114impl SdfSer {
115    /// Posa el ser: `pos` en coords de voxel `[0, dim]`, `yaw` en radianes.
116    pub fn posado(mut self, pos: [f32; 3], yaw: f32) -> Self {
117        self.pos = pos;
118        self.yaw = yaw;
119        self
120    }
121
122    /// Esfera envolvente `(centro, radio)` del ensamble en espacio LOCAL (el
123    /// empaque la lleva a mundo con `pos`/`yaw`). Incluye el margen de blend.
124    pub fn bound(&self) -> ([f32; 3], f32) {
125        if self.prims.is_empty() {
126            return ([0.0; 3], 0.0);
127        }
128        let mut lo = [f32::INFINITY; 3];
129        let mut hi = [f32::NEG_INFINITY; 3];
130        let mut kmax = 0.0f32;
131        for p in &self.prims {
132            let r = p.ra.max(p.rb).max(match p.kind {
133                SdfKind::Elipsoide => p.b[0].max(p.b[1]).max(p.b[2]),
134                _ => 0.0,
135            });
136            for i in 0..3 {
137                let (amin, amax) = match p.kind {
138                    // El elipsoide se extiende `b` (semiejes) desde `a`; su `b` no es punto.
139                    SdfKind::Elipsoide => (p.a[i] - p.b[i], p.a[i] + p.b[i]),
140                    _ => (p.a[i].min(p.b[i]) - r, p.a[i].max(p.b[i]) + r),
141                };
142                lo[i] = lo[i].min(amin);
143                hi[i] = hi[i].max(amax);
144            }
145            kmax = kmax.max(p.blend);
146        }
147        let c = [
148            (lo[0] + hi[0]) * 0.5,
149            (lo[1] + hi[1]) * 0.5,
150            (lo[2] + hi[2]) * 0.5,
151        ];
152        let dx = (hi[0] - lo[0]) * 0.5;
153        let dy = (hi[1] - lo[1]) * 0.5;
154        let dz = (hi[2] - lo[2]) * 0.5;
155        let r = (dx * dx + dy * dy + dz * dz).sqrt() + kmax + 1.0;
156        (c, r)
157    }
158}
159
160/// Empaca los seres al layout del uniform `SerU` del shader (binding 5).
161/// `count(16) + hdrs(16×48) + prims(240×64)` = 16 144 bytes exactos.
162pub(crate) fn pack_sers(sers: &[SdfSer]) -> Vec<u8> {
163    let mut out = Vec::with_capacity(16 + VOXEL_MAX_SERS * 48 + VOXEL_MAX_SER_PRIMS * 64);
164    let push4 = |buf: &mut Vec<u8>, v: [f32; 4]| {
165        for x in v {
166            buf.extend_from_slice(&x.to_ne_bytes());
167        }
168    };
169
170    // Header + prims aplanadas, respetando ambos topes. La esfera envolvente se
171    // lleva a MUNDO acá (centro local rotado por yaw + trasladado por pos).
172    struct Hdr {
173        c: [f32; 3],
174        r: f32,
175        start: usize,
176        count: usize,
177        pos: [f32; 3],
178        yaw: f32,
179    }
180    let mut flat: Vec<&SdfPrim> = Vec::new();
181    let mut hdrs: Vec<Hdr> = Vec::new();
182    for s in sers.iter().take(VOXEL_MAX_SERS) {
183        let start = flat.len();
184        let room = VOXEL_MAX_SER_PRIMS.saturating_sub(start);
185        // Un ser entra ENTERO o no entra. Truncarlo por la mitad no ahorra nada y
186        // pinta un mutilado (torso sin cabeza) — el corte cae donde caiga la lista.
187        // La única excepción es un ser que por sí solo excede el tope: ahí, mejor
188        // recortado que nada. Los que no entran se descartan: el llamador debe
189        // ordenarlos por cercanía y bajarles el detalle antes (`reparto_sdf`).
190        if s.prims.len() > room {
191            if start > 0 {
192                break;
193            }
194        }
195        let take = s.prims.len().min(room);
196        flat.extend(s.prims.iter().take(take));
197        let (cl, r) = s.bound();
198        let (sy, cy) = s.yaw.sin_cos();
199        let c = [
200            s.pos[0] + cy * cl[0] + sy * cl[2],
201            s.pos[1] + cl[1],
202            s.pos[2] - sy * cl[0] + cy * cl[2],
203        ];
204        hdrs.push(Hdr { c, r, start, count: take, pos: s.pos, yaw: s.yaw });
205    }
206
207    push4(&mut out, [hdrs.len() as f32, 0.0, 0.0, 0.0]);
208    for i in 0..VOXEL_MAX_SERS {
209        match hdrs.get(i) {
210            Some(h) => {
211                push4(&mut out, [h.c[0], h.c[1], h.c[2], h.r]);
212                push4(&mut out, [h.start as f32, h.count as f32, h.yaw, 0.0]);
213                push4(&mut out, [h.pos[0], h.pos[1], h.pos[2], 0.0]);
214            }
215            None => {
216                for _ in 0..3 {
217                    push4(&mut out, [0.0; 4]);
218                }
219            }
220        }
221    }
222    for i in 0..VOXEL_MAX_SER_PRIMS {
223        if let Some(p) = flat.get(i) {
224            push4(&mut out, [p.a[0], p.a[1], p.a[2], p.ra]);
225            push4(&mut out, [p.b[0], p.b[1], p.b[2], p.rb]);
226            push4(&mut out, [p.color[0], p.color[1], p.color[2], p.kind.code()]);
227            push4(&mut out, [p.blend.max(1e-3), 0.0, 0.0, 0.0]);
228        } else {
229            for _ in 0..4 {
230                push4(&mut out, [0.0; 4]);
231            }
232        }
233    }
234    out
235}
236
237/// Fragmento WGSL de los seres SDF: uniform (binding 5) + campo `smin` + sphere
238/// tracing + decals + sombra. Se antepone al WGSL principal del voxel renderer
239/// (el orden de declaración no importa en WGSL; el fs lo invoca).
240///
241/// Los tamaños de los dos arrays los pone [`wgsl_ser`] desde [`VOXEL_MAX_SERS`] y
242/// [`VOXEL_MAX_SER_PRIMS`]: escritos a mano se separaban del empacador en cuanto
243/// alguien subía un tope, y un uniform más chico que el buffer NO es un error de
244/// wgpu — el shader lee basura de las últimas primitivas.
245const WGSL_SER_TPL: &str = r#"
246struct SerPrim {
247    pa: vec4<f32>,   // a.xyz, ra
248    pb: vec4<f32>,   // b.xyz, rb
249    col: vec4<f32>,  // rgb, kind (0 esfera / 1 cápsula / 2 elipsoide / 3 decal-esfera / 4 decal-cápsula)
250    ext: vec4<f32>,  // x = blend (radio smin), yzw libres
251};
252struct SerHdr {
253    c: vec4<f32>,    // centro.xyz (MUNDO), radio de esfera envolvente
254    rango: vec4<f32>, // x = prim inicial, y = cantidad, z = yaw (rad)
255    pos: vec4<f32>,  // xyz = origen local del ser en mundo
256};
257struct SerU {
258    count: vec4<f32>, // x = seres vivos
259    hdrs: array<SerHdr, {MAX_SERS}>,
260    prims: array<SerPrim, {MAX_PRIMS}>,
261};
262@group(0) @binding(5) var<uniform> seru: SerU;
263
264// Lleva un punto de MUNDO al espacio LOCAL del ser `si` (des-traslada y des-rota
265// el yaw). Las primitivas viven en local — así el elipsoide (semiejes alineados
266// a ejes) rota sin deformarse. Rotación = isometría → las distancias del campo
267// siguen válidas en mundo (el gradiente/normal sale bien sin des-rotar nada).
268fn ser_local(si: i32, p: vec3<f32>) -> vec3<f32> {
269    let d = p - seru.hdrs[si].pos.xyz;
270    let yaw = seru.hdrs[si].rango.z;
271    let cy = cos(yaw);
272    let sy = sin(yaw);
273    return vec3<f32>(cy * d.x - sy * d.z, d.y, sy * d.x + cy * d.z);
274}
275
276// Unión suave polinómica (iq): junta orgánica entre primitivas.
277fn ser_smin(d1: f32, d2: f32, k: f32) -> f32 {
278    let h = clamp(0.5 + 0.5 * (d2 - d1) / max(k, 1e-4), 0.0, 1.0);
279    return mix(d2, d1, h) - k * h * (1.0 - h);
280}
281
282// Distancia a UNA primitiva de geometría (decals → 1e9, no aportan volumen).
283// La cápsula interpola el radio a lo largo del segmento (cono redondeado aprox;
284// no es SDF exacta con radios distintos → el trace marcha con factor de seguridad).
285// El elipsoide usa la cota de iq (k0*(k0-1)/k1), suficiente para sphere tracing.
286fn ser_prim_d(pr: SerPrim, p: vec3<f32>) -> f32 {
287    let kind = i32(pr.col.w);
288    if (kind == 0) {
289        return length(p - pr.pa.xyz) - pr.pa.w;
290    }
291    if (kind == 1) {
292        let a = pr.pa.xyz;
293        let ab = pr.pb.xyz - a;
294        let t = clamp(dot(p - a, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);
295        return length(p - (a + ab * t)) - mix(pr.pa.w, pr.pb.w, t);
296    }
297    if (kind == 2) {
298        let q = p - pr.pa.xyz;
299        let r = max(pr.pb.xyz, vec3<f32>(1e-3));
300        let k0 = length(q / r);
301        let k1 = length(q / (r * r));
302        return k0 * (k0 - 1.0) / max(k1, 1e-6);
303    }
304    return 1e9;
305}
306
307// Campo del ser `si` en `p` (MUNDO) = smin acumulado de sus primitivas (locales).
308fn ser_map(si: i32, p: vec3<f32>) -> f32 {
309    let pl = ser_local(si, p);
310    let start = i32(seru.hdrs[si].rango.x);
311    let n = i32(seru.hdrs[si].rango.y);
312    var d = 1e9;
313    var primera = true;
314    for (var i = 0; i < n; i = i + 1) {
315        let pr = seru.prims[start + i];
316        if (i32(pr.col.w) >= 3) { continue; }
317        let di = ser_prim_d(pr, pl);
318        // La primera primitiva se toma TAL CUAL. Fundirla contra el 1e9 inicial
319        // pasa por `mix(1e9, di, 1)`, y una GPU que lo evalúa como
320        // `1e9 + (di − 1e9)·1` devuelve 0 en f32 (cancelación): el campo valía 0
321        // desde la esfera envolvente y cada ser se dibujaba como esa esfera
322        // (medido en una Iris Xe: la estrella de utilería salía bola).
323        if (primera) { d = di; primera = false; } else { d = ser_smin(di, d, pr.ext.x); }
324    }
325    return d;
326}
327
328// Color del ser en el punto de superficie `p`: mezcla suave de las primitivas de
329// geometría (transición ENDURECIDA respecto de la geométrica, para que la ropa no
330// sangre de más) y después los DECALS en orden (el último pinta encima), con borde AA.
331fn ser_color(si: i32, p: vec3<f32>) -> vec3<f32> {
332    let pl = ser_local(si, p);
333    let start = i32(seru.hdrs[si].rango.x);
334    let n = i32(seru.hdrs[si].rango.y);
335    var d = 1e9;
336    var col = vec3<f32>(0.5);
337    var primera = true;
338    for (var i = 0; i < n; i = i + 1) {
339        let pr = seru.prims[start + i];
340        if (i32(pr.col.w) >= 3) { continue; }
341        let di = ser_prim_d(pr, pl);
342        // La primera, tal cual (ver `ser_map`: fundirla contra 1e9 cancela a 0).
343        if (primera) { d = di; col = pr.col.rgb; primera = false; continue; }
344        let k = pr.ext.x;
345        let h = clamp(0.5 + 0.5 * (d - di) / max(k, 1e-4), 0.0, 1.0);
346        // Endurecer FUERTE el borde de color (la geometría funde ancho, el color casi
347        // duro): con ×2.6 el pelo sangraba una franja gris-marrón por la mejilla en
348        // toda la juntura smin. ×6 = look vinyl-toy de piezas pintadas.
349        let hc = clamp((h - 0.5) * 6.0 + 0.5, 0.0, 1.0);
350        col = mix(col, pr.col.rgb, hc);
351        d = mix(d, di, h) - k * h * (1.0 - h);
352    }
353    for (var i = 0; i < n; i = i + 1) {
354        let pr = seru.prims[start + i];
355        let kind = i32(pr.col.w);
356        if (kind < 3) { continue; }
357        var dd = 1e9;
358        if (kind == 3) {
359            dd = length(pl - pr.pa.xyz) - pr.pa.w;
360        } else {
361            let a = pr.pa.xyz;
362            let ab = pr.pb.xyz - a;
363            let t = clamp(dot(pl - a, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0);
364            dd = length(pl - (a + ab * t)) - pr.pa.w;
365        }
366        // Borde AA del decal, RELATIVO al tamano del ser (un ser de escena mide
367        // ~2 voxels; un borde fijo de 0.4 vox le comeria la cara entera).
368        let aa = max(seru.hdrs[si].c.w * 0.01, 1e-3);
369        let w = clamp(0.5 - dd / aa, 0.0, 1.0);
370        col = mix(col, pr.col.rgb, w);
371    }
372    return col;
373}
374
375// Normal del ser = gradiente del campo (técnica del tetraedro, 4 evaluaciones).
376fn ser_normal(si: i32, p: vec3<f32>) -> vec3<f32> {
377    // Epsilon RELATIVO al radio envolvente: calibrado a 0.06 para un ser de 72
378    // voxels (radio ~40); a escala de escena (~2 voxels) uno fijo aplastaria las
379    // normales de rasgos mas chicos que el propio epsilon.
380    let e = max(seru.hdrs[si].c.w * 1.5e-3, 1e-3);
381    let k0 = vec3<f32>( 1.0, -1.0, -1.0);
382    let k1 = vec3<f32>(-1.0, -1.0,  1.0);
383    let k2 = vec3<f32>(-1.0,  1.0, -1.0);
384    let k3 = vec3<f32>( 1.0,  1.0,  1.0);
385    let g = k0 * ser_map(si, p + k0 * e)
386          + k1 * ser_map(si, p + k1 * e)
387          + k2 * ser_map(si, p + k2 * e)
388          + k3 * ser_map(si, p + k3 * e);
389    let l = length(g);
390    if (l < 1e-6) { return vec3<f32>(0.0, 1.0, 0.0); }
391    return g / l;
392}
393
394struct SerHit {
395    hit: bool,
396    t: f32,
397    si: i32,
398    p: vec3<f32>,
399};
400
401// Sphere tracing de todos los seres, gateado por esfera envolvente. Devuelve el
402// hit más cercano por debajo de `max_t` (así compite con voxels y entidades).
403// Factor de marcha 0.8 = margen para las SDF aproximadas (cápsula cónica/elipsoide).
404fn trace_sers(ro: vec3<f32>, rd: vec3<f32>, max_t: f32) -> SerHit {
405    var best: SerHit;
406    best.hit = false;
407    best.t = max_t;
408    let ns = i32(seru.count.x);
409    for (var si = 0; si < ns; si = si + 1) {
410        let hdr = seru.hdrs[si];
411        if (hdr.c.w <= 0.0) { continue; }
412        let oc = ro - hdr.c.xyz;
413        let bq = dot(oc, rd);
414        let cq = dot(oc, oc) - hdr.c.w * hdr.c.w;
415        let disc = bq * bq - cq;
416        if (disc < 0.0) { continue; }
417        let sq = sqrt(disc);
418        let t_in = max(-bq - sq, 1e-3);
419        let t_out = -bq + sq;
420        if (t_out <= 0.0 || t_in >= best.t) { continue; }
421        var t = t_in;
422        for (var i = 0; i < 80; i = i + 1) {
423            let p = ro + rd * t;
424            let d = ser_map(si, p);
425            // Hit-eps relativo (0.035 a radio 40): la precision escala con el ser.
426            if (d < max(hdr.c.w * 9e-4, 5e-4)) {
427                if (t < best.t) {
428                    best.hit = true;
429                    best.t = t;
430                    best.si = si;
431                    best.p = p;
432                }
433                break;
434            }
435            t = t + d * 0.8;
436            if (t > min(t_out, best.t)) { break; }
437        }
438    }
439    return best;
440}
441
442// Sombra binaria de los seres: ¿algún ser bloquea el rayo antes de `max_t`?
443// Marcha más gruesa que la primaria (alcanza para sombra dura).
444fn ser_shadow(ro: vec3<f32>, rd: vec3<f32>, max_t: f32) -> bool {
445    return ser_shadow_salvo(ro, rd, max_t, vec3<f32>(1e18));
446}
447
448// Igual, hacia una luz puntual en `luz`: el ser cuya esfera envolvente CONTIENE
449// la luz no cuenta. Es la pieza que la emite (la estrella que se lleva en la
450// mano): sin esto, su propio cuerpo tapaba la luz que tiene adentro y no
451// alumbraba nada.
452fn ser_shadow_salvo(ro: vec3<f32>, rd: vec3<f32>, max_t: f32, luz: vec3<f32>) -> bool {
453    let ns = i32(seru.count.x);
454    for (var si = 0; si < ns; si = si + 1) {
455        let hdr = seru.hdrs[si];
456        if (hdr.c.w <= 0.0) { continue; }
457        if (length(luz - hdr.c.xyz) < hdr.c.w) { continue; }
458        let oc = ro - hdr.c.xyz;
459        let bq = dot(oc, rd);
460        let cq = dot(oc, oc) - hdr.c.w * hdr.c.w;
461        let disc = bq * bq - cq;
462        if (disc < 0.0) { continue; }
463        let sq = sqrt(disc);
464        let t_in = max(-bq - sq, 5e-2);
465        let t_out = min(-bq + sq, max_t);
466        if (t_out <= t_in) { continue; }
467        var t = t_in;
468        let eps = max(hdr.c.w * 1.25e-3, 7e-4);
469        for (var i = 0; i < 48; i = i + 1) {
470            let d = ser_map(si, ro + rd * t);
471            if (d < eps) { return true; }
472            t = t + max(d * 0.9, eps * 0.6);
473            if (t > t_out) { break; }
474        }
475    }
476    return false;
477}
478"#;
479
480/// El WGSL de los seres con los topes del empacador ya sustituidos.
481pub(crate) fn wgsl_ser() -> String {
482    WGSL_SER_TPL
483        .replace("{MAX_SERS}", &VOXEL_MAX_SERS.to_string())
484        .replace("{MAX_PRIMS}", &VOXEL_MAX_SER_PRIMS.to_string())
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    fn ser_de_prueba() -> SdfSer {
492        SdfSer {
493            prims: vec![
494                SdfPrim {
495                    kind: SdfKind::Esfera,
496                    a: [10.0, 20.0, 10.0],
497                    ra: 5.0,
498                    b: [0.0; 3],
499                    rb: 0.0,
500                    color: [1.0, 0.5, 0.2],
501                    blend: 2.0,
502                },
503                SdfPrim {
504                    kind: SdfKind::Capsula,
505                    a: [10.0, 5.0, 10.0],
506                    ra: 2.0,
507                    b: [10.0, 15.0, 10.0],
508                    rb: 1.5,
509                    color: [0.2, 0.4, 0.8],
510                    blend: 1.5,
511                },
512            ],
513            pos: [0.0; 3],
514            yaw: 0.0,
515        }
516    }
517
518    #[test]
519    fn bound_envuelve_las_primitivas() {
520        let s = ser_de_prueba();
521        let (c, r) = s.bound();
522        // La esfera (centro y=20, radio 5) llega a y=25; la cápsula baja a y=3.
523        // El bound debe cubrir ambos extremos desde su centro.
524        assert!(c[1] > 3.0 && c[1] < 25.0, "centro dentro del cuerpo: {c:?}");
525        assert!(r > 10.0, "radio cubre el ensamble: {r}");
526        assert!(c[1] - r < 3.0 && c[1] + r > 25.0, "extremos cubiertos");
527    }
528
529    /// El uniform de seres tiene que entrar en el límite de WebGPU
530    /// (`maxUniformBufferBindingSize` = 65 536). Si alguien sube un tope sin mirar
531    /// esta cuenta, el pipeline no se crea y el cuadro sale VACÍO — sin error de
532    /// compilación que lo delate.
533    #[test]
534    fn el_uniform_entra_en_el_limite_de_webgpu() {
535        let bytes = 16 + VOXEL_MAX_SERS * 48 + VOXEL_MAX_SER_PRIMS * 64;
536        assert!(
537            bytes <= 65_536,
538            "el uniform de seres mide {bytes} B y el tope de WebGPU es 65 536"
539        );
540        assert_eq!(pack_sers(&[]).len(), bytes, "el empacador escribe el layout completo");
541    }
542
543    /// El WGSL declara los arrays con los MISMOS topes que empaca `pack_sers`. Un
544    /// array más chico en el shader lee basura (o recorta el reparto) sin avisar.
545    #[test]
546    fn el_wgsl_declara_los_topes_del_empacador() {
547        let src = wgsl_ser();
548        assert!(
549            src.contains(&format!("array<SerHdr, {VOXEL_MAX_SERS}>")),
550            "el shader declara {VOXEL_MAX_SERS} headers"
551        );
552        assert!(
553            src.contains(&format!("array<SerPrim, {VOXEL_MAX_SER_PRIMS}>")),
554            "el shader declara {VOXEL_MAX_SER_PRIMS} primitivas"
555        );
556        assert!(!src.contains("{MAX_"), "no quedan marcas sin sustituir");
557    }
558
559    #[test]
560    fn pack_layout_exacto() {
561        let sers = [ser_de_prueba()];
562        let buf = pack_sers(&sers);
563        assert_eq!(buf.len(), 16 + VOXEL_MAX_SERS * 48 + VOXEL_MAX_SER_PRIMS * 64);
564        // count.x = 1
565        assert_eq!(f32::from_ne_bytes(buf[0..4].try_into().unwrap()), 1.0);
566        // hdr[0].meta = start 0, count 2
567        let meta_off = 16 + 16;
568        assert_eq!(f32::from_ne_bytes(buf[meta_off..meta_off + 4].try_into().unwrap()), 0.0);
569        assert_eq!(
570            f32::from_ne_bytes(buf[meta_off + 4..meta_off + 8].try_into().unwrap()),
571            2.0
572        );
573        // prim[1].col.w = kind cápsula = 1.0
574        let p1 = 16 + VOXEL_MAX_SERS * 48 + 64 + 32;
575        assert_eq!(f32::from_ne_bytes(buf[p1 + 12..p1 + 16].try_into().unwrap()), 1.0);
576    }
577
578    #[test]
579    fn un_ser_que_no_entra_se_descarta_entero() {
580        // Uno gordo que ocupa casi todo + otro normal: el segundo NO entra, y no debe
581        // colarse a medias (un torso sin cabeza es peor que un ser ausente).
582        let mut gordo = ser_de_prueba();
583        gordo.prims = gordo.prims.iter().cycle().take(VOXEL_MAX_SER_PRIMS - 1).copied().collect();
584        let buf = pack_sers(&[gordo, ser_de_prueba()]);
585        assert_eq!(f32::from_ne_bytes(buf[0..4].try_into().unwrap()), 1.0, "sólo el que entra");
586    }
587
588    #[test]
589    fn pack_respeta_topes() {
590        let gordo = SdfSer {
591            prims: vec![
592                SdfPrim {
593                    kind: SdfKind::Esfera,
594                    a: [0.0; 3],
595                    ra: 1.0,
596                    b: [0.0; 3],
597                    rb: 0.0,
598                    color: [1.0; 3],
599                    blend: 0.5,
600                };
601                VOXEL_MAX_SER_PRIMS + 40
602            ],
603            pos: [0.0; 3],
604            yaw: 0.0,
605        };
606        let buf = pack_sers(&[gordo]);
607        // hdr[0].count clampeado al tope global.
608        let meta_off = 16 + 16;
609        let count = f32::from_ne_bytes(buf[meta_off + 4..meta_off + 8].try_into().unwrap());
610        assert_eq!(count as usize, VOXEL_MAX_SER_PRIMS);
611    }
612}