makepad/libs/model/tests/portable_atlas.rs
Admin d2130e6550 libs: model, mesh_edit, csg, scene, sim, render, xr
Squash of 25 work commits (Sep 1–12):
  237da90  render: the sprite lane hands the screen draw back the way it found it
  895b9a7  particles: an emitter can ride a body's own frame
  914471f  ai-hub: a feed session whose last socket left ends on its idle timeout; skin: parent, skinned centroid and a nodes-only rig for retargets
  3fcccf3  sim: the whole world is implicitly editable, and one seam says where the ground is
  5692fab  sim: the landform world proves itself — walker through the tunnel included
  f4af6df  sim: terrain knows who changed it — a plan layer over player history
  adbb078  render: a water volume can be physics without a picture
  c17480f  render: a non-rigid body may carry its own orientation
  acad401  sim: an agent with no route holds and retries instead of walking into the wall
  22b2bf9  sim + chat: the composed world surface takes a map floor; the chat gets plan tools
  faf8112  web path: the tessellator's lap timer, the trace span and the fusion cycle timer have no clock on the web
  d3472eb  tsdf: the clock-taking XR helpers are native-only — the browser has no depth camera and no Instant
  4946c9e  sim + render: a repaint is not a world edit — colour and glow restyles never rebake the lightmap
  4317f58  sim + render + chat: walk decks as a surface, the filmed body is no obstruction, the brief never asks
  9791279  render: support rigged models and custom materials across viewers
  3ce2792  sim: use deck geometry for collision and sensing
  c8b79ff  Add portable PBR, rig and soft-body authoring support
  108d423  Add transactional polygon modeling and editable asset documents
  38f4d3b  Refine editable modeling and firm yarn character behavior
  08a2637  sim: add entity-owned lights and vehicle headlights
  00d96a7  render: add clustered lights, local shadows and incremental GI
  cf916af  render: import glTF asset extensions and wire clustered GI
  c6d2cca  model: cut transaction memory and raise capacity limits
  41af47a  raytrace: add a CPU probe-ray BVH budget example
  c963d0a  libs: the game sim splits into makepad-scene and makepad-soft-body; render, model, fab and the asset importer retarget

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:31 +02:00

137 lines
8 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 exported=decoded.texcoords0.unwrap();
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 actual=makepad_gltf::decode_mesh_primitive(&loaded,0,0).unwrap().texcoords0.unwrap();
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))));
}