BlueGit

golden-age paperdoll-renderer

Merge feature/gltf-texture-transform (#12b)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 7 days ago

Hash
067e6cebdb536e1a79b09c3e409493ba82b42fc6
3 files changed +77 −3
Cargo.toml +1 −1
@@ -10,7 +10,7 @@ description = "Paperdoll Renderer with project files, model import and a live gl
10 10 eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
11 11 egui_glow = "0.29.1"
12 12 glow = "0.14.2"
13 gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }
13 gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils", "KHR_texture_transform"] }
14 14 libloading = "0.8"
15 15 png = "0.18"
16 16 rfd = "0.15.4"
@@ -10,7 +10,7 @@ description = "Paperdoll Renderer with project files, model import and a live gl
10eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
10eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }
11egui_glow = "0.29.1"
11egui_glow = "0.29.1"
12glow = "0.14.2"
12glow = "0.14.2"
13gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils"] }
13gltf = { version = "1.4.1", default-features = false, features = ["import", "names", "utils", "KHR_texture_transform"] }
14libloading = "0.8"
14libloading = "0.8"
15png = "0.18"
15png = "0.18"
16rfd = "0.15.4"
16rfd = "0.15.4"
README.md +1 −1
@@ -32,7 +32,7 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
32 32
33 33 ### Meerdere meshes en skins (glTF/GLB)
34 34
35 Een geskind glTF/GLB-lichaam wordt volledig geladen: alle mesh-nodes en alle skins. De joints van alle skins worden samengevoegd tot één skelet (joints die in meerdere skins voorkomen tellen één keer; maximaal 128). Een mesh-node zonder skin beweegt star mee met de dichtstbijzijnde voorouder die een joint is; heeft hij die niet, dan hangt hij aan de eerste joint. Zijn rustpositie in de scène blijft daarbij exact behouden (gespiegelde transformaties draaien de driehoeksvolgorde om). Materialen blijven per primitive: basiskleur, albedotexture, alpha-cutoff en vertexkleuren.
35 Een geskind glTF/GLB-lichaam wordt volledig geladen: alle mesh-nodes en alle skins. De joints van alle skins worden samengevoegd tot één skelet (joints die in meerdere skins voorkomen tellen één keer; maximaal 128). Een mesh-node zonder skin beweegt star mee met de dichtstbijzijnde voorouder die een joint is; heeft hij die niet, dan hangt hij aan de eerste joint. Zijn rustpositie in de scène blijft daarbij exact behouden (gespiegelde transformaties draaien de driehoeksvolgorde om). Materialen blijven per primitive: basiskleur, albedotexture, alpha-cutoff en vertexkleuren. `KHR_texture_transform` op de basiskleurtexture (offset, rotatie, schaal en `texCoord`) wordt bij het laden één keer in de UV's verwerkt. De sampler-wrapmodus wordt niet gelezen: textures worden altijd met nearest en herhalen (REPEAT) bemonsterd, dus CLAMP/MIRROR geven buiten het UV-bereik 0–1 een ander beeld. Metallic/roughness-, normal-, occlusion- en emissive-textures en morph targets worden niet gebruikt; de normal map komt uit de geometrie en de specular-map van een body is nul (metallic/roughness wordt niet omgerekend; een scalaire specular kan PBR niet fysiek nabootsen).
36 36
37 37 ### Statische lichamen (OBJ, glTF/FBX zonder skin)
38 38
@@ -32,7 +32,7 @@ De eerste projectweergave toont de bodies. **Kies man / Kies vrouw** selecteert
32
32
33### Meerdere meshes en skins (glTF/GLB)
33### Meerdere meshes en skins (glTF/GLB)
34
34
35Een geskind glTF/GLB-lichaam wordt volledig geladen: alle mesh-nodes en alle skins. De joints van alle skins worden samengevoegd tot één skelet (joints die in meerdere skins voorkomen tellen één keer; maximaal 128). Een mesh-node zonder skin beweegt star mee met de dichtstbijzijnde voorouder die een joint is; heeft hij die niet, dan hangt hij aan de eerste joint. Zijn rustpositie in de scène blijft daarbij exact behouden (gespiegelde transformaties draaien de driehoeksvolgorde om). Materialen blijven per primitive: basiskleur, albedotexture, alpha-cutoff en vertexkleuren.
35Een geskind glTF/GLB-lichaam wordt volledig geladen: alle mesh-nodes en alle skins. De joints van alle skins worden samengevoegd tot één skelet (joints die in meerdere skins voorkomen tellen één keer; maximaal 128). Een mesh-node zonder skin beweegt star mee met de dichtstbijzijnde voorouder die een joint is; heeft hij die niet, dan hangt hij aan de eerste joint. Zijn rustpositie in de scène blijft daarbij exact behouden (gespiegelde transformaties draaien de driehoeksvolgorde om). Materialen blijven per primitive: basiskleur, albedotexture, alpha-cutoff en vertexkleuren. `KHR_texture_transform` op de basiskleurtexture (offset, rotatie, schaal en `texCoord`) wordt bij het laden één keer in de UV's verwerkt. De sampler-wrapmodus wordt niet gelezen: textures worden altijd met nearest en herhalen (REPEAT) bemonsterd, dus CLAMP/MIRROR geven buiten het UV-bereik 0–1 een ander beeld. Metallic/roughness-, normal-, occlusion- en emissive-textures en morph targets worden niet gebruikt; de normal map komt uit de geometrie en de specular-map van een body is nul (metallic/roughness wordt niet omgerekend; een scalaire specular kan PBR niet fysiek nabootsen).
36
36
37### Statische lichamen (OBJ, glTF/FBX zonder skin)
37### Statische lichamen (OBJ, glTF/FBX zonder skin)
38
38
src/preview.rs +75 −1
@@ -783,7 +783,12 @@ fn read_gltf_primitive(
783 783 let material = primitive.material();
784 784 let pbr = material.pbr_metallic_roughness();
785 785 let texture = pbr.base_color_texture();
786 let uvset = texture.as_ref().map(|t| t.tex_coord()).unwrap_or(0);
786 let transform = texture.as_ref().and_then(|t| t.texture_transform());
787 let uvset = transform
788 .as_ref()
789 .and_then(|t| t.tex_coord())
790 .or_else(|| texture.as_ref().map(|t| t.tex_coord()))
791 .unwrap_or(0);
787 792 let colors = reader
788 793 .read_colors(0)
789 794 .map(|v| v.into_rgba_f32().collect::<Vec<_>>())
@@ -792,6 +797,13 @@ fn read_gltf_primitive(
792 797 .read_tex_coords(uvset)
793 798 .map(|v| v.into_f32().collect::<Vec<_>>())
794 799 .unwrap_or_else(|| vec![[0.; 2]; positions.len()]);
800 let uvs = match &transform {
801 Some(t) => {
802 let m = uv_transform(t.offset(), t.rotation(), t.scale());
803 uvs.into_iter().map(|uv| apply_uv(&m, uv)).collect()
804 }
805 None => uvs,
806 };
795 807 let indices: Vec<u32> = match reader.read_indices() {
796 808 Some(read_indices) => read_indices.into_u32().collect(),
797 809 None => (0..positions.len() as u32).collect(),
@@ -943,6 +955,23 @@ fn load_static_gltf(
943 955 acc.finish("glTF", reason)
944 956 }
945 957
958 /// KHR_texture_transform as a 2×3 affine UV matrix (T·R·S, rotation
959 /// counter-clockwise in UV space as in the extension's examples).
960 pub fn uv_transform(offset: [f32; 2], rotation: f32, scale: [f32; 2]) -> [[f32; 3]; 2] {
961 let (s, c) = rotation.sin_cos();
962 [
963 [c * scale[0], s * scale[1], offset[0]],
964 [-s * scale[0], c * scale[1], offset[1]],
965 ]
966 }
967
968 fn apply_uv(m: &[[f32; 3]; 2], uv: [f32; 2]) -> [f32; 2] {
969 [
970 m[0][0] * uv[0] + m[0][1] * uv[1] + m[0][2],
971 m[1][0] * uv[0] + m[1][1] * uv[1] + m[1][2],
972 ]
973 }
974
946 975 /// Inverse of an affine matrix (None when singular).
947 976 fn affine_inverse(m: &Mat4) -> Option<Mat4> {
948 977 let it = normal_matrix(m)?; // inverse-transpose of the 3×3
@@ -1632,6 +1661,51 @@ mod tests {
1632 1661 let _ = std::fs::remove_file(path);
1633 1662 }
1634 1663
1664 #[test]
1665 fn texture_transform_matches_extension_example() {
1666 // Spec example: lower-left quadrant, rotated 90° clockwise.
1667 let m = uv_transform([0., 1.], std::f32::consts::FRAC_PI_2, [0.5, 0.5]);
1668 let close =
1669 |a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
1670 assert!(close(apply_uv(&m, [0., 0.]), [0., 1.]));
1671 assert!(close(apply_uv(&m, [1., 0.]), [0., 0.5]));
1672 assert!(close(apply_uv(&m, [0., 1.]), [0.5, 1.]));
1673 // Spec example: flip T.
1674 let m = uv_transform([0., 1.], 0., [1., -1.]);
1675 assert!(close(apply_uv(&m, [0.25, 0.25]), [0.25, 0.75]));
1676 }
1677
1678 #[test]
1679 fn gltf_texture_transform_is_baked_into_uvs() {
1680 let base = load_gltf_file(
1681 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1682 .join("tests/fixtures/bodies/test-body-male.glb"),
1683 )
1684 .unwrap();
1685 let path = glb_with(|d| {
1686 d["images"] = serde_json::json!([{"uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"}]);
1687 d["textures"] = serde_json::json!([{"source": 0}]);
1688 for m in d["materials"].as_array_mut().unwrap() {
1689 let pbr = &mut m["pbrMetallicRoughness"];
1690 if pbr.get("baseColorTexture").is_none() {
1691 pbr["baseColorTexture"] = serde_json::json!({"index": 0});
1692 }
1693 pbr["baseColorTexture"]["extensions"] = serde_json::json!(
1694 {"KHR_texture_transform": {"offset": [0.5, 0.25], "scale": [2.0, 3.0]}});
1695 }
1696 });
1697 let moved = load_gltf_file(&path);
1698 let _ = std::fs::remove_file(&path);
1699 let moved = match moved {
1700 Ok(m) => m,
1701 Err(e) => panic!("{e}"),
1702 };
1703 for (a, b) in base.uvs.iter().zip(&moved.uvs).take(50) {
1704 assert!((a[0] * 2.0 + 0.5 - b[0]).abs() < 1e-5);
1705 assert!((a[1] * 3.0 + 0.25 - b[1]).abs() < 1e-5);
1706 }
1707 }
1708
1635 1709 #[test]
1636 1710 fn affine_inverse_round_trips() {
1637 1711 let m = trs(
@@ -783,7 +783,12 @@ fn read_gltf_primitive(
783 let material = primitive.material();
783 let material = primitive.material();
784 let pbr = material.pbr_metallic_roughness();
784 let pbr = material.pbr_metallic_roughness();
785 let texture = pbr.base_color_texture();
785 let texture = pbr.base_color_texture();
786 let uvset = texture.as_ref().map(|t| t.tex_coord()).unwrap_or(0);
786 let transform = texture.as_ref().and_then(|t| t.texture_transform());
787 let uvset = transform
788 .as_ref()
789 .and_then(|t| t.tex_coord())
790 .or_else(|| texture.as_ref().map(|t| t.tex_coord()))
791 .unwrap_or(0);
787 let colors = reader
792 let colors = reader
788 .read_colors(0)
793 .read_colors(0)
789 .map(|v| v.into_rgba_f32().collect::<Vec<_>>())
794 .map(|v| v.into_rgba_f32().collect::<Vec<_>>())
@@ -792,6 +797,13 @@ fn read_gltf_primitive(
792 .read_tex_coords(uvset)
797 .read_tex_coords(uvset)
793 .map(|v| v.into_f32().collect::<Vec<_>>())
798 .map(|v| v.into_f32().collect::<Vec<_>>())
794 .unwrap_or_else(|| vec![[0.; 2]; positions.len()]);
799 .unwrap_or_else(|| vec![[0.; 2]; positions.len()]);
800 let uvs = match &transform {
801 Some(t) => {
802 let m = uv_transform(t.offset(), t.rotation(), t.scale());
803 uvs.into_iter().map(|uv| apply_uv(&m, uv)).collect()
804 }
805 None => uvs,
806 };
795 let indices: Vec<u32> = match reader.read_indices() {
807 let indices: Vec<u32> = match reader.read_indices() {
796 Some(read_indices) => read_indices.into_u32().collect(),
808 Some(read_indices) => read_indices.into_u32().collect(),
797 None => (0..positions.len() as u32).collect(),
809 None => (0..positions.len() as u32).collect(),
@@ -943,6 +955,23 @@ fn load_static_gltf(
943 acc.finish("glTF", reason)
955 acc.finish("glTF", reason)
944}
956}
945
957
958/// KHR_texture_transform as a 2×3 affine UV matrix (T·R·S, rotation
959/// counter-clockwise in UV space as in the extension's examples).
960pub fn uv_transform(offset: [f32; 2], rotation: f32, scale: [f32; 2]) -> [[f32; 3]; 2] {
961 let (s, c) = rotation.sin_cos();
962 [
963 [c * scale[0], s * scale[1], offset[0]],
964 [-s * scale[0], c * scale[1], offset[1]],
965 ]
966}
967
968fn apply_uv(m: &[[f32; 3]; 2], uv: [f32; 2]) -> [f32; 2] {
969 [
970 m[0][0] * uv[0] + m[0][1] * uv[1] + m[0][2],
971 m[1][0] * uv[0] + m[1][1] * uv[1] + m[1][2],
972 ]
973}
974
946/// Inverse of an affine matrix (None when singular).
975/// Inverse of an affine matrix (None when singular).
947fn affine_inverse(m: &Mat4) -> Option<Mat4> {
976fn affine_inverse(m: &Mat4) -> Option<Mat4> {
948 let it = normal_matrix(m)?; // inverse-transpose of the 3×3
977 let it = normal_matrix(m)?; // inverse-transpose of the 3×3
@@ -1632,6 +1661,51 @@ mod tests {
1632 let _ = std::fs::remove_file(path);
1661 let _ = std::fs::remove_file(path);
1633 }
1662 }
1634
1663
1664 #[test]
1665 fn texture_transform_matches_extension_example() {
1666 // Spec example: lower-left quadrant, rotated 90° clockwise.
1667 let m = uv_transform([0., 1.], std::f32::consts::FRAC_PI_2, [0.5, 0.5]);
1668 let close =
1669 |a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-6 && (a[1] - b[1]).abs() < 1e-6;
1670 assert!(close(apply_uv(&m, [0., 0.]), [0., 1.]));
1671 assert!(close(apply_uv(&m, [1., 0.]), [0., 0.5]));
1672 assert!(close(apply_uv(&m, [0., 1.]), [0.5, 1.]));
1673 // Spec example: flip T.
1674 let m = uv_transform([0., 1.], 0., [1., -1.]);
1675 assert!(close(apply_uv(&m, [0.25, 0.25]), [0.25, 0.75]));
1676 }
1677
1678 #[test]
1679 fn gltf_texture_transform_is_baked_into_uvs() {
1680 let base = load_gltf_file(
1681 &std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1682 .join("tests/fixtures/bodies/test-body-male.glb"),
1683 )
1684 .unwrap();
1685 let path = glb_with(|d| {
1686 d["images"] = serde_json::json!([{"uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC"}]);
1687 d["textures"] = serde_json::json!([{"source": 0}]);
1688 for m in d["materials"].as_array_mut().unwrap() {
1689 let pbr = &mut m["pbrMetallicRoughness"];
1690 if pbr.get("baseColorTexture").is_none() {
1691 pbr["baseColorTexture"] = serde_json::json!({"index": 0});
1692 }
1693 pbr["baseColorTexture"]["extensions"] = serde_json::json!(
1694 {"KHR_texture_transform": {"offset": [0.5, 0.25], "scale": [2.0, 3.0]}});
1695 }
1696 });
1697 let moved = load_gltf_file(&path);
1698 let _ = std::fs::remove_file(&path);
1699 let moved = match moved {
1700 Ok(m) => m,
1701 Err(e) => panic!("{e}"),
1702 };
1703 for (a, b) in base.uvs.iter().zip(&moved.uvs).take(50) {
1704 assert!((a[0] * 2.0 + 0.5 - b[0]).abs() < 1e-5);
1705 assert!((a[1] * 3.0 + 0.25 - b[1]).abs() < 1e-5);
1706 }
1707 }
1708
1635 #[test]
1709 #[test]
1636 fn affine_inverse_round_trips() {
1710 fn affine_inverse_round_trips() {
1637 let m = trs(
1711 let m = trs(