Squash of 25 work commits (Sep 1–12):237da90render: the sprite lane hands the screen draw back the way it found it895b9a7particles: an emitter can ride a body's own frame914471fai-hub: a feed session whose last socket left ends on its idle timeout; skin: parent, skinned centroid and a nodes-only rig for retargets3fcccf3sim: the whole world is implicitly editable, and one seam says where the ground is5692fabsim: the landform world proves itself — walker through the tunnel includedf4af6dfsim: terrain knows who changed it — a plan layer over player historyadbb078render: a water volume can be physics without a picturec17480frender: a non-rigid body may carry its own orientationacad401sim: an agent with no route holds and retries instead of walking into the wall22b2bf9sim + chat: the composed world surface takes a map floor; the chat gets plan toolsfaf8112web path: the tessellator's lap timer, the trace span and the fusion cycle timer have no clock on the webd3472ebtsdf: the clock-taking XR helpers are native-only — the browser has no depth camera and no Instant4946c9esim + render: a repaint is not a world edit — colour and glow restyles never rebake the lightmap4317f58sim + render + chat: walk decks as a surface, the filmed body is no obstruction, the brief never asks9791279render: support rigged models and custom materials across viewers3ce2792sim: 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>
479 lines
14 KiB
Rust
479 lines
14 KiB
Rust
use makepad_gltf::{JsonValue, ParsedGlb};
|
|
use makepad_model::*;
|
|
|
|
fn apply(doc: &mut Document, id: &str, operations: Vec<Operation>) -> Applied {
|
|
doc.apply(
|
|
Transaction {
|
|
request_id: id.into(),
|
|
expected: doc.head(),
|
|
operations,
|
|
},
|
|
None,
|
|
)
|
|
.unwrap()
|
|
}
|
|
fn skeleton() -> Skeleton {
|
|
Skeleton {
|
|
joints: vec![
|
|
Joint {
|
|
name: "root".into(),
|
|
parent: None,
|
|
translation: [0., -1., 0.],
|
|
},
|
|
Joint {
|
|
name: "arm".into(),
|
|
parent: Some(0),
|
|
translation: [0., 1., 0.],
|
|
},
|
|
],
|
|
}
|
|
}
|
|
fn wave() -> AnimationClip {
|
|
AnimationClip {
|
|
name: "wave".into(),
|
|
channels: vec![AnimationChannel {
|
|
joint: 1,
|
|
path: AnimationPath::Rotation,
|
|
keys: vec![
|
|
Keyframe {
|
|
time: 0.,
|
|
value: [0., 0., 0., 1.],
|
|
},
|
|
Keyframe {
|
|
time: 1.,
|
|
value: [
|
|
0.,
|
|
0.,
|
|
std::f64::consts::FRAC_1_SQRT_2,
|
|
std::f64::consts::FRAC_1_SQRT_2,
|
|
],
|
|
},
|
|
],
|
|
}],
|
|
}
|
|
}
|
|
fn number(v: &JsonValue) -> usize {
|
|
match v {
|
|
JsonValue::U64(v) => *v as usize,
|
|
JsonValue::I64(v) => *v as usize,
|
|
JsonValue::F64(v) => *v as usize,
|
|
_ => panic!("number"),
|
|
}
|
|
}
|
|
fn array(v: &JsonValue) -> &[JsonValue] {
|
|
match v {
|
|
JsonValue::Array(v) => v,
|
|
_ => panic!("array"),
|
|
}
|
|
}
|
|
fn accessor_bytes(parsed: &ParsedGlb, index: usize) -> &[u8] {
|
|
let a = &parsed.document.accessors_slice()[index];
|
|
let v = &parsed.document.buffer_views_slice()[a.buffer_view.unwrap()];
|
|
let offset = v.byte_offset.unwrap_or(0) + a.byte_offset.unwrap_or(0);
|
|
&parsed.bin_chunk.as_ref().unwrap()[offset..]
|
|
}
|
|
fn f32s(parsed: &ParsedGlb, index: usize, count: usize) -> Vec<f32> {
|
|
accessor_bytes(parsed, index)[..count * 4]
|
|
.chunks_exact(4)
|
|
.map(|b| f32::from_le_bytes(b.try_into().unwrap()))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn character_roundtrip_preserves_materials_skin_bind_and_animation_accessors() {
|
|
let mut doc = Document::new(Limits::default()).unwrap();
|
|
apply(
|
|
&mut doc,
|
|
"body",
|
|
vec![Operation::Cube {
|
|
object: "body".into(),
|
|
size: [2.; 3],
|
|
}],
|
|
);
|
|
let face = doc.object("body").unwrap().faces()[0].id;
|
|
apply(
|
|
&mut doc,
|
|
"rig",
|
|
vec![
|
|
Operation::SetMaterial {
|
|
material: 7,
|
|
value: Material {
|
|
color: [0.2, 0.4, 0.8],
|
|
..Default::default()
|
|
},
|
|
},
|
|
Operation::TextureSolid {
|
|
material: 7,
|
|
width: 8,
|
|
height: 8,
|
|
color: [80, 120, 200],
|
|
},
|
|
Operation::PaintTexture {
|
|
material: 7,
|
|
center: [0.5, 0.5],
|
|
radius: 0.25,
|
|
color: [250, 40, 30],
|
|
},
|
|
Operation::AssignMaterial {
|
|
object: "body".into(),
|
|
faces: vec![face],
|
|
material: 7,
|
|
},
|
|
Operation::SetSkeleton {
|
|
skeleton: skeleton(),
|
|
},
|
|
Operation::AutoWeights {
|
|
object: "body".into(),
|
|
},
|
|
Operation::SetClip { clip: wave() },
|
|
],
|
|
);
|
|
let compiled = doc.compile(None).unwrap();
|
|
let parsed = makepad_gltf::parse_glb_bytes(&compiled.glb).unwrap();
|
|
assert_eq!(parsed.document.meshes_slice()[0].primitives.len(), 2);
|
|
assert_eq!(parsed.document.materials_slice().len(), 1);
|
|
assert_eq!(parsed.document.images_slice().len(), 1);
|
|
assert_eq!(
|
|
parsed.document.nodes_slice()[2].translation,
|
|
Some([0., 1., 0.])
|
|
);
|
|
let skin = &parsed.document.skins.as_ref().unwrap()[0];
|
|
assert_eq!(
|
|
array(skin.key("joints").unwrap())
|
|
.iter()
|
|
.map(number)
|
|
.collect::<Vec<_>>(),
|
|
vec![1, 2]
|
|
);
|
|
let inverse = f32s(
|
|
&parsed,
|
|
number(skin.key("inverseBindMatrices").unwrap()),
|
|
32,
|
|
);
|
|
assert_eq!(&inverse[12..15], &[0., 1., 0.]);
|
|
assert_eq!(&inverse[28..31], &[0., 0., 0.]);
|
|
let expected = doc.skeleton().unwrap().inverse_bind_matrices().unwrap();
|
|
assert_eq!(expected[0][1][3], 1.);
|
|
let mut saw_arm = false;
|
|
for primitive in &parsed.document.meshes_slice()[0].primitives {
|
|
let weights = primitive.attributes["WEIGHTS_0"];
|
|
let joints = primitive.attributes["JOINTS_0"];
|
|
let count = parsed.document.accessors_slice()[weights].count;
|
|
let values = f32s(&parsed, weights, count * 4);
|
|
let indices = accessor_bytes(&parsed, joints);
|
|
assert_eq!(
|
|
parsed.document.accessors_slice()[joints].component_type,
|
|
5121
|
|
);
|
|
for i in 0..count {
|
|
assert!((values[i * 4..i * 4 + 4].iter().sum::<f32>() - 1.).abs() < 1e-6);
|
|
saw_arm |= indices[i * 4] == 1;
|
|
}
|
|
assert!(primitive.material.is_some());
|
|
assert!(primitive.attributes.contains_key("TEXCOORD_0"));
|
|
}
|
|
assert!(saw_arm);
|
|
let animation = &parsed.document.animations.as_ref().unwrap()[0];
|
|
assert_eq!(animation.key("name").unwrap().string().unwrap(), "wave");
|
|
let channel = &array(animation.key("channels").unwrap())[0];
|
|
assert_eq!(
|
|
number(channel.key("target").unwrap().key("node").unwrap()),
|
|
2
|
|
);
|
|
let sampler = &array(animation.key("samplers").unwrap())[0];
|
|
assert_eq!(
|
|
f32s(&parsed, number(sampler.key("input").unwrap()), 2),
|
|
vec![0., 1.]
|
|
);
|
|
let q = f32s(&parsed, number(sampler.key("output").unwrap()), 8);
|
|
assert!((q[6] - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-6);
|
|
let before = doc.head();
|
|
doc.checkpoint(before).unwrap();
|
|
assert_eq!(doc.head().content, before.content);
|
|
assert_eq!(doc.head().generation, before.generation + 1);
|
|
let source = doc.to_bytes(None).unwrap();
|
|
let reopened = Document::from_bytes(&source, Limits::default(), None).unwrap();
|
|
assert_eq!(reopened.to_bytes(None).unwrap(), source);
|
|
assert_eq!(reopened.compile(None).unwrap().glb, compiled.glb);
|
|
assert_eq!(reopened.skeleton(), doc.skeleton());
|
|
assert_eq!(reopened.clips(), doc.clips());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_rig_and_animation_roll_back_and_compile_never_prunes_weights() {
|
|
let mut doc = Document::new(Limits::default()).unwrap();
|
|
apply(
|
|
&mut doc,
|
|
"cube",
|
|
vec![Operation::Cube {
|
|
object: "body".into(),
|
|
size: [1.; 3],
|
|
}],
|
|
);
|
|
let before = doc.to_bytes(None).unwrap();
|
|
let mut bad = skeleton();
|
|
bad.joints[0].parent = Some(1);
|
|
assert!(doc
|
|
.apply(
|
|
Transaction {
|
|
request_id: "bad".into(),
|
|
expected: doc.head(),
|
|
operations: vec![Operation::SetSkeleton { skeleton: bad }]
|
|
},
|
|
None
|
|
)
|
|
.is_err());
|
|
assert_eq!(doc.to_bytes(None).unwrap(), before);
|
|
let mut rig = skeleton();
|
|
for i in 2..5 {
|
|
rig.joints.push(Joint {
|
|
name: format!("j{i}"),
|
|
parent: Some(0),
|
|
translation: [i as f64, 0., 0.],
|
|
});
|
|
}
|
|
apply(
|
|
&mut doc,
|
|
"bind",
|
|
vec![
|
|
Operation::SetSkeleton { skeleton: rig },
|
|
Operation::AutoWeights {
|
|
object: "body".into(),
|
|
},
|
|
],
|
|
);
|
|
let vertex = doc.object("body").unwrap().vertices()[0].id;
|
|
apply(
|
|
&mut doc,
|
|
"many",
|
|
vec![Operation::SetWeights {
|
|
object: "body".into(),
|
|
vertex,
|
|
weights: (0..5)
|
|
.map(|joint| mesh::JointWeight { joint, weight: 0.2 })
|
|
.collect(),
|
|
}],
|
|
);
|
|
assert!(matches!(doc.compile(None), Err(Error::Invalid(_))));
|
|
assert_eq!(
|
|
doc.object("body")
|
|
.unwrap()
|
|
.vertex(vertex)
|
|
.unwrap()
|
|
.weights
|
|
.len(),
|
|
5
|
|
);
|
|
let before = doc.to_bytes(None).unwrap();
|
|
let mut clip = wave();
|
|
clip.channels[0].keys[1].time = 0.;
|
|
assert!(doc
|
|
.apply(
|
|
Transaction {
|
|
request_id: "bad-time".into(),
|
|
expected: doc.head(),
|
|
operations: vec![Operation::SetClip { clip }]
|
|
},
|
|
None
|
|
)
|
|
.is_err());
|
|
assert_eq!(doc.to_bytes(None).unwrap(), before);
|
|
let mut changed = skeleton();
|
|
changed.joints[0].name = "other".into();
|
|
assert!(doc
|
|
.apply(
|
|
Transaction {
|
|
request_id: "retarget".into(),
|
|
expected: doc.head(),
|
|
operations: vec![Operation::SetSkeleton { skeleton: changed }]
|
|
},
|
|
None
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn procedural_texture_logs_replay_and_cancel_atomically() {
|
|
let mut doc = Document::new(Limits::default()).unwrap();
|
|
apply(
|
|
&mut doc,
|
|
"texture",
|
|
vec![Operation::TextureSolid {
|
|
material: 0,
|
|
width: 32,
|
|
height: 32,
|
|
color: [10, 20, 30],
|
|
}],
|
|
);
|
|
let solid = doc.materials()[&0].base_color_png.clone();
|
|
apply(
|
|
&mut doc,
|
|
"paint",
|
|
vec![Operation::PaintTexture {
|
|
material: 0,
|
|
center: [0.5, 0.5],
|
|
radius: 0.1,
|
|
color: [255, 0, 0],
|
|
}],
|
|
);
|
|
let painted = doc.materials()[&0].base_color_png.clone();
|
|
assert_ne!(painted, solid);
|
|
let source = doc.to_bytes(None).unwrap();
|
|
let reopened = Document::from_bytes(&source, Limits::default(), None).unwrap();
|
|
assert_eq!(reopened.materials()[&0].base_color_png, painted);
|
|
doc.undo(doc.head(), None).unwrap();
|
|
assert_eq!(doc.materials()[&0].base_color_png, solid);
|
|
doc.redo(doc.head(), None).unwrap();
|
|
assert_eq!(doc.materials()[&0].base_color_png, painted);
|
|
let before = doc.to_bytes(None).unwrap();
|
|
let calls = std::cell::Cell::new(0);
|
|
let cancel = || {
|
|
calls.set(calls.get() + 1);
|
|
calls.get() > 12
|
|
};
|
|
assert!(doc
|
|
.apply(
|
|
Transaction {
|
|
request_id: "cancel".into(),
|
|
expected: doc.head(),
|
|
operations: vec![Operation::PaintTexture {
|
|
material: 0,
|
|
center: [0.5, 0.5],
|
|
radius: 0.9,
|
|
color: [0, 255, 0]
|
|
}]
|
|
},
|
|
Some(&cancel)
|
|
)
|
|
.is_err());
|
|
assert_eq!(doc.to_bytes(None).unwrap(), before);
|
|
}
|
|
|
|
#[test]
|
|
fn expanded_modeling_operations_survive_source_replay() {
|
|
let mut doc = Document::new(Limits::default()).unwrap();
|
|
apply(
|
|
&mut doc,
|
|
"cube",
|
|
vec![Operation::Cube {
|
|
object: "body".into(),
|
|
size: [2.; 3],
|
|
}],
|
|
);
|
|
let face = doc.object("body").unwrap().faces()[1].id;
|
|
let corners = doc.object("body").unwrap().face_corners(face).unwrap();
|
|
let corner = corners[0].id;
|
|
let edge = mesh::EdgeKey::new(corners[0].vertex, corners[1].vertex);
|
|
apply(
|
|
&mut doc,
|
|
"detail",
|
|
vec![
|
|
Operation::EdgeAttributes {
|
|
object: "body".into(),
|
|
edge,
|
|
attributes: mesh::EdgeAttributes {
|
|
seam: true,
|
|
crease: 0.5,
|
|
},
|
|
},
|
|
Operation::CornerNormal {
|
|
object: "body".into(),
|
|
corner,
|
|
normal: Some([0., 0., 1.]),
|
|
},
|
|
Operation::Inset {
|
|
object: "body".into(),
|
|
face,
|
|
distance: 0.2,
|
|
},
|
|
Operation::Weld {
|
|
object: "body".into(),
|
|
vertices: vec![],
|
|
distance: 0.,
|
|
},
|
|
],
|
|
);
|
|
let source = doc.to_bytes(None).unwrap();
|
|
let reopened = Document::from_bytes(&source, Limits::default(), None).unwrap();
|
|
assert_eq!(reopened.to_bytes(None).unwrap(), source);
|
|
assert!(
|
|
reopened.object("body").unwrap().edge_attributes()[&edge]
|
|
.attributes
|
|
.seam
|
|
);
|
|
assert_eq!(reopened.compile(None).unwrap().triangles, 20);
|
|
}
|
|
|
|
#[test]
|
|
fn organic_modifier_log_replays_to_same_skinned_output() {
|
|
let mut doc = Document::new(Limits::default()).unwrap();
|
|
apply(
|
|
&mut doc,
|
|
"base",
|
|
vec![
|
|
Operation::Cube {
|
|
object: "body".into(),
|
|
size: [2.; 3],
|
|
},
|
|
Operation::SetSkeleton {
|
|
skeleton: skeleton(),
|
|
},
|
|
Operation::AutoWeights {
|
|
object: "body".into(),
|
|
},
|
|
Operation::Subdivide {
|
|
object: "body".into(),
|
|
levels: 2,
|
|
},
|
|
],
|
|
);
|
|
let vertices = doc
|
|
.object("body")
|
|
.unwrap()
|
|
.vertices()
|
|
.iter()
|
|
.map(|v| v.id)
|
|
.collect::<Vec<_>>();
|
|
let faces = doc
|
|
.object("body")
|
|
.unwrap()
|
|
.faces()
|
|
.iter()
|
|
.map(|f| f.id)
|
|
.collect::<Vec<_>>();
|
|
apply(
|
|
&mut doc,
|
|
"sculpt",
|
|
vec![
|
|
Operation::Smooth {
|
|
object: "body".into(),
|
|
vertices: vertices.clone(),
|
|
iterations: 2,
|
|
factor: 0.2,
|
|
preserve_boundary: true,
|
|
},
|
|
Operation::Brush {
|
|
object: "body".into(),
|
|
vertices,
|
|
center: [0., 0.8, 0.],
|
|
radius: 0.7,
|
|
delta: [0., 0.1, 0.],
|
|
max_displacement: 0.1,
|
|
},
|
|
Operation::ProjectUv {
|
|
object: "body".into(),
|
|
faces,
|
|
axis: 2,
|
|
scale: [0.5; 2],
|
|
offset: [0.5; 2],
|
|
},
|
|
Operation::SetClip { clip: wave() },
|
|
],
|
|
);
|
|
let glb = doc.compile(None).unwrap();
|
|
assert_eq!(glb.triangles, 192);
|
|
let source = doc.to_bytes(None).unwrap();
|
|
let reopened = Document::from_bytes(&source, Limits::default(), None).unwrap();
|
|
assert_eq!(reopened.compile(None).unwrap().glb, glb.glb);
|
|
assert_eq!(reopened.to_bytes(None).unwrap(), source);
|
|
}
|