makepad/libs/model/tests/portable_atlas.rs
Admin 8e05517f07 render, model, scene, audio_synth, platform: the game renderer's lights, shadows, GI, VFX and HDR, a character kit, a shared synth, and the platform hooks the Sandbox needed -- libs/render is split into renderer/ and shaders/ modules (pure moves first) and gets stable cascaded shadow maps with a local shadow atlas, cascaded voxel-traced probe GI with SSAO (the CPU BVH placement and analytic emitters are gone), an opt-in HDR scene pass (Renderer::set_hdr_output: AgX, bloom, auto-exposure, FXAA), GPU particles with presets and decals (libs/scene/vfx, with a CPU fallback where float16 blending is missing), world streaming, skinned animation layers and a composed HUD (the HUD document no longer caps its element count; CameraState gains a far plane). Fab and Stage keep the 8-bit lane and in-scene VFX; only hosts that opt in change. libs/model gains the character kit (body, head, gear, clips, presets, FPS arms), construction shapes and decals, bevels, model programs with weathering, and import; mesh_edit decimation collapses every independent candidate per adjacency pass instead of one (a 4.6k-face mesh took 10 s). libs/csg drops the document/rig evaluator the Sandbox no longer uses. New libs/audio_synth (makepad-audio-synth): band-limited oscillators, filters, physically inspired engines, a sound-recipe format, FDN reverb, a spatializer and a look-ahead limiter, with the piano, drum kits, SoundFonts and Ironfish behind instruments; Stage's audio_dsp re-exports it. Platform: GpuInfo::float16_blend_targets, with Metal building an RGBA16Float PSO pair per Bgra8 shader on first use and Vulkan reporting the device's RGBA16F blend support; the WGSL backend forwards only the instance fields the fragment stage reads (39 vec4 varyings overflowed NVIDIA's limit and lost the device); an unbound Vulkan texture_depth slot gets the depth dummy instead of a colour view; an over-budget remote raw grab is box-halved until it fits instead of failing; Windows implements game_input_infos and XInput haptics, DirectInput FFB starts its constant force once and takes exclusive mode only in the foreground, iOS/tvOS get Core Haptics, and game_input gains an additive force API. The AI hub's Claude Code provider can serve one MCP server's tools natively (--mcp-config/--allowedTools, no think-tag litter for redacted thinking), the MCP server answers tool results with image content, and the fleet Qwen provider keeps its vision observations on the row they annotate. widgets gain the cyberpunk desktop style, and a Splash isolate inherits the stylesheet of the VM that allocates it. Checked: cargo check --workspace on macOS (clean apart from apps/source-library's own warnings), makepad-platform/widgets/render/scene/model/audio-synth/mesh-edit/csg for x86_64-unknown-linux-gnu and x86_64-pc-windows-msvc (clean); tests: platform 318, render 488, widgets-core 1307, model 158, scene 35, audio-synth 40, mesh-edit 9, all passing.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:55:13 +02:00

137 lines
8.2 KiB
Rust

use makepad_model::*;
fn apply(doc: &mut Document, id: &str, operations: Vec<Operation>) {
doc.apply(Transaction { request_id: id.into(), expected: doc.head(), operations }, None).unwrap();
}
fn texture(limits: &Limits) -> Vec<u8> {
Texture { width: 2, height: 2, rgb: vec![128,160,200, 240,40,80, 20,220,90, 80,100,250] }.to_png(limits).unwrap()
}
fn document(two_materials: bool, uv_scale: f64, uv_offset: f64) -> Document {
let mut doc = Document::new(Limits::default()).unwrap();
let mut ops = vec![Operation::Plane { object: "a".into(), size: [2.0;2] },
Operation::SetMaterial { material: 0, value: Material { color: [0.25,0.5,0.75], base_color_png: texture(doc.limits()) } }];
if two_materials {
ops.push(Operation::Plane { object: "b".into(), size: [2.0;2] });
ops.push(Operation::SetMaterial { material: 7, value: Material { color: [0.8,0.2,0.4], base_color_png: texture(doc.limits()) } });
}
apply(&mut doc, "planes", ops);
let mut ops = Vec::new();
for (name, mesh) in doc.objects() {
for corner in mesh.corners() {
let p = mesh.vertex(corner.vertex).unwrap().position;
ops.push(Operation::SetUv { object: name.into(), corner: corner.id,
uv: [(p[0]+1.0)*0.5*uv_scale+uv_offset, (p[2]+1.0)*0.5*uv_scale+uv_offset] });
}
if name == "b" { ops.push(Operation::AssignMaterial { object: name.into(), faces: mesh.faces().iter().map(|f|f.id).collect(), material: 7 }); }
}
ops.push(Operation::SetSkeleton { skeleton: Skeleton { joints: vec![Joint { name: "root".into(), parent: None, translation: [0.;3] }] } });
for name in ["a", "b"].into_iter().take(if two_materials {2} else {1}) { ops.push(Operation::AutoWeights { object: name.into() }); }
for name in ["idle", "walk"] { ops.push(Operation::SetClip { clip: AnimationClip { name: name.into(), channels: vec![AnimationChannel {
joint: 0, path: AnimationPath::Translation, keys: vec![Keyframe { time: 0., value: [0.;4] }, Keyframe { time: 1., value: [0.,0.1,0.,0.] }],
}] } }); }
apply(&mut doc, "skin", ops);
doc
}
fn linear(byte: u8) -> f64 {
let value=byte as f64/255.; if value <=0.04045 {value/12.92} else {((value+0.055)/1.055).powf(2.4)}
}
// This consumer reads only the compiled PNG and UV accessor. Like the old
// character loader, it never consults source materials or the authoring engine.
fn sample(image: &Texture, uv: [f64;2]) -> [f64;3] {
let at=[uv[0]*image.width as f64-0.5, uv[1]*image.height as f64-0.5];
let low=[at[0].floor() as i64,at[1].floor() as i64];
let fraction=[at[0]-at[0].floor(),at[1]-at[1].floor()];
let mut color=[0.;3];
for y in 0..2 {for x in 0..2 {
let sx=(low[0]+x).rem_euclid(image.width as i64) as usize;
let sy=(low[1]+y).rem_euclid(image.height as i64) as usize;
let weight=if x==0 {1.-fraction[0]}else{fraction[0]} * if y==0 {1.-fraction[1]}else{fraction[1]};
for lane in 0..3 {color[lane]+=linear(image.rgb[(sy*image.width as usize+sx)*3+lane])*weight;}
}}
color
}
fn original_uvs(doc: &Document, object: &str) -> Vec<[f64;2]> {
let mut ctx=mesh::Context::new(doc.limits().mesh.clone(),None);
let triangles=doc.object(object).unwrap().triangulate(&mut ctx).unwrap();
triangles.triangles.iter().flat_map(|triangle|triangle.indices.iter().map(|&index|triangles.vertices[index as usize].uv)).collect()
}
fn compare_first_image(doc: &Document, compiled: &CompiledModel) {
let loaded=makepad_gltf::load_gltf_from_bytes(&compiled.glb,None).unwrap();
assert_eq!(loaded.document.materials_slice().len(),1);
assert_eq!(loaded.document.images_slice().len(),1);
let png=makepad_gltf::load_image_bytes(&loaded,0).unwrap();
let image=Texture::from_png(&png,doc.limits()).unwrap();
let factor=loaded.document.materials_slice()[0].pbr_metallic_roughness.as_ref().unwrap().base_color_factor.unwrap_or([1.;4]);
assert_eq!(factor,[1.;4]);
assert_eq!(loaded.document.skins.as_ref().unwrap().len(),1);
assert_eq!(loaded.document.animations.as_ref().unwrap().len(),2);
let json=String::from_utf8_lossy(&compiled.glb);
assert!(json.contains("\"minFilter\":9729"),"shared atlas must avoid cross-tile mip bleeding");
for (index, source) in compiled.primitives.iter().enumerate() {
let primitive=&loaded.document.meshes_slice()[0].primitives[index];
assert_eq!(primitive.material,Some(0));
assert!(primitive.attributes.contains_key("JOINTS_0") && primitive.attributes.contains_key("WEIGHTS_0"));
let decoded=makepad_gltf::decode_mesh_primitive(&loaded,0,index).unwrap();
let uvs=decoded.texcoords0.unwrap();let exported:Vec<[f32;2]>=decoded.indices.iter().map(|&i|uvs[i as usize]).collect();
let original=original_uvs(doc,&source.object);
let material=&doc.materials()[&source.material];
let source_image=Texture::from_png(&material.base_color_png,doc.limits()).unwrap();
assert_eq!(original.len(),exported.len());
for (before,after) in original.chunks_exact(3).zip(exported.chunks_exact(3)) {
for weights in [[1.,0.,0.],[0.,1.,0.],[0.,0.,1.],[0.2,0.3,0.5],[0.33,0.34,0.33]] {
let original_uv=std::array::from_fn(|axis| (0..3).map(|i|before[i][axis]*weights[i]).sum());
let compiled_uv=std::array::from_fn(|axis| (0..3).map(|i|after[i][axis] as f64*weights[i]).sum());
let original_color=sample(&source_image,original_uv);
let actual=sample(&image,compiled_uv);
for lane in 0..3 {
let expected=original_color[lane]*material.color[lane] as f64;
assert!((actual[lane]-expected).abs()<0.0045,"primitive{index} lane{lane}: expected{expected}, got{} (UV{original_uv:?})",actual[lane]);
}
}
}
}
}
#[test]
fn portable_first_image_preserves_all_material_colors_repeat_seams_skin_and_source() {
let doc=document(true,2.0,-0.5);
let source=doc.to_bytes(None).unwrap();
let compiled=doc.compile(None).unwrap();
assert_eq!(doc.materials().len(),2);
assert_eq!(compiled.primitives.len(),2);
assert_eq!(doc.to_bytes(None).unwrap(),source,"export must not rewrite editable UVs or materials");
compare_first_image(&doc,&compiled);
let reopened=Document::from_bytes(&source,Limits::default(),None).unwrap();
assert_eq!(reopened.to_bytes(None).unwrap(),source);
assert_eq!(reopened.materials(),doc.materials());
assert_eq!(reopened.compile(None).unwrap().glb,compiled.glb);
}
#[test]
fn single_material_keeps_arbitrary_repeat_uvs_and_bakes_linear_color_factor() {
let doc=document(false,2.0,40.0);
let compiled=doc.compile(None).unwrap();
compare_first_image(&doc,&compiled);
let loaded=makepad_gltf::load_gltf_from_bytes(&compiled.glb,None).unwrap();
let decoded=makepad_gltf::decode_mesh_primitive(&loaded,0,0).unwrap();let uvs=decoded.texcoords0.unwrap();let actual:Vec<[f32;2]>=decoded.indices.iter().map(|&i|uvs[i as usize]).collect();
assert_eq!(actual,original_uvs(&doc,"a").iter().map(|uv|[uv[0] as f32,uv[1] as f32]).collect::<Vec<_>>());
let image=Texture::from_png(&makepad_gltf::load_image_bytes(&loaded,0).unwrap(),doc.limits()).unwrap();
assert_eq!((image.width,image.height),(2,2));
assert!(image.rgb[0]>60 && image.rgb[0]<70,"linear factor is not naive sRGB byte multiplication");
}
#[test]
fn unsafe_repeat_domains_and_atlas_budgets_refuse_without_source_mutation() {
for doc in [document(true,2.0,40.0), document(true,9.0,0.0)] {
let before=doc.to_bytes(None).unwrap();
assert!(doc.compile(None).is_err());
assert_eq!(doc.to_bytes(None).unwrap(),before);
}
let mut doc=document(true,1.0,0.0);
let png=Texture::solid(1024,1,[120,30,90],doc.limits()).unwrap().to_png(doc.limits()).unwrap();
apply(&mut doc,"large",vec![Operation::SetMaterial {material:7,value:Material {color:[1.;3],base_color_png:png}}]);
let before=doc.to_bytes(None).unwrap();
assert!(matches!(doc.compile(None),Err(Error::Budget("skin atlas tile dimensions"))));
assert_eq!(doc.to_bytes(None).unwrap(),before);
assert!(matches!(document(true,1.,0.).compile(Some(&||true)),Err(Error::Mesh(mesh::MeshError::Cancelled))));
}