| 1 | #![allow(clippy::needless_range_loop)] |
| 2 | //! Bakes the hair of Tim's original "Animated Female Base Mesh" into the |
| 3 | //! bundled rigid equipment preset `female-hair` (design.md, t1019u): the hair |
| 4 | //! primitive (material ponytail01, last primitive) is skinned once at the rest |
| 5 | //! pose, expressed in the rest-pose space of the `head` joint, written as |
| 6 | //! OBJ + MTL + PNG, imported with the normal OBJ importer and bound to `head` |
| 7 | //! of the edited default female with zero offset. |
| 8 | //! Usage: bake_female_hair ORIGINAL_FEMALE.gltf HAIR_TEXTURE.png EDITED_FEMALE.gltf OUT.ppe |
| 9 | use paperdoll_renderer::{equipment, preview}; |
| 10 | use std::io::Write; |
| 11 | |
| 12 | fn inverse(m: [f32; 16]) -> [f32; 16] { |
| 13 | let mut a = [[0f64; 8]; 4]; |
| 14 | for r in 0..4 { |
| 15 | for c in 0..4 { |
| 16 | a[r][c] = m[c * 4 + r] as f64; |
| 17 | } |
| 18 | a[r][4 + r] = 1.; |
| 19 | } |
| 20 | for c in 0..4 { |
| 21 | let p = (c..4) |
| 22 | .max_by(|&x, &y| a[x][c].abs().total_cmp(&a[y][c].abs())) |
| 23 | .unwrap(); |
| 24 | a.swap(c, p); |
| 25 | let d = a[c][c]; |
| 26 | for k in 0..8 { |
| 27 | a[c][k] /= d; |
| 28 | } |
| 29 | for r in 0..4 { |
| 30 | if r != c { |
| 31 | let f = a[r][c]; |
| 32 | for k in 0..8 { |
| 33 | a[r][k] -= f * a[c][k]; |
| 34 | } |
| 35 | } |
| 36 | } |
| 37 | } |
| 38 | let mut out = [0f32; 16]; |
| 39 | for r in 0..4 { |
| 40 | for c in 0..4 { |
| 41 | out[c * 4 + r] = a[r][4 + c] as f32; |
| 42 | } |
| 43 | } |
| 44 | out |
| 45 | } |
| 46 | fn apply(m: &[f32; 16], p: [f32; 3], w: f32) -> [f32; 3] { |
| 47 | [0, 1, 2].map(|i| m[i] * p[0] + m[4 + i] * p[1] + m[8 + i] * p[2] + m[12 + i] * w) |
| 48 | } |
| 49 | |
| 50 | fn main() -> Result<(), Box<dyn std::error::Error>> { |
| 51 | let a: Vec<String> = std::env::args().skip(1).collect(); |
| 52 | let body = preview::load_body_file(std::path::Path::new(&a[0]))?; |
| 53 | let hair = body.surfaces.last().ok_or("no surfaces")?; |
| 54 | let bones: Vec<[f32; 16]> = (0..body.joint_nodes.len()) |
| 55 | .map(|j| { |
| 56 | preview::mul( |
| 57 | preview::rest_joint_world(&body, j).unwrap(), |
| 58 | body.inverse_binds[j], |
| 59 | ) |
| 60 | }) |
| 61 | .collect(); |
| 62 | let head = body |
| 63 | .joint_names |
| 64 | .iter() |
| 65 | .position(|n| n.to_ascii_lowercase().starts_with("head")) |
| 66 | .ok_or("no head joint")?; |
| 67 | let head_world = preview::animated_joint_world(&body, "", 0.0, head).ok_or("head")?; |
| 68 | let to_head = inverse(head_world); |
| 69 | let idx = &body.indices[hair.first..hair.first + hair.count]; |
| 70 | let mut used: Vec<u32> = idx.to_vec(); |
| 71 | used.sort_unstable(); |
| 72 | used.dedup(); |
| 73 | let map: std::collections::HashMap<u32, usize> = |
| 74 | used.iter().enumerate().map(|(i, v)| (*v, i + 1)).collect(); |
| 75 | let dir = tempdir(); |
| 76 | let mut obj = std::fs::File::create(dir.join("female-hair.obj"))?; |
| 77 | writeln!(obj, "# Hair of \"Animated Female Base Mesh\" by assetfactory (Sketchfab Standard), baked rigid to {}", body.joint_names[head])?; |
| 78 | writeln!(obj, "mtllib female-hair.mtl\nusemtl hair")?; |
| 79 | for v in &used { |
| 80 | let i = *v as usize; |
| 81 | let mut m = [0f32; 16]; |
| 82 | for (j, w) in body.joints[i].iter().zip(body.weights[i]) { |
| 83 | for k in 0..16 { |
| 84 | m[k] += bones[*j as usize][k] * w; |
| 85 | } |
| 86 | } |
| 87 | let p = apply(&to_head, apply(&m, body.positions[i], 1.), 1.); |
| 88 | let n = apply(&to_head, apply(&m, body.normals[i], 0.), 0.); |
| 89 | let l = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt().max(1e-9); |
| 90 | let uv = body.uvs[i]; |
| 91 | writeln!(obj, "v {} {} {}", p[0], p[1], p[2])?; |
| 92 | writeln!(obj, "vt {} {}", uv[0], 1. - uv[1])?; |
| 93 | writeln!(obj, "vn {} {} {}", n[0] / l, n[1] / l, n[2] / l)?; |
| 94 | } |
| 95 | for t in idx.chunks_exact(3) { |
| 96 | let f = [map[&t[0]], map[&t[1]], map[&t[2]]]; |
| 97 | writeln!( |
| 98 | obj, |
| 99 | "f {0}/{0}/{0} {1}/{1}/{1} {2}/{2}/{2}", |
| 100 | f[0], f[1], f[2] |
| 101 | )?; |
| 102 | } |
| 103 | drop(obj); |
| 104 | std::fs::write( |
| 105 | dir.join("female-hair.mtl"), |
| 106 | "newmtl hair\nKd 1 1 1\nmap_Kd female-hair.png\n", |
| 107 | )?; |
| 108 | std::fs::copy(&a[1], dir.join("female-hair.png"))?; |
| 109 | let edited = preview::load_body_file(std::path::Path::new(&a[2]))?; |
| 110 | let mut preset = equipment::Preset::new("female-hair".into()); |
| 111 | preset.applies = [false, true]; |
| 112 | preset.provenance = "Hair of \"Animated Female Base Mesh\" by assetfactory, https://sketchfab.com/3d-models/animated-female-base-mesh-52fc9c15cf3e49d2a5a9cee6444964ec, Sketchfab Standard licence. Baked rigid to the head joint at the rest pose (was skinned to several bones).".into(); |
| 113 | let part = equipment::import_obj(&mut preset, &dir.join("female-hair.obj"))?; |
| 114 | let head_edited = edited |
| 115 | .joint_names |
| 116 | .iter() |
| 117 | .position(|n| n == &body.joint_names[head]) |
| 118 | .ok_or("head missing in edited body")?; |
| 119 | preset.bind(1, &edited, part, head_edited)?; |
| 120 | // Tim t1023u defaults: position relative to the bone, rotation, gloss. |
| 121 | preset.attachments[part].translation[1] = [0.0, -0.370, 0.0]; |
| 122 | preset.attachments[part].rotation_deg[1] = [0.0, 0.0, 0.0]; |
| 123 | preset.attachments[part].specular = 0.62; |
| 124 | std::fs::write(&a[3], preset.encode()?)?; |
| 125 | println!( |
| 126 | "hair: {} vertices, {} triangles, bound to {}", |
| 127 | used.len(), |
| 128 | idx.len() / 3, |
| 129 | body.joint_names[head] |
| 130 | ); |
| 131 | Ok(()) |
| 132 | } |
| 133 | fn tempdir() -> std::path::PathBuf { |
| 134 | let d = std::env::temp_dir().join(format!("bake-hair-{}", std::process::id())); |
| 135 | std::fs::create_dir_all(&d).unwrap(); |
| 136 | d |
| 137 | } |