tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
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}