Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
a8f66e7
Enable Bevy experimental_pbr_pcss for soft directional shadows
matbeedotcom Oct 20, 2026
0fb2df5
Add CC5 avatar editor, cc5-shape crate and CC5 shader materials
matbeedotcom Oct 20, 2026
6404e0d
Add library hair to the CC5 avatar editor
matbeedotcom Oct 21, 2026
a587f47
cc5-shape: bind Conform with an exact grid search, in parallel
matbeedotcom Oct 21, 2026
cd6b32f
Add cc5-hair (CC5 hair -> strand parts in Rust) and live hair editing
matbeedotcom Oct 21, 2026
142a9b0
Add cc5-fbx: CC5 FBX exports to GLB with ufbx (no Blender); cc5-hair …
matbeedotcom Oct 21, 2026
bdf0a2a
Hair strands as GPU ribbons: centre lines in the part GLB, expanded i…
matbeedotcom Oct 21, 2026
f299e23
cc5-hair: regions, check and --style; scripted hair-edit save in the …
matbeedotcom Oct 21, 2026
9f9b0f7
cc5-fbx expressions: the editor's expression pack without Blender; pa…
matbeedotcom Oct 21, 2026
3373141
avatar_editor: hair edit sliders show cc5-hair's defaults for unset s…
matbeedotcom Oct 21, 2026
8fbf400
cc5-hair: tests for skin_at weights and the part GLB round trip
matbeedotcom Oct 21, 2026
64f86b3
cc5-fbx: unique joint names, as Blender makes them
matbeedotcom Oct 21, 2026
9410b30
avatar_editor: Expressions section keeps its place while sliders move
matbeedotcom Oct 21, 2026
4bfa7c7
avatar_editor: a base body, CC5's neutral female by default
matbeedotcom Oct 21, 2026
877c85a
avatar_editor: open the default character and pack when none are given
matbeedotcom Oct 21, 2026
85c22d8
docs: fix a corrupted path in the avatar editor's Running section
matbeedotcom Oct 21, 2026
e48ba0c
avatar_editor: Skin section (CC5 skin settings, SkinGen skins and lay…
matbeedotcom Oct 21, 2026
5b177ac
CC5 skin: calibrated against CC5 renders
matbeedotcom Oct 21, 2026
0db2b80
CC5 skin colour adjustments: human colour, blend map, brightness, sat…
matbeedotcom Oct 21, 2026
6d8ddf3
CC5 human colour: skin colour range term, more slider samples (combo …
matbeedotcom Oct 21, 2026
742f5d6
Skin layers carry roughness: solved with the layer's opacity, composi…
matbeedotcom Oct 21, 2026
3fe3076
Skin layers carry normals (scars, wrinkles, acne): from CC5's exporte…
matbeedotcom Oct 21, 2026
ad6234d
Skin: generated makeup and details from the head's region masks (lips…
matbeedotcom Oct 21, 2026
4add701
Skin generators: softer blush, placed highlight, finer freckles and s…
matbeedotcom Oct 21, 2026
1e3fa09
Skin layers: placement (move / scale / rotate), per-layer roughness a…
matbeedotcom Oct 21, 2026
f7b905f
Skin generator: a mole / spot at a UV position (size, colour); placed…
matbeedotcom Oct 21, 2026
caf8f07
cc5-skin build: refuse a second base the SkinGen bake ignored
matbeedotcom Oct 21, 2026
ef7f162
Shaped normals follow the surface: CC5's normals turned by the change…
matbeedotcom Oct 21, 2026
f074dca
cc5-skin build: refuse a layer CC5 did not apply on the second base
matbeedotcom Oct 21, 2026
e5b62c8
cc5-skin: per-layer blend modes (normal, multiply, screen)
matbeedotcom Oct 21, 2026
873142b
cc5-skin: layer parts in other blend modes
matbeedotcom Oct 21, 2026
817af73
avatar_editor: CC5_EXPOSURE grading knob (stops)
matbeedotcom Oct 21, 2026
5994c9b
avatar_editor: ACES grading for CC5 mode
matbeedotcom Oct 21, 2026
51b51b6
cc5-skin: generator shapes fitted to SkinGen presets; generate CLI; p…
matbeedotcom Oct 21, 2026
25ed925
Skin editor: SkinGen layer controls (colour adjust, masks, blend, ran…
matbeedotcom Oct 21, 2026
cf8f837
docs: avatar editor skin: generated layers, controls against SkinGen's
matbeedotcom Oct 21, 2026
9bb09aa
avatar_editor: camera lens = CC5's Preview Camera (80 mm, 17.06 deg v…
matbeedotcom Oct 21, 2026
794ce2f
avatar_editor: cc5 views fit the film gate's height (square pixels)
matbeedotcom Oct 21, 2026
a103036
Eyes: Iris UV Scale shrinks the pupil; iris brightness calibrated aga…
matbeedotcom Oct 21, 2026
2397be5
Skin quality vs CC5: SSS map centred on grey, skin mipmaps, 2K skin m…
matbeedotcom Oct 21, 2026
fa567f9
Skin: overlay / soft-light blends, reordering, CC5-mode fill calibration
matbeedotcom Oct 21, 2026
0a4627a
avatar_editor: CC5 light tint refitted under ACES at CC5's framing
matbeedotcom Oct 21, 2026
898389a
avatar_editor: snapshots wait for the skin composite to be on the mat…
matbeedotcom Oct 21, 2026
3ef2c25
Skin editor: skin materials that appear after the first get the recip…
matbeedotcom Oct 21, 2026
e107855
avatar_editor: CC5's rect lights get a soft radius (0.25 m, fitted)
matbeedotcom Oct 21, 2026
3578b35
Skin editor: per-layer metallic, occlusion and glow (SkinGen layer Ma…
matbeedotcom Oct 21, 2026
f662967
cc5-skin: stubble lays a haze under its hairs, as CC5's stubble
matbeedotcom Oct 21, 2026
7a45b88
cc5-skin: stubble as short hair strokes over a pale haze (two tones)
matbeedotcom Oct 21, 2026
364a0dc
avatar_editor: CC5 light gains refitted under ACES at CC5's framing
matbeedotcom Oct 21, 2026
5b11641
avatar_editor: CC5 mode settings per snapshot job (batched calibration)
matbeedotcom Oct 21, 2026
c52ac62
CC5 mode: scalp hair colour corrected for the light tint; refitted wr…
matbeedotcom Oct 21, 2026
c65e4fc
CC5 skin: normal maps unflipped (green up)
matbeedotcom Oct 21, 2026
76683fd
CC5 mode: soft shadows with a minimum penumbra, as CC5's
matbeedotcom Oct 21, 2026
05dd2c3
CC5 hair: strand highlights from spot / point lights; lighter roots i…
matbeedotcom Oct 21, 2026
b0c98e8
CC5 mode: CC5's screen-space render backdrop
matbeedotcom Oct 21, 2026
6b994eb
cc5-skin generators: region-border features; refitted stubble, contou…
matbeedotcom Oct 21, 2026
65add1f
cc5-skin generators: mirrored UV blob features; contour, highlight, s…
matbeedotcom Oct 21, 2026
56ba1c6
cc5-skin stubble: finer, denser, paler default hairs
matbeedotcom Oct 21, 2026
1479d35
Avatar editor timelapse: sliders move from a start shape to a target …
matbeedotcom Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
target/
Cargo.lock
*.bk
target-*/
17 changes: 16 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ publish = false
# bevy / wgpu / bevy_rapier3d deps don't unify against the host's, and
# heavy renderer stack compiles in isolation.
[workspace]
# `cc5-shape`: CC5 shaping sliders (Rust + Python); `cc5-hair`: CC5 hair -> strand parts.
members = ["crates/cc5-shape", "crates/cc5-hair", "crates/cc5-fbx", "crates/cc5-skin"]

[profile.release]
opt-level = 3
Expand Down Expand Up @@ -50,7 +52,8 @@ tracing = "0.1"
# explicitly carry that default set here so the standalone-workspace
# build doesn't drop `x11`, `multi_threaded`, etc. and break under
# winit / wgpu.
bevy = { version = "0.15", features = ["jpeg", "tonemapping_luts", "ktx2", "zstd"] }
# experimental_pbr_pcss: soft (PCSS) directional shadows for the avatar editor's studio key light.
bevy = { version = "0.15", features = ["jpeg", "tonemapping_luts", "ktx2", "zstd", "experimental_pbr_pcss"] }
# bevy_rapier3d shipped unconditionally — no nested cargo features.
bevy_rapier3d = { version = "=0.28.0", default-features = false, features = ["dim3", "async-collider"] }
# wgpu pinned to the version Bevy 0.15.3 bundles. Aliased to `wgpu_pin`
Expand All @@ -66,3 +69,15 @@ crossbeam-channel = "0.5"
# a few regexes — bring in `regex` directly.
regex = "1"
tempfile = "3.27.0"
# CC5 shaping sliders (avatar editor); also a standalone crate + Python module.
cc5-shape = { path = "crates/cc5-shape" }

[dev-dependencies]
# `examples/avatar_editor.rs` UI; 0.32 is the Bevy 0.15 line.
bevy_egui = "0.32"
# live hair editing in the avatar editor (CC5 hair -> strands)
cc5-hair = { path = "crates/cc5-hair" }
# avatar editor: skin layers / skins captured from CC5
cc5-skin = { path = "crates/cc5-skin" }
# avatar editor: GPU-ribbon hair expansion in parallel (already in the tree via cc5-shape)
rayon = "1"
23 changes: 23 additions & 0 deletions crates/cc5-fbx/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
[package]
name = "cc5-fbx"
version = "0.1.0"
edition = "2021"
publish = false
description = "Read Character Creator 5 FBX exports (ufbx) and write the GLBs cc-render loads, without Blender."

[lib]
name = "cc5_fbx"

[[bin]]
name = "cc5-fbx"
path = "src/main.rs"

[dependencies]
anyhow = "1"
# 0.29 = the glam Bevy 0.15 uses
glam = "0.29"
serde = "1"
serde_json = { version = "1", features = ["preserve_order"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
rayon = "1"
ufbx = "0.11"
194 changes: 194 additions & 0 deletions crates/cc5-fbx/src/cc5json.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//! CC5's export sidecar JSON (next to the FBX): material tints and opacity
//! (bake_alpha.py's authoritative first pass) and the CC5 shader settings and
//! maps as `extras.cc5` (glb_cc5_materials.py):
//!
//! ```json
//! {"shader": "RLHair", "maps": {"Hair Flow Map": "character.cc5maps/..._flow.png", ...},
//! "srgb": ["..."], "vars": {...}, "sss": {...}, "wrinkle": {"maps": {...}, "rules": [...], ...}}
//! ```

use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};

use anyhow::{Context, Result};
use serde_json::{json, Map, Value};

pub struct Cc5Json {
pub path: PathBuf,
/// Every material under Meshes (bake_alpha.py's `load_cc5_json`).
specs: HashMap<String, Value>,
/// Materials with a CC5 custom shader, anywhere in the file.
shaders: HashMap<String, Value>,
}

/// Writes the maps `extras.cc5` names into one folder (TGA re-encoded as PNG).
pub struct Maps {
base: PathBuf,
dir: PathBuf,
done: HashMap<PathBuf, String>,
pub missing: Vec<PathBuf>,
}

impl Maps {
/// Maps named relative to `json`'s folder, written into `dir`.
pub fn new(json: &Path, dir: PathBuf) -> Self {
Self { base: json.parent().unwrap_or(Path::new(".")).to_path_buf(), dir, done: HashMap::new(), missing: Vec::new() }
}

/// The map's path relative to `dir`'s parent, or None when the file is missing.
fn get(&mut self, rel: &str) -> Result<Option<String>> {
let path = normalize(&self.base.join(rel.replace('\\', "/")));
if let Some(r) = self.done.get(&path) {
return Ok(Some(r.clone()));
}
if !path.is_file() {
self.missing.push(path);
return Ok(None);
}
let (bytes, mime) = crate::glb::file_bytes(&path)?;
let stem = path.file_stem().unwrap().to_string_lossy();
let fname = format!("{stem}.{}", if mime == "image/png" { "png" } else { "jpg" });
std::fs::create_dir_all(&self.dir)?;
let out = self.dir.join(&fname);
// shared between rebuilds: rewrite only when it differs
if std::fs::metadata(&out).map(|m| m.len() as usize != bytes.len()).unwrap_or(true) {
std::fs::write(&out, &bytes).with_context(|| format!("writing {}", out.display()))?;
}
let r = format!("{}/{fname}", self.dir.file_name().unwrap().to_string_lossy());
self.done.insert(path, r.clone());
Ok(Some(r))
}
}

fn normalize(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
c => out.push(c),
}
}
out
}

/// Colour maps (sRGB); the rest are data.
fn is_srgb(slot: &str) -> bool {
let l = slot.to_lowercase();
l.contains("basecolor") || l.contains("diffuse") || l == "sclera" || l.contains("color")
}

fn clean(vars: Option<&Value>) -> Value {
let mut m = Map::new();
if let Some(o) = vars.and_then(Value::as_object) {
for (k, v) in o {
if !k.to_lowercase().starts_with("padding") {
m.insert(k.clone(), v.clone());
}
}
}
Value::Object(m)
}

impl Cc5Json {
pub fn load(path: &Path) -> Result<Self> {
let data: Value = serde_json::from_slice(&std::fs::read(path).with_context(|| format!("reading {}", path.display()))?)
.with_context(|| format!("parsing {}", path.display()))?;
let mut specs = HashMap::new();
if let Some((rig, r)) = data.as_object().and_then(|o| o.iter().next()) {
let obj = r.get("Object").and_then(Value::as_object);
let inner = obj.and_then(|o| o.get(rig).or_else(|| o.values().next()));
if let Some(meshes) = inner.and_then(|i| i.get("Meshes")).and_then(Value::as_object) {
for m in meshes.values() {
if let Some(mats) = m.get("Materials").and_then(Value::as_object) {
for (name, mat) in mats {
if mat.is_object() {
specs.insert(name.clone(), mat.clone());
}
}
}
}
}
}
let mut shaders = HashMap::new();
fn walk(v: &Value, out: &mut HashMap<String, Value>) {
match v {
Value::Object(o) => {
for (k, x) in o {
if x.get("Custom Shader").is_some() {
out.insert(k.clone(), x.clone());
} else {
walk(x, out);
}
}
}
Value::Array(a) => a.iter().for_each(|x| walk(x, out)),
_ => {}
}
}
walk(&data, &mut shaders);
Ok(Self { path: path.to_path_buf(), specs, shaders })
}

/// CC5's diffuse tint and opacity; transparent families (`*_Transparency`,
/// Std_Eyelash) blend at full alpha (their maps carry it).
pub fn apply(&self, name: &str, factor: &mut [f32; 4], blend: &mut bool) {
if let Some(spec) = self.specs.get(name) {
if let Some(dc) = spec.get("Diffuse Color").and_then(Value::as_array).filter(|a| a.len() >= 3) {
for k in 0..3 {
factor[k] = (dc[k].as_f64().unwrap_or(255.0) as f32 / 255.0).clamp(0.0, 1.0);
}
}
if let Some(o) = spec.get("Opacity").and_then(Value::as_f64) {
factor[3] = (o as f32).clamp(0.0, 1.0);
}
if factor[3] < 0.999 {
*blend = true;
}
}
if name.ends_with("_Transparency") || name == "Std_Eyelash" {
*blend = true;
factor[3] = 1.0;
}
}

/// `extras.cc5` for a material with a CC5 custom shader.
pub fn extras(&self, name: &str, maps: &mut Maps) -> Result<Option<Value>> {
let Some(cm) = self.shaders.get(name) else { return Ok(None) };
let cs = &cm["Custom Shader"];
let mut info = json!({"shader": cs.get("Shader Name").and_then(Value::as_str).unwrap_or(""), "maps": {}, "srgb": [],
"vars": clean(cs.get("Variable"))});
if let Some(imgs) = cs.get("Image").and_then(Value::as_object) {
for (slot, t) in imgs {
let Some(p) = t.get("Texture Path").and_then(Value::as_str).filter(|p| !p.is_empty()) else { continue };
if let Some(r) = maps.get(p)? {
info["maps"][slot] = json!(r);
if is_srgb(slot) {
info["srgb"].as_array_mut().unwrap().push(json!(slot));
}
}
}
}
if let Some(s) = cm.get("Subsurface Scatter").filter(|s| !s.is_null() && s.as_object().map_or(true, |o| !o.is_empty())) {
info["sss"] = s.clone();
}
if let Some(w) = cm.get("Wrinkle") {
if let Some(tex) = w.get("Textures").and_then(Value::as_object).filter(|t| !t.is_empty()) {
let mut wm = Map::new();
for (slot, t) in tex {
let Some(p) = t.get("Texture Path").and_then(Value::as_str).filter(|p| !p.is_empty()) else { continue };
if let Some(r) = maps.get(p)? {
wm.insert(slot.clone(), json!(r));
}
}
info["wrinkle"] = json!({"maps": wm, "rules": w.get("WrinkleRules").cloned().unwrap_or(json!([])),
"rule_weights": w.get("WrinkleRuleWeights").cloned().unwrap_or(json!([])),
"ease_strength": w.get("WrinkleEaseStrength").cloned().unwrap_or(Value::Null),
"overall_weight": w.get("WrinkleOverallWeight").cloned().unwrap_or(Value::Null)});
}
}
Ok(Some(info))
}
}
67 changes: 67 additions & 0 deletions crates/cc5-fbx/src/dump.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//! The joints / weights / rest-position dump (as Blender's fbx_to_glb.py wrote
//! it), for the pack builders (build_editor_pack.py, build_hd_map.py) and
//! `cc5-hair regions`. In Blender's frame (Z up, metres):
//!
//! ```json
//! {"joints": {name: [x, y, z]}, // bind-pose joint positions
//! "meshes": {name: vertex count}, // control points
//! "mesh_order": [name, ...],
//! "parents": {name: parent or null},
//! "weights": {mesh: [[[joint, w], ...] per vertex]}}
//! ```
//! plus `<dump>.verts.f32`: every mesh's rest positions (f32 xyz), in `mesh_order`.

use std::path::Path;

use anyhow::{Context, Result};
use glam::Vec3;
use serde_json::{json, Map, Value};

use crate::Character;

/// Y-up -> Blender's Z-up.
fn z_up(p: Vec3) -> [f32; 3] {
[p.x, -p.z, p.y]
}

fn num(x: f32) -> Value {
json!(x as f64)
}

pub fn write(c: &Character, path: &Path) -> Result<()> {
let mut joints = Map::new();
let mut parents = Map::new();
for j in &c.joints {
joints.insert(j.name.clone(), json!(z_up(j.world.w_axis.truncate()).map(num)));
parents.insert(j.name.clone(), j.parent.map_or(Value::Null, |p| json!(c.joints[p].name)));
}
let mut meshes = Map::new();
let mut weights = Map::new();
let mut flat: Vec<u8> = Vec::new();
for m in &c.meshes {
meshes.insert(m.name.clone(), json!(m.points.len()));
weights.insert(
m.name.clone(),
Value::Array(
m.weights
.iter()
.map(|ws| json!(ws.iter().map(|&(j, w)| json!([c.joints[j as usize].name, (w as f64 * 1e6).round() / 1e6])).collect::<Vec<_>>()))
.collect(),
),
);
for p in &m.points {
for x in z_up(*p) {
flat.extend_from_slice(&x.to_le_bytes());
}
}
}
let order: Vec<&str> = c.meshes.iter().map(|m| m.name.as_str()).collect();
let out = json!({"joints": joints, "meshes": meshes, "mesh_order": order, "parents": parents, "weights": weights});
let mut text = Vec::new();
let mut ser = serde_json::Serializer::with_formatter(&mut text, serde_json::ser::PrettyFormatter::with_indent(b" "));
serde::Serialize::serialize(&out, &mut ser)?;
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
let vp = format!("{}.verts.f32", path.display());
std::fs::write(&vp, flat).with_context(|| format!("writing {vp}"))?;
Ok(())
}
Loading
Loading