BlueGit

golden-age paperdoll-renderer

design: glTF UVs unflipped in the live preview (Tim t1009u)

timfa Grok Bot · pushed by timfa

Authored 2026-11-07 · 6 days ago

Hash
e40c41245c18c53308d06343e260779316724d4f
Parent
62b79b7d
Pipeline
Obsolete
4 files changed +56 −5
design.md +4 −0
@@ -533,3 +533,7 @@ Supersedes the neck pivot of t952u (the fixed framing itself is unchanged).
533 533 ## Decision 2026-11-07 (Tim t998u.4): glTF KHR_materials_specular feeds the body specular map
534 534
535 535 When a glTF body material carries `KHR_materials_specular`, its specular strength becomes the body's specular map: `specularFactor` × the alpha channel of `specularTexture` (when present), per primitive, as loaded. `specularColorFactor`/`specularColorTexture` are not used (our specular map is a scalar strength). The specular texture is sampled with the same (base-colour) UV set and transform as the albedo; a specular texture on a different `texCoord` is ignored with a load notice-free fallback to the factor alone. Materials without the extension keep specular 0 as before; there is no roughness/metallic conversion (KHR_materials_specular was straightforward, so the roughness fallback is not needed). The live preview uses the same per-primitive strength and texture for its highlight. FBX/OBJ bodies are unchanged (specular 0).
536
537 ## Decision 2026-11-07 (Tim t1009u): glTF UVs reach the GPU unchanged in the live preview
538
539 glTF texture coordinates have their origin at the top-left of the image (v = 0 is the first row) and are used as loaded, with no V flip, per `TEXCOORD_n` of the texture (`texCoord`) and after `KHR_texture_transform`, as in production. The live shader no longer flips V for everything; only equipment meshes (OBJ/FBX convention, bottom-left origin) are converted once when they are handed to the live GPU path, exactly as production already did. Sampler wrap stays nearest + REPEAT for both paths. A GPU probe case checks both origins with a 1×2 texture.
@@ -533,3 +533,7 @@ Supersedes the neck pivot of t952u (the fixed framing itself is unchanged).
533## Decision 2026-11-07 (Tim t998u.4): glTF KHR_materials_specular feeds the body specular map
533## Decision 2026-11-07 (Tim t998u.4): glTF KHR_materials_specular feeds the body specular map
534
534
535When a glTF body material carries `KHR_materials_specular`, its specular strength becomes the body's specular map: `specularFactor` × the alpha channel of `specularTexture` (when present), per primitive, as loaded. `specularColorFactor`/`specularColorTexture` are not used (our specular map is a scalar strength). The specular texture is sampled with the same (base-colour) UV set and transform as the albedo; a specular texture on a different `texCoord` is ignored with a load notice-free fallback to the factor alone. Materials without the extension keep specular 0 as before; there is no roughness/metallic conversion (KHR_materials_specular was straightforward, so the roughness fallback is not needed). The live preview uses the same per-primitive strength and texture for its highlight. FBX/OBJ bodies are unchanged (specular 0).
535When a glTF body material carries `KHR_materials_specular`, its specular strength becomes the body's specular map: `specularFactor` × the alpha channel of `specularTexture` (when present), per primitive, as loaded. `specularColorFactor`/`specularColorTexture` are not used (our specular map is a scalar strength). The specular texture is sampled with the same (base-colour) UV set and transform as the albedo; a specular texture on a different `texCoord` is ignored with a load notice-free fallback to the factor alone. Materials without the extension keep specular 0 as before; there is no roughness/metallic conversion (KHR_materials_specular was straightforward, so the roughness fallback is not needed). The live preview uses the same per-primitive strength and texture for its highlight. FBX/OBJ bodies are unchanged (specular 0).
536
537## Decision 2026-11-07 (Tim t1009u): glTF UVs reach the GPU unchanged in the live preview
538
539glTF texture coordinates have their origin at the top-left of the image (v = 0 is the first row) and are used as loaded, with no V flip, per `TEXCOORD_n` of the texture (`texCoord`) and after `KHR_texture_transform`, as in production. The live shader no longer flips V for everything; only equipment meshes (OBJ/FBX convention, bottom-left origin) are converted once when they are handed to the live GPU path, exactly as production already did. Sampler wrap stays nearest + REPEAT for both paths. A GPU probe case checks both origins with a 1×2 texture.
src/editor.rs +2 −2
@@ -1847,7 +1847,7 @@ impl EditorApp {
1847 1847 )));
1848 1848 if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {
1849 1849 ui.add_enabled_ui(false, |ui| {
1850 ui.collapsing("Skeleton / botten", |ui| {
1850 ui.collapsing(crate::i18n::t("Skeleton / botten"), |ui| {
1851 1851 ui.label(crate::i18n::t("Geen botten"));
1852 1852 });
1853 1853 });
@@ -1864,7 +1864,7 @@ impl EditorApp {
1864 1864 crate::i18n::t("Rigbinding ontbreekt of wijkt af; selecteer een bot."),
1865 1865 );
1866 1866 }
1867 ui.collapsing("Skeleton / botten", |ui| {
1867 ui.collapsing(crate::i18n::t("Skeleton / botten"), |ui| {
1868 1868 if let Some(joint) = bone_tree(ui, body, selected.as_deref()) {
1869 1869 if let Err(error) =
1870 1870 preset.bind(sex, body, self.equipment_part, joint)
@@ -1847,7 +1847,7 @@ impl EditorApp {
1847 )));
1847 )));
1848 if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {
1848 if let Some(reason) = body.and_then(PreviewCharacter::static_reason) {
1849 ui.add_enabled_ui(false, |ui| {
1849 ui.add_enabled_ui(false, |ui| {
1850 ui.collapsing("Skeleton / botten", |ui| {
1850 ui.collapsing(crate::i18n::t("Skeleton / botten"), |ui| {
1851 ui.label(crate::i18n::t("Geen botten"));
1851 ui.label(crate::i18n::t("Geen botten"));
1852 });
1852 });
1853 });
1853 });
@@ -1864,7 +1864,7 @@ impl EditorApp {
1864 crate::i18n::t("Rigbinding ontbreekt of wijkt af; selecteer een bot."),
1864 crate::i18n::t("Rigbinding ontbreekt of wijkt af; selecteer een bot."),
1865 );
1865 );
1866 }
1866 }
1867 ui.collapsing("Skeleton / botten", |ui| {
1867 ui.collapsing(crate::i18n::t("Skeleton / botten"), |ui| {
1868 if let Some(joint) = bone_tree(ui, body, selected.as_deref()) {
1868 if let Some(joint) = bone_tree(ui, body, selected.as_deref()) {
1869 if let Err(error) =
1869 if let Err(error) =
1870 preset.bind(sex, body, self.equipment_part, joint)
1870 preset.bind(sex, body, self.equipment_part, joint)
src/i18n_table.rs +3 −0
@@ -813,6 +813,9 @@ pub const TABLE: &[(&str, &str)] = &[
813 813 ("Instellingen", "Settings"),
814 814 ("Taal", "Language"),
815 815 ("Sluiten", "Close"),
816 ("body-UV niet volgens glTF (v=0 boven): {body_uv:?}", "body UV not per glTF (v=0 at top): {body_uv:?}"),
817 ("equipment-UV verkeerd georiënteerd: {equipment_uv:?}", "equipment UV wrongly oriented: {equipment_uv:?}"),
818 ("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}, body-uv-top={body_uv:?}, equipment-uv-bottom={equipment_uv:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}, body-uv-top={body_uv:?}, equipment-uv-bottom={equipment_uv:?}"),
816 819 ("Auto midden", "Auto centre"),
817 820 ("Wis handmatige pivot; gebruik het midden van het model (rustpose)", "Clear manual pivot; use the model's centre (rest pose)"),
818 821 ("Laad een glTF/GLB-, FBX- of OBJ-lichaam om de pivot te bepalen.", "Load a glTF/GLB, FBX or OBJ body to determine the pivot."),
@@ -813,6 +813,9 @@ pub const TABLE: &[(&str, &str)] = &[
813 ("Instellingen", "Settings"),
813 ("Instellingen", "Settings"),
814 ("Taal", "Language"),
814 ("Taal", "Language"),
815 ("Sluiten", "Close"),
815 ("Sluiten", "Close"),
816 ("body-UV niet volgens glTF (v=0 boven): {body_uv:?}", "body UV not per glTF (v=0 at top): {body_uv:?}"),
817 ("equipment-UV verkeerd georiënteerd: {equipment_uv:?}", "equipment UV wrongly oriented: {equipment_uv:?}"),
818 ("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}, body-uv-top={body_uv:?}, equipment-uv-bottom={equipment_uv:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}, body-uv-top={body_uv:?}, equipment-uv-bottom={equipment_uv:?}"),
816 ("Auto midden", "Auto centre"),
819 ("Auto midden", "Auto centre"),
817 ("Wis handmatige pivot; gebruik het midden van het model (rustpose)", "Clear manual pivot; use the model's centre (rest pose)"),
820 ("Wis handmatige pivot; gebruik het midden van het model (rustpose)", "Clear manual pivot; use the model's centre (rest pose)"),
818 ("Laad een glTF/GLB-, FBX- of OBJ-lichaam om de pivot te bepalen.", "Load a glTF/GLB, FBX or OBJ body to determine the pivot."),
821 ("Laad een glTF/GLB-, FBX- of OBJ-lichaam om de pivot te bepalen.", "Load a glTF/GLB, FBX or OBJ body to determine the pivot."),
src/preview_gl.rs +47 −3
@@ -442,7 +442,9 @@ impl PendingFrame {
442 442 positions: layer.mesh.positions,
443 443 normals: layer.mesh.normals,
444 444 indices: layer.mesh.indices,
445 uvs: layer.mesh.uvs,
445 // Equipment meshes keep the OBJ/FBX bottom-left UV origin; the GPU
446 // path uses the glTF top-left origin (row 0 = top), like production.
447 uvs: layer.mesh.uvs.iter().map(|[u, v]| [*u, 1. - *v]).collect(),
446 448 color: layer.mesh.color,
447 449 joints: Vec::new(),
448 450 weights: Vec::new(),
@@ -844,7 +846,7 @@ void main() {
844 846 + a_weights.z * u_bones[j2]
845 847 + a_weights.w * u_bones[j3];
846 848 vec4 world = u_model * skin * vec4(a_pos, 1.0);
847 v_uv = vec2(a_uv.x,1.0-a_uv.y); v_world=world.xyz;
849 v_uv = a_uv; v_world=world.xyz;
848 850 v_normal = transpose(inverse(mat3(u_model * skin))) * a_normal;
849 851 gl_Position = u_view_proj * world;
850 852 }
@@ -1145,7 +1147,47 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
1145 1147 "materiaal per primitive verwisseld: {surface_swap:?}"
1146 1148 ));
1147 1149 }
1148 println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}");
1150 // UV origin (Tim t1009u): body UVs are glTF (v = 0 is the top row of
1151 // the image), equipment UVs are bottom-left (v = 0 is the bottom row).
1152 let red_top_blue_bottom = Arc::new(crate::equipment::Texture {
1153 content_hash: 6,
1154 width: 1,
1155 height: 2,
1156 rgba: vec![255, 0, 0, 255, 0, 0, 255, 255],
1157 });
1158 let mut body = make_frame(vec![0, 1, 2], 14);
1159 body.uvs = vec![[0.5, 0.25]; 6];
1160 body.surfaces = vec![SurfaceDraw {
1161 first: 0,
1162 count: 3,
1163 color: [1.; 4],
1164 albedo: Some(red_top_blue_bottom.clone()),
1165 cutoff: 0.001,
1166 specular: 0.,
1167 shiny: None,
1168 }];
1169 cache.pending = Some(body);
1170 cache.paint(gl, &info, true);
1171 let body_uv = read();
1172 if body_uv[0] < 250 || body_uv[2] > 2 {
1173 return Err(format!(
1174 "body-UV niet volgens glTF (v=0 boven): {body_uv:?}"
1175 ));
1176 }
1177 let mut body = make_frame(vec![3, 4, 5], 15);
1178 let mut front = make_frame(vec![0, 1, 2], 16);
1179 front.uvs = vec![[0.5, 0.75]; 6]; // already converted from bottom-left v = 0.25
1180 front.albedo = Some(red_top_blue_bottom);
1181 body.equipment = vec![front];
1182 cache.pending = Some(body);
1183 cache.paint(gl, &info, true);
1184 let equipment_uv = read();
1185 if equipment_uv[2] < 250 || equipment_uv[0] > 2 {
1186 return Err(format!(
1187 "equipment-UV verkeerd georiënteerd: {equipment_uv:?}"
1188 ));
1189 }
1190 println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}, body-uv-top={body_uv:?}, equipment-uv-bottom={equipment_uv:?}");
1149 1191 Ok(())
1150 1192 })();
1151 1193 cache.destroy(gl);
@@ -1177,6 +1219,8 @@ mod tests {
1177 1219 );
1178 1220 assert_eq!(frame.surfaces.len(), 3);
1179 1221 assert_eq!(frame.uvs.len(), frame.positions.len());
1222 // glTF UVs reach the GPU unchanged (top-left origin, no V flip).
1223 assert_eq!(frame.uvs, body.uvs);
1180 1224 let ranges: Vec<_> = frame.surfaces.iter().map(|s| (s.first, s.count)).collect();
1181 1225 assert_eq!(ranges, [(0, 6), (6, 6), (12, 6)]);
1182 1226 assert_eq!(frame.surfaces[0].color, [0.2, 0.4, 0.9, 1.0]);
@@ -442,7 +442,9 @@ impl PendingFrame {
442 positions: layer.mesh.positions,
442 positions: layer.mesh.positions,
443 normals: layer.mesh.normals,
443 normals: layer.mesh.normals,
444 indices: layer.mesh.indices,
444 indices: layer.mesh.indices,
445 uvs: layer.mesh.uvs,
445 // Equipment meshes keep the OBJ/FBX bottom-left UV origin; the GPU
446 // path uses the glTF top-left origin (row 0 = top), like production.
447 uvs: layer.mesh.uvs.iter().map(|[u, v]| [*u, 1. - *v]).collect(),
446 color: layer.mesh.color,
448 color: layer.mesh.color,
447 joints: Vec::new(),
449 joints: Vec::new(),
448 weights: Vec::new(),
450 weights: Vec::new(),
@@ -844,7 +846,7 @@ void main() {
844 + a_weights.z * u_bones[j2]
846 + a_weights.z * u_bones[j2]
845 + a_weights.w * u_bones[j3];
847 + a_weights.w * u_bones[j3];
846 vec4 world = u_model * skin * vec4(a_pos, 1.0);
848 vec4 world = u_model * skin * vec4(a_pos, 1.0);
847 v_uv = vec2(a_uv.x,1.0-a_uv.y); v_world=world.xyz;
849 v_uv = a_uv; v_world=world.xyz;
848 v_normal = transpose(inverse(mat3(u_model * skin))) * a_normal;
850 v_normal = transpose(inverse(mat3(u_model * skin))) * a_normal;
849 gl_Position = u_view_proj * world;
851 gl_Position = u_view_proj * world;
850}
852}
@@ -1145,7 +1147,47 @@ pub unsafe fn depth_regression_probe(gl: &glow::Context) -> Result<(), String> {
1145 "materiaal per primitive verwisseld: {surface_swap:?}"
1147 "materiaal per primitive verwisseld: {surface_swap:?}"
1146 ));
1148 ));
1147 }
1149 }
1148 println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}");
1150 // UV origin (Tim t1009u): body UVs are glTF (v = 0 is the top row of
1151 // the image), equipment UVs are bottom-left (v = 0 is the bottom row).
1152 let red_top_blue_bottom = Arc::new(crate::equipment::Texture {
1153 content_hash: 6,
1154 width: 1,
1155 height: 2,
1156 rgba: vec![255, 0, 0, 255, 0, 0, 255, 255],
1157 });
1158 let mut body = make_frame(vec![0, 1, 2], 14);
1159 body.uvs = vec![[0.5, 0.25]; 6];
1160 body.surfaces = vec![SurfaceDraw {
1161 first: 0,
1162 count: 3,
1163 color: [1.; 4],
1164 albedo: Some(red_top_blue_bottom.clone()),
1165 cutoff: 0.001,
1166 specular: 0.,
1167 shiny: None,
1168 }];
1169 cache.pending = Some(body);
1170 cache.paint(gl, &info, true);
1171 let body_uv = read();
1172 if body_uv[0] < 250 || body_uv[2] > 2 {
1173 return Err(format!(
1174 "body-UV niet volgens glTF (v=0 boven): {body_uv:?}"
1175 ));
1176 }
1177 let mut body = make_frame(vec![3, 4, 5], 15);
1178 let mut front = make_frame(vec![0, 1, 2], 16);
1179 front.uvs = vec![[0.5, 0.75]; 6]; // already converted from bottom-left v = 0.25
1180 front.albedo = Some(red_top_blue_bottom);
1181 body.equipment = vec![front];
1182 cache.pending = Some(body);
1183 cache.paint(gl, &info, true);
1184 let equipment_uv = read();
1185 if equipment_uv[2] < 250 || equipment_uv[0] > 2 {
1186 return Err(format!(
1187 "equipment-UV verkeerd georiënteerd: {equipment_uv:?}"
1188 ));
1189 }
1190 println!("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}, body-uv-top={body_uv:?}, equipment-uv-bottom={equipment_uv:?}");
1149 Ok(())
1191 Ok(())
1150 })();
1192 })();
1151 cache.destroy(gl);
1193 cache.destroy(gl);
@@ -1177,6 +1219,8 @@ mod tests {
1177 );
1219 );
1178 assert_eq!(frame.surfaces.len(), 3);
1220 assert_eq!(frame.surfaces.len(), 3);
1179 assert_eq!(frame.uvs.len(), frame.positions.len());
1221 assert_eq!(frame.uvs.len(), frame.positions.len());
1222 // glTF UVs reach the GPU unchanged (top-left origin, no V flip).
1223 assert_eq!(frame.uvs, body.uvs);
1180 let ranges: Vec<_> = frame.surfaces.iter().map(|s| (s.first, s.count)).collect();
1224 let ranges: Vec<_> = frame.surfaces.iter().map(|s| (s.first, s.count)).collect();
1181 assert_eq!(ranges, [(0, 6), (6, 6), (12, 6)]);
1225 assert_eq!(ranges, [(0, 6), (6, 6), (12, 6)]);
1182 assert_eq!(frame.surfaces[0].color, [0.2, 0.4, 0.9, 1.0]);
1226 assert_eq!(frame.surfaces[0].color, [0.2, 0.4, 0.9, 1.0]);