//! **Seres SDF** — la segunda técnica de graficación del motor: personajes orgánicos //! definidos como un ensamble de **primitivas analíticas** (esferas / cápsulas / //! elipsoides) fundidas por **unión suave** (`smin`), trazadas por *sphere tracing* //! dentro del MISMO fragment shader del ray-march voxel (la receta de *Dreams* de //! Media Molecule). //! //! ## Por qué existe (lección 2026-07-27) //! //! El camino `humanoide_voxel` + `trace_smooth` modela al ser con elipsoides suaves, //! lo **voxeliza a una grilla binaria** y el shader intenta *reconstruir* la superficie //! lisa desde ese campo 0/1 — la información de la forma se destruye en la voxelización //! y la fidelidad queda en ~1 voxel (bultos, anillos). Acá el ensamble se evalúa //! **analíticamente**: silueta matemáticamente lisa a cualquier distancia, normales //! exactas (gradiente del campo), y el redondeo es parámetro de diseño (`blend`), no //! reconstrucción. //! //! ## Composición con el mundo //! //! `trace_sers` compite por `t` contra el mundo voxel y las [`Entity3d`] //! (cajas analíticas) en el fs; el hit escribe `frag_depth` → oclusión mutua con //! voxels Y mallas (depth compartido de `Scene3d`). Los seres proyectan sombra //! (el shadow ray del sol y de las puntuales también los marcha) y se auto-sombrean. //! Sombreado propio: **rampa toon** (bandas cuantizadas con AA) + **rim light** — //! el ser lleva su respuesta de luz "de personaje", el mundo conserva la suya. //! //! ## Cara = decals (kind `Decal*`) //! //! Las primitivas decal **no aportan geometría**: re-pintan el color de la superficie //! donde su campo es negativo (ojos como discos 2D expresivos sobre la cabeza 3D, //! sonrisa como arco fino, rubor) — el truco de *Animal Crossing*/*Wind Waker*. Son //! datos por frame → parpadeo/expresión = mover/achicar decals desde CPU. //! //! Todo va en un uniform propio (binding 5), gateado por `count == 0` → costo ~cero //! cuando no hay seres (retrocompat byte-idéntico). /// Tope de seres SDF por frame. /// /// **Subido de 16 a 40 el 2026-09-06** (el tope viejo era la bandada del studio, y /// una CALLE con gente caminando no entra en dieciséis): el uniform pasa de 16 144 a /// 42 896 bytes. Eso deja atrás el mínimo de 16 KiB de un UBO de WebGL2/GLES3 y se /// para en el límite de WebGPU, `maxUniformBufferBindingSize = 65 536` — el camino /// que este renderer usa de verdad (ray-march con `texture_3d` y bucles de 80 pasos; /// nunca corrió sobre el fallback WebGL, y `check-llimphi-web.sh` no lo compila). /// Si algún día hace falta el fallback GL, la salida no es bajar el tope sino mudar /// las primitivas a un storage buffer. pub const VOXEL_MAX_SERS: usize = 40; /// Tope de primitivas SDF TOTALES por frame (repartidas entre los seres). /// El uniform completo debe quedar bajo el límite de WebGPU: /// `16 + 40×48 + 640×64 = 42 896` bytes (de 65 536). /// /// Con LOD (los de lejos pierden la cara, que a esa distancia es sub-píxel) 640 /// primitivas dan para una vereda con veinte personas, o para una selva con seis /// árboles (~40 prims cada uno) y su fauna. Ver `llimphi_voxel::DetalleSer`. pub const VOXEL_MAX_SER_PRIMS: usize = 640; /// Clase de primitiva SDF. Las `Decal*` no tienen volumen: sólo re-pintan color. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SdfKind { /// Esfera: `a` = centro, `ra` = radio. Esfera, /// Cápsula/cono redondeado: segmento `a→b`, radio `ra` en `a` → `rb` en `b`. Capsula, /// Elipsoide: `a` = centro, `b` = semiejes (aprox. de iq, suficiente para trace). Elipsoide, /// Decal esférico: pinta `color` donde `|p−a| < ra` sobre la superficie ya trazada. DecalEsfera, /// Decal capsular: pinta `color` a distancia `< ra` del segmento `a→b`. DecalCapsula, } impl SdfKind { fn code(self) -> f32 { match self { SdfKind::Esfera => 0.0, SdfKind::Capsula => 1.0, SdfKind::Elipsoide => 2.0, SdfKind::DecalEsfera => 3.0, SdfKind::DecalCapsula => 4.0, } } } /// Una primitiva del ensamble. Coordenadas en espacio de voxel `[0, dim]` (como /// [`Entity3d`](crate::Entity3d)); color RGB lineal `[0,1]`; `blend` = radio de /// unión suave (voxels) con lo acumulado ANTES en la lista (orden importa). /// /// Un color con algún canal **mayor que 1,5** es **luz propia**: la pieza se ve /// con su tono (normalizado al canal más alto) aunque esté en sombra o sea de /// noche. #[derive(Debug, Clone, Copy)] pub struct SdfPrim { pub kind: SdfKind, pub a: [f32; 3], pub ra: f32, pub b: [f32; 3], pub rb: f32, pub color: [f32; 3], pub blend: f32, } /// Un ser = lista ordenada de primitivas (geometría primero, decals después) en /// espacio LOCAL (pies en el origen, mirando `+Z`), posado en el mundo por /// `pos` + `yaw`. El shader lleva cada punto a espacio local antes de evaluar el /// campo — así el elipsoide (semiejes alineados a ejes) rota sin deformarse. #[derive(Debug, Clone, Default)] pub struct SdfSer { pub prims: Vec, /// Posición del origen local en coords de voxel `[0, dim]`. pub pos: [f32; 3], /// Rumbo (radianes alrededor de +Y; `0` = mirando `+Z`). pub yaw: f32, } impl SdfSer { /// Posa el ser: `pos` en coords de voxel `[0, dim]`, `yaw` en radianes. pub fn posado(mut self, pos: [f32; 3], yaw: f32) -> Self { self.pos = pos; self.yaw = yaw; self } /// Esfera envolvente `(centro, radio)` del ensamble en espacio LOCAL (el /// empaque la lleva a mundo con `pos`/`yaw`). Incluye el margen de blend. pub fn bound(&self) -> ([f32; 3], f32) { if self.prims.is_empty() { return ([0.0; 3], 0.0); } let mut lo = [f32::INFINITY; 3]; let mut hi = [f32::NEG_INFINITY; 3]; let mut kmax = 0.0f32; for p in &self.prims { let r = p.ra.max(p.rb).max(match p.kind { SdfKind::Elipsoide => p.b[0].max(p.b[1]).max(p.b[2]), _ => 0.0, }); for i in 0..3 { let (amin, amax) = match p.kind { // El elipsoide se extiende `b` (semiejes) desde `a`; su `b` no es punto. SdfKind::Elipsoide => (p.a[i] - p.b[i], p.a[i] + p.b[i]), _ => (p.a[i].min(p.b[i]) - r, p.a[i].max(p.b[i]) + r), }; lo[i] = lo[i].min(amin); hi[i] = hi[i].max(amax); } kmax = kmax.max(p.blend); } let c = [ (lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5, (lo[2] + hi[2]) * 0.5, ]; let dx = (hi[0] - lo[0]) * 0.5; let dy = (hi[1] - lo[1]) * 0.5; let dz = (hi[2] - lo[2]) * 0.5; let r = (dx * dx + dy * dy + dz * dz).sqrt() + kmax + 1.0; (c, r) } } /// Empaca los seres al layout del uniform `SerU` del shader (binding 5). /// `count(16) + hdrs(16×48) + prims(240×64)` = 16 144 bytes exactos. pub(crate) fn pack_sers(sers: &[SdfSer]) -> Vec { let mut out = Vec::with_capacity(16 + VOXEL_MAX_SERS * 48 + VOXEL_MAX_SER_PRIMS * 64); let push4 = |buf: &mut Vec, v: [f32; 4]| { for x in v { buf.extend_from_slice(&x.to_ne_bytes()); } }; // Header + prims aplanadas, respetando ambos topes. La esfera envolvente se // lleva a MUNDO acá (centro local rotado por yaw + trasladado por pos). struct Hdr { c: [f32; 3], r: f32, start: usize, count: usize, pos: [f32; 3], yaw: f32, } let mut flat: Vec<&SdfPrim> = Vec::new(); let mut hdrs: Vec = Vec::new(); for s in sers.iter().take(VOXEL_MAX_SERS) { let start = flat.len(); let room = VOXEL_MAX_SER_PRIMS.saturating_sub(start); // Un ser entra ENTERO o no entra. Truncarlo por la mitad no ahorra nada y // pinta un mutilado (torso sin cabeza) — el corte cae donde caiga la lista. // La única excepción es un ser que por sí solo excede el tope: ahí, mejor // recortado que nada. Los que no entran se descartan: el llamador debe // ordenarlos por cercanía y bajarles el detalle antes (`reparto_sdf`). if s.prims.len() > room { if start > 0 { break; } } let take = s.prims.len().min(room); flat.extend(s.prims.iter().take(take)); let (cl, r) = s.bound(); let (sy, cy) = s.yaw.sin_cos(); let c = [ s.pos[0] + cy * cl[0] + sy * cl[2], s.pos[1] + cl[1], s.pos[2] - sy * cl[0] + cy * cl[2], ]; hdrs.push(Hdr { c, r, start, count: take, pos: s.pos, yaw: s.yaw }); } push4(&mut out, [hdrs.len() as f32, 0.0, 0.0, 0.0]); for i in 0..VOXEL_MAX_SERS { match hdrs.get(i) { Some(h) => { push4(&mut out, [h.c[0], h.c[1], h.c[2], h.r]); push4(&mut out, [h.start as f32, h.count as f32, h.yaw, 0.0]); push4(&mut out, [h.pos[0], h.pos[1], h.pos[2], 0.0]); } None => { for _ in 0..3 { push4(&mut out, [0.0; 4]); } } } } for i in 0..VOXEL_MAX_SER_PRIMS { if let Some(p) = flat.get(i) { push4(&mut out, [p.a[0], p.a[1], p.a[2], p.ra]); push4(&mut out, [p.b[0], p.b[1], p.b[2], p.rb]); push4(&mut out, [p.color[0], p.color[1], p.color[2], p.kind.code()]); push4(&mut out, [p.blend.max(1e-3), 0.0, 0.0, 0.0]); } else { for _ in 0..4 { push4(&mut out, [0.0; 4]); } } } out } /// Fragmento WGSL de los seres SDF: uniform (binding 5) + campo `smin` + sphere /// tracing + decals + sombra. Se antepone al WGSL principal del voxel renderer /// (el orden de declaración no importa en WGSL; el fs lo invoca). /// /// Los tamaños de los dos arrays los pone [`wgsl_ser`] desde [`VOXEL_MAX_SERS`] y /// [`VOXEL_MAX_SER_PRIMS`]: escritos a mano se separaban del empacador en cuanto /// alguien subía un tope, y un uniform más chico que el buffer NO es un error de /// wgpu — el shader lee basura de las últimas primitivas. const WGSL_SER_TPL: &str = r#" struct SerPrim { pa: vec4, // a.xyz, ra pb: vec4, // b.xyz, rb col: vec4, // rgb, kind (0 esfera / 1 cápsula / 2 elipsoide / 3 decal-esfera / 4 decal-cápsula) ext: vec4, // x = blend (radio smin), yzw libres }; struct SerHdr { c: vec4, // centro.xyz (MUNDO), radio de esfera envolvente rango: vec4, // x = prim inicial, y = cantidad, z = yaw (rad) pos: vec4, // xyz = origen local del ser en mundo }; struct SerU { count: vec4, // x = seres vivos hdrs: array, prims: array, }; @group(0) @binding(5) var seru: SerU; // Lleva un punto de MUNDO al espacio LOCAL del ser `si` (des-traslada y des-rota // el yaw). Las primitivas viven en local — así el elipsoide (semiejes alineados // a ejes) rota sin deformarse. Rotación = isometría → las distancias del campo // siguen válidas en mundo (el gradiente/normal sale bien sin des-rotar nada). fn ser_local(si: i32, p: vec3) -> vec3 { let d = p - seru.hdrs[si].pos.xyz; let yaw = seru.hdrs[si].rango.z; let cy = cos(yaw); let sy = sin(yaw); return vec3(cy * d.x - sy * d.z, d.y, sy * d.x + cy * d.z); } // Unión suave polinómica (iq): junta orgánica entre primitivas. fn ser_smin(d1: f32, d2: f32, k: f32) -> f32 { let h = clamp(0.5 + 0.5 * (d2 - d1) / max(k, 1e-4), 0.0, 1.0); return mix(d2, d1, h) - k * h * (1.0 - h); } // Distancia a UNA primitiva de geometría (decals → 1e9, no aportan volumen). // La cápsula interpola el radio a lo largo del segmento (cono redondeado aprox; // no es SDF exacta con radios distintos → el trace marcha con factor de seguridad). // El elipsoide usa la cota de iq (k0*(k0-1)/k1), suficiente para sphere tracing. fn ser_prim_d(pr: SerPrim, p: vec3) -> f32 { let kind = i32(pr.col.w); if (kind == 0) { return length(p - pr.pa.xyz) - pr.pa.w; } if (kind == 1) { let a = pr.pa.xyz; let ab = pr.pb.xyz - a; let t = clamp(dot(p - a, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0); return length(p - (a + ab * t)) - mix(pr.pa.w, pr.pb.w, t); } if (kind == 2) { let q = p - pr.pa.xyz; let r = max(pr.pb.xyz, vec3(1e-3)); let k0 = length(q / r); let k1 = length(q / (r * r)); return k0 * (k0 - 1.0) / max(k1, 1e-6); } return 1e9; } // Campo del ser `si` en `p` (MUNDO) = smin acumulado de sus primitivas (locales). fn ser_map(si: i32, p: vec3) -> f32 { let pl = ser_local(si, p); let start = i32(seru.hdrs[si].rango.x); let n = i32(seru.hdrs[si].rango.y); var d = 1e9; var primera = true; for (var i = 0; i < n; i = i + 1) { let pr = seru.prims[start + i]; if (i32(pr.col.w) >= 3) { continue; } let di = ser_prim_d(pr, pl); // La primera primitiva se toma TAL CUAL. Fundirla contra el 1e9 inicial // pasa por `mix(1e9, di, 1)`, y una GPU que lo evalúa como // `1e9 + (di − 1e9)·1` devuelve 0 en f32 (cancelación): el campo valía 0 // desde la esfera envolvente y cada ser se dibujaba como esa esfera // (medido en una Iris Xe: la estrella de utilería salía bola). if (primera) { d = di; primera = false; } else { d = ser_smin(di, d, pr.ext.x); } } return d; } // Color del ser en el punto de superficie `p`: mezcla suave de las primitivas de // geometría (transición ENDURECIDA respecto de la geométrica, para que la ropa no // sangre de más) y después los DECALS en orden (el último pinta encima), con borde AA. fn ser_color(si: i32, p: vec3) -> vec3 { let pl = ser_local(si, p); let start = i32(seru.hdrs[si].rango.x); let n = i32(seru.hdrs[si].rango.y); var d = 1e9; var col = vec3(0.5); var primera = true; for (var i = 0; i < n; i = i + 1) { let pr = seru.prims[start + i]; if (i32(pr.col.w) >= 3) { continue; } let di = ser_prim_d(pr, pl); // La primera, tal cual (ver `ser_map`: fundirla contra 1e9 cancela a 0). if (primera) { d = di; col = pr.col.rgb; primera = false; continue; } let k = pr.ext.x; let h = clamp(0.5 + 0.5 * (d - di) / max(k, 1e-4), 0.0, 1.0); // Endurecer FUERTE el borde de color (la geometría funde ancho, el color casi // duro): con ×2.6 el pelo sangraba una franja gris-marrón por la mejilla en // toda la juntura smin. ×6 = look vinyl-toy de piezas pintadas. let hc = clamp((h - 0.5) * 6.0 + 0.5, 0.0, 1.0); col = mix(col, pr.col.rgb, hc); d = mix(d, di, h) - k * h * (1.0 - h); } for (var i = 0; i < n; i = i + 1) { let pr = seru.prims[start + i]; let kind = i32(pr.col.w); if (kind < 3) { continue; } var dd = 1e9; if (kind == 3) { dd = length(pl - pr.pa.xyz) - pr.pa.w; } else { let a = pr.pa.xyz; let ab = pr.pb.xyz - a; let t = clamp(dot(pl - a, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0); dd = length(pl - (a + ab * t)) - pr.pa.w; } // Borde AA del decal, RELATIVO al tamano del ser (un ser de escena mide // ~2 voxels; un borde fijo de 0.4 vox le comeria la cara entera). let aa = max(seru.hdrs[si].c.w * 0.01, 1e-3); let w = clamp(0.5 - dd / aa, 0.0, 1.0); col = mix(col, pr.col.rgb, w); } return col; } // Normal del ser = gradiente del campo (técnica del tetraedro, 4 evaluaciones). fn ser_normal(si: i32, p: vec3) -> vec3 { // Epsilon RELATIVO al radio envolvente: calibrado a 0.06 para un ser de 72 // voxels (radio ~40); a escala de escena (~2 voxels) uno fijo aplastaria las // normales de rasgos mas chicos que el propio epsilon. let e = max(seru.hdrs[si].c.w * 1.5e-3, 1e-3); let k0 = vec3( 1.0, -1.0, -1.0); let k1 = vec3(-1.0, -1.0, 1.0); let k2 = vec3(-1.0, 1.0, -1.0); let k3 = vec3( 1.0, 1.0, 1.0); let g = k0 * ser_map(si, p + k0 * e) + k1 * ser_map(si, p + k1 * e) + k2 * ser_map(si, p + k2 * e) + k3 * ser_map(si, p + k3 * e); let l = length(g); if (l < 1e-6) { return vec3(0.0, 1.0, 0.0); } return g / l; } struct SerHit { hit: bool, t: f32, si: i32, p: vec3, }; // Sphere tracing de todos los seres, gateado por esfera envolvente. Devuelve el // hit más cercano por debajo de `max_t` (así compite con voxels y entidades). // Factor de marcha 0.8 = margen para las SDF aproximadas (cápsula cónica/elipsoide). fn trace_sers(ro: vec3, rd: vec3, max_t: f32) -> SerHit { var best: SerHit; best.hit = false; best.t = max_t; let ns = i32(seru.count.x); for (var si = 0; si < ns; si = si + 1) { let hdr = seru.hdrs[si]; if (hdr.c.w <= 0.0) { continue; } let oc = ro - hdr.c.xyz; let bq = dot(oc, rd); let cq = dot(oc, oc) - hdr.c.w * hdr.c.w; let disc = bq * bq - cq; if (disc < 0.0) { continue; } let sq = sqrt(disc); let t_in = max(-bq - sq, 1e-3); let t_out = -bq + sq; if (t_out <= 0.0 || t_in >= best.t) { continue; } var t = t_in; for (var i = 0; i < 80; i = i + 1) { let p = ro + rd * t; let d = ser_map(si, p); // Hit-eps relativo (0.035 a radio 40): la precision escala con el ser. if (d < max(hdr.c.w * 9e-4, 5e-4)) { if (t < best.t) { best.hit = true; best.t = t; best.si = si; best.p = p; } break; } t = t + d * 0.8; if (t > min(t_out, best.t)) { break; } } } return best; } // Sombra binaria de los seres: ¿algún ser bloquea el rayo antes de `max_t`? // Marcha más gruesa que la primaria (alcanza para sombra dura). fn ser_shadow(ro: vec3, rd: vec3, max_t: f32) -> bool { return ser_shadow_salvo(ro, rd, max_t, vec3(1e18)); } // Igual, hacia una luz puntual en `luz`: el ser cuya esfera envolvente CONTIENE // la luz no cuenta. Es la pieza que la emite (la estrella que se lleva en la // mano): sin esto, su propio cuerpo tapaba la luz que tiene adentro y no // alumbraba nada. fn ser_shadow_salvo(ro: vec3, rd: vec3, max_t: f32, luz: vec3) -> bool { let ns = i32(seru.count.x); for (var si = 0; si < ns; si = si + 1) { let hdr = seru.hdrs[si]; if (hdr.c.w <= 0.0) { continue; } if (length(luz - hdr.c.xyz) < hdr.c.w) { continue; } let oc = ro - hdr.c.xyz; let bq = dot(oc, rd); let cq = dot(oc, oc) - hdr.c.w * hdr.c.w; let disc = bq * bq - cq; if (disc < 0.0) { continue; } let sq = sqrt(disc); let t_in = max(-bq - sq, 5e-2); let t_out = min(-bq + sq, max_t); if (t_out <= t_in) { continue; } var t = t_in; let eps = max(hdr.c.w * 1.25e-3, 7e-4); for (var i = 0; i < 48; i = i + 1) { let d = ser_map(si, ro + rd * t); if (d < eps) { return true; } t = t + max(d * 0.9, eps * 0.6); if (t > t_out) { break; } } } return false; } "#; /// El WGSL de los seres con los topes del empacador ya sustituidos. pub(crate) fn wgsl_ser() -> String { WGSL_SER_TPL .replace("{MAX_SERS}", &VOXEL_MAX_SERS.to_string()) .replace("{MAX_PRIMS}", &VOXEL_MAX_SER_PRIMS.to_string()) } #[cfg(test)] mod tests { use super::*; fn ser_de_prueba() -> SdfSer { SdfSer { prims: vec![ SdfPrim { kind: SdfKind::Esfera, a: [10.0, 20.0, 10.0], ra: 5.0, b: [0.0; 3], rb: 0.0, color: [1.0, 0.5, 0.2], blend: 2.0, }, SdfPrim { kind: SdfKind::Capsula, a: [10.0, 5.0, 10.0], ra: 2.0, b: [10.0, 15.0, 10.0], rb: 1.5, color: [0.2, 0.4, 0.8], blend: 1.5, }, ], pos: [0.0; 3], yaw: 0.0, } } #[test] fn bound_envuelve_las_primitivas() { let s = ser_de_prueba(); let (c, r) = s.bound(); // La esfera (centro y=20, radio 5) llega a y=25; la cápsula baja a y=3. // El bound debe cubrir ambos extremos desde su centro. assert!(c[1] > 3.0 && c[1] < 25.0, "centro dentro del cuerpo: {c:?}"); assert!(r > 10.0, "radio cubre el ensamble: {r}"); assert!(c[1] - r < 3.0 && c[1] + r > 25.0, "extremos cubiertos"); } /// El uniform de seres tiene que entrar en el límite de WebGPU /// (`maxUniformBufferBindingSize` = 65 536). Si alguien sube un tope sin mirar /// esta cuenta, el pipeline no se crea y el cuadro sale VACÍO — sin error de /// compilación que lo delate. #[test] fn el_uniform_entra_en_el_limite_de_webgpu() { let bytes = 16 + VOXEL_MAX_SERS * 48 + VOXEL_MAX_SER_PRIMS * 64; assert!( bytes <= 65_536, "el uniform de seres mide {bytes} B y el tope de WebGPU es 65 536" ); assert_eq!(pack_sers(&[]).len(), bytes, "el empacador escribe el layout completo"); } /// El WGSL declara los arrays con los MISMOS topes que empaca `pack_sers`. Un /// array más chico en el shader lee basura (o recorta el reparto) sin avisar. #[test] fn el_wgsl_declara_los_topes_del_empacador() { let src = wgsl_ser(); assert!( src.contains(&format!("array")), "el shader declara {VOXEL_MAX_SERS} headers" ); assert!( src.contains(&format!("array")), "el shader declara {VOXEL_MAX_SER_PRIMS} primitivas" ); assert!(!src.contains("{MAX_"), "no quedan marcas sin sustituir"); } #[test] fn pack_layout_exacto() { let sers = [ser_de_prueba()]; let buf = pack_sers(&sers); assert_eq!(buf.len(), 16 + VOXEL_MAX_SERS * 48 + VOXEL_MAX_SER_PRIMS * 64); // count.x = 1 assert_eq!(f32::from_ne_bytes(buf[0..4].try_into().unwrap()), 1.0); // hdr[0].meta = start 0, count 2 let meta_off = 16 + 16; assert_eq!(f32::from_ne_bytes(buf[meta_off..meta_off + 4].try_into().unwrap()), 0.0); assert_eq!( f32::from_ne_bytes(buf[meta_off + 4..meta_off + 8].try_into().unwrap()), 2.0 ); // prim[1].col.w = kind cápsula = 1.0 let p1 = 16 + VOXEL_MAX_SERS * 48 + 64 + 32; assert_eq!(f32::from_ne_bytes(buf[p1 + 12..p1 + 16].try_into().unwrap()), 1.0); } #[test] fn un_ser_que_no_entra_se_descarta_entero() { // Uno gordo que ocupa casi todo + otro normal: el segundo NO entra, y no debe // colarse a medias (un torso sin cabeza es peor que un ser ausente). let mut gordo = ser_de_prueba(); gordo.prims = gordo.prims.iter().cycle().take(VOXEL_MAX_SER_PRIMS - 1).copied().collect(); let buf = pack_sers(&[gordo, ser_de_prueba()]); assert_eq!(f32::from_ne_bytes(buf[0..4].try_into().unwrap()), 1.0, "sólo el que entra"); } #[test] fn pack_respeta_topes() { let gordo = SdfSer { prims: vec![ SdfPrim { kind: SdfKind::Esfera, a: [0.0; 3], ra: 1.0, b: [0.0; 3], rb: 0.0, color: [1.0; 3], blend: 0.5, }; VOXEL_MAX_SER_PRIMS + 40 ], pos: [0.0; 3], yaw: 0.0, }; let buf = pack_sers(&[gordo]); // hdr[0].count clampeado al tope global. let meta_off = 16 + 16; let count = f32::from_ne_bytes(buf[meta_off + 4..meta_off + 8].try_into().unwrap()); assert_eq!(count as usize, VOXEL_MAX_SER_PRIMS); } }