Merge feature/gltf-texture-transform (#12b)
@@ -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 gl10
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }10
eframe = { version = "0.29.1", default-features = false, features = ["default_fonts", "glow", "x11"] }11
egui_glow = "0.29.1"11
egui_glow = "0.29.1"12
glow = "0.14.2"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
libloading = "0.8"14
libloading = "0.8"15
png = "0.18"15
png = "0.18"16
rfd = "0.15.4"16
rfd = "0.15.4"@@ -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** selecteert32
32
33
### Meerdere meshes en skins (glTF/GLB)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
### Statische lichamen (OBJ, glTF/FBX zonder skin)37
### Statische lichamen (OBJ, glTF/FBX zonder skin)38
38
@@ -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 = transform788
.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 = reader792
let colors = reader788
.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, rotation959
/// 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
/// Inverse of an affine matrix (None when singular).975
/// Inverse of an affine matrix (None when singular).947
fn affine_inverse(m: &Mat4) -> Option<Mat4> {976
fn affine_inverse(m: &Mat4) -> Option<Mat4> {948
let it = normal_matrix(m)?; // inverse-transpose of the 3×3977
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(