From 8bd052c4cc093d015d3f031649af76c39de9f557 Mon Sep 17 00:00:00 2001 From: chazmm Date: Wed, 9 Sep 2026 04:19:07 +0100 Subject: [PATCH] Prepare complete character content on first run and restore stock and clothing lighting --- crates/skate-game/src/custom_models.rs | 109 ++++++++++++++-- crates/skate-game/src/customiser.rs | 27 +++- crates/skate-game/src/customiser_material.rs | 16 +++ crates/skate-game/src/customiser_parts.rs | 45 +++++-- crates/skate-game/src/customiser_stamp.wgsl | 12 +- crates/skate-game/src/retail_character.rs | 83 +++++++++--- crates/skate-game/src/retail_character.wgsl | 45 +------ .../src/retail_character_common.wgsl | 49 ++++++++ crates/skate-game/src/setup.rs | 19 ++- docs/character-customisation.md | 30 +++-- docs/custom-models.md | 9 ++ docs/updates.md | 7 ++ scripts/Build-Release.ps1 | 5 +- tools/asset_pipeline/customisation_library.py | 2 +- tools/asset_pipeline/customisation_native.py | 5 + tools/asset_pipeline/customiser_lighting.py | 91 ++++++++++++++ tools/asset_pipeline/customiser_setup.py | 118 ++++++++++++++++++ tools/asset_pipeline/test_customiser_setup.py | 72 +++++++++++ tools/mixamo_to_skate/check_package.py | 5 + tools/setup.py | 4 +- 20 files changed, 659 insertions(+), 94 deletions(-) create mode 100644 crates/skate-game/src/retail_character_common.wgsl create mode 100644 tools/asset_pipeline/customiser_lighting.py create mode 100644 tools/asset_pipeline/customiser_setup.py create mode 100644 tools/asset_pipeline/test_customiser_setup.py diff --git a/crates/skate-game/src/custom_models.rs b/crates/skate-game/src/custom_models.rs index 475a597a..b90abdbe 100644 --- a/crates/skate-game/src/custom_models.rs +++ b/crates/skate-game/src/custom_models.rs @@ -27,6 +27,14 @@ pub(crate) struct Entry { pub name: String, #[serde(default)] native: Option, + #[serde(skip)] + asset_prefix: String, +} +impl Entry { + fn asset_path(&self, file: &str) -> String { + let prefix = if self.asset_prefix.is_empty() { "characters://" } else { &self.asset_prefix }; + format!("{prefix}entries/{}/{file}", self.id) + } } #[derive(Clone, Deserialize)] struct NativeCharacter { @@ -64,19 +72,23 @@ struct Import { #[derive(Component)] pub(crate) struct CustomModelRoot; #[derive(Component)] +pub(crate) struct NativeModelRoot(pub String); +#[derive(Component)] struct Panel; #[derive(Component)] struct Action(usize); #[derive(Component)] struct ModelScroll; -#[derive(Resource)] +#[derive(Resource, Default)] pub(crate) struct CustomModels { pub open: bool, just_opened: bool, pub active: Option, entries: Vec, directory: PathBuf, + native_directory: PathBuf, + native_prefix: String, request: Option>, pending: Option, active_root: Option, @@ -175,6 +187,16 @@ fn discover(directory: &Path) -> Vec { }); entries } +fn discover_all(directory: &Path, native_directory: &Path, native_prefix: &str) -> Vec { + let mut entries = discover(directory); + for mut native in discover(native_directory).into_iter().filter(|e| e.native.is_some()) { + entries.retain(|entry| entry.id != native.id); + native.asset_prefix = native_prefix.to_owned(); + entries.push(native); + } + entries.sort_by_key(|e| (e.name.to_lowercase(), e.id.clone())); + entries +} fn save_selection(directory: &Path, selected: Option) -> Result<(), String> { std::fs::create_dir_all(directory).map_err(|e| e.to_string())?; let temporary = directory.join("selection.tmp"); @@ -193,7 +215,10 @@ pub(crate) struct CustomModelsPlugin; impl Plugin for CustomModelsPlugin { fn build(&self, app: &mut App) { let directory = library_path(); - let entries = discover(&directory); + let assets = &app.world().resource::().asset_root; + let native_directory = crate::customiser_parts::asset_directory(assets).join("native-roster"); + let native_prefix = format!("{}/", native_directory.strip_prefix(assets).unwrap().to_string_lossy().replace('\\', "/")); + let entries = discover_all(&directory, &native_directory, &native_prefix); let saved = std::fs::read(directory.join("selection.json")) .ok() .and_then(|b| serde_json::from_slice::(&b).ok()) @@ -206,6 +231,8 @@ impl Plugin for CustomModelsPlugin { active: None, entries, directory, + native_directory, + native_prefix, request: saved.map(Some), pending: None, active_root: None, @@ -410,7 +437,7 @@ fn poll_import(mut state: ResMut) { .ok() .and_then(|b| serde_json::from_slice::(&b).ok()); let _ = std::fs::remove_file(&job.result); - state.entries = discover(&state.directory); + state.entries = discover_all(&state.directory, &state.native_directory, &state.native_prefix); state.status = match reply.as_ref().and_then(|r| r["status"].as_str()) { Some("cancelled") => "Import cancelled. Your character is unchanged.".into(), Some("ready") => { @@ -436,12 +463,26 @@ fn poll_import(mut state: ResMut) { }; state.dirty = true; } +fn restore_stock( + state: &mut CustomModels, + parts: &mut crate::customiser_parts::Parts, + animation: &mut crate::animation::AnimationStatus, + scenes: &mut Query<(Entity, &ChildOf, &mut Visibility), With>, +) { + for (e, visibility) in state.stock_visibility.drain(..) { + if let Ok((_, _, mut v)) = scenes.get_mut(e) { *v = visibility; } + } + state.active = None; + parts.applied = serde_json::Value::Null; + *animation = default(); +} fn publish( mut commands: Commands, mut state: ResMut, server: Res, skater: Res, mut animation: ResMut, + mut parts: ResMut, roots: Query>, spawner: Res, instances: Query<&SceneInstance>, @@ -462,13 +503,9 @@ fn publish( if let Some(root) = state.active_root.take() { commands.entity(root).despawn(); } - for (e, visibility) in state.stock_visibility.drain(..) { - if let Ok((_, _, mut v)) = scenes.get_mut(e) { - *v = visibility; - } - } - state.active = None; - *animation = default(); + // Visibility was owned by the alternate character while active. + // Reapply the current outfit even if its saved JSON is unchanged. + restore_stock(&mut state, &mut parts, &mut animation, &mut scenes); state.status = match save_selection(&state.directory, None) { Ok(()) => "Stock skater restored.".into(), Err(e) => format!("Restored; selection could not be saved: {e}"), @@ -478,7 +515,7 @@ fn publish( if state.active.as_ref() != Some(&id) && state.entries.iter().any(|e| e.id == id) => { - let path = format!("characters://entries/{id}/character.glb"); + let path = state.entries.iter().find(|e| e.id == id).unwrap().asset_path("character.glb"); let asset = server.load(path.clone()); let root = commands .spawn(( @@ -489,6 +526,9 @@ fn publish( )) .id(); commands.entity(player).add_child(root); + if let Some(native) = state.entries.iter().find(|e| e.id == id).and_then(|e| e.native.as_ref()) { + commands.entity(root).insert(NativeModelRoot(native.key.clone())); + } state.pending = Some(Pending { id, root, @@ -607,7 +647,7 @@ fn draw( cards.spawn((Button,Action(action),Node { min_width:px(0),height:px(CARD_HEIGHT),align_items:AlignItems::Center,padding:UiRect::all(px(6)),flex_direction:FlexDirection::Column,row_gap:px(6), ..default() }, BackgroundColor(if selected==action { Color::srgb(0.12,0.3,0.34) } else { Color::srgb(0.075,0.105,0.14) }))) .with_children(|card| { - card.spawn((ImageNode::new(server.load(format!("characters://entries/{}/preview.png",entry.id))),Node {width:px(128),height:px(160),..default()})); + card.spawn((ImageNode::new(server.load(entry.asset_path("preview.png"))),Node {width:px(128),height:px(160),..default()})); label(card,&entry.name,15.); if let Some(native) = &entry.native { label(card,&format!("{} ยท Native",native.category),12.); } if state.active.as_ref()==Some(&entry.id) { label(card,"Equipped",14.); } @@ -655,6 +695,51 @@ fn button(parent: &mut ChildSpawnerCommands, action: usize, text: &str, selected mod tests { use super::*; #[test] + fn customiser_fresh_roster_combines_with_personal_imports_without_duplicates() { + let root = std::env::temp_dir().join(format!("skate-roster-sources-{}", std::process::id())); + let personal = root.join("personal"); + let native = root.join("native"); + let pro_id = "a".repeat(64); + let import_id = "b".repeat(64); + for (library, id, name, pro) in [(&personal, &pro_id, "Legacy pro", true), + (&personal, &import_id, "My import", false), (&native, &pro_id, "Current pro", true)] { + let entry = library.join("entries").join(id); + std::fs::create_dir_all(&entry).unwrap(); + std::fs::write(entry.join("manifest.json"), serde_json::to_vec(&serde_json::json!({ + "version":1,"id":id,"name":name,"native":if pro {serde_json::json!({"key":"pro","category":"Pro"})} else {serde_json::Value::Null} + })).unwrap()).unwrap(); + std::fs::write(entry.join("character.glb"), b"fixture").unwrap(); + std::fs::write(entry.join("preview.png"), b"fixture").unwrap(); + } + let entries = discover_all(&personal, &native, "private/roster/"); + assert_eq!(entries.len(), 2); + let pro = entries.iter().find(|e| e.id == pro_id).unwrap(); + assert_eq!(pro.name, "Current pro"); + assert_eq!(pro.asset_path("character.glb"), format!("private/roster/entries/{pro_id}/character.glb")); + let imported = entries.iter().find(|e| e.id == import_id).unwrap(); + assert_eq!(imported.asset_path("preview.png"), format!("characters://entries/{import_id}/preview.png")); + assert_eq!(discover(&personal).len(), 2); // No legacy entry was removed. + std::fs::remove_dir_all(root).unwrap(); + } + #[test] + fn customiser_restore_stock_invalidates_cached_outfit_and_animation() { + use bevy::ecs::system::SystemState; + let mut world = World::new(); + let player = world.spawn_empty().id(); + let scene = world.spawn((SceneRoot(default()), Visibility::Hidden, ChildOf(player))).id(); + let mut state = CustomModels { active: Some("native".into()), stock_visibility: vec![(scene, Visibility::Inherited)], ..default() }; + let mut parts = crate::customiser_parts::Parts::default(); + parts.applied = serde_json::json!({"selections":{"body":"previous"}}); + let mut animation = crate::animation::AnimationStatus::default(); + animation.ready = true; + let mut queries: SystemState>> = SystemState::new(&mut world); + restore_stock(&mut state, &mut parts, &mut animation, &mut queries.get_mut(&mut world)); + assert_eq!(*world.get::(scene).unwrap(), Visibility::Inherited); + assert!(state.active.is_none() && state.stock_visibility.is_empty()); + assert!(parts.applied.is_null()); + assert!(!animation.ready); + } + #[test] #[ignore = "Requires private retail roster and animation banks; asset loading only"] fn native_roster_assets_bind_to_the_retail_animation_skeleton() { use bevy::{ diff --git a/crates/skate-game/src/customiser.rs b/crates/skate-game/src/customiser.rs index e7037127..f22b69ba 100644 --- a/crates/skate-game/src/customiser.rs +++ b/crates/skate-game/src/customiser.rs @@ -678,9 +678,7 @@ fn setup(mut commands: Commands, config: Res, parts: Res< } } let extras = std::fs::read( - config - .asset_root - .join("private/customisation/extra-menu.json"), + crate::customiser_parts::asset_directory(&config.asset_root).join("extra-menu.json"), ) .ok() .and_then(|b| serde_json::from_slice(&b).ok()) @@ -1332,6 +1330,29 @@ fn matches_patch(profile: &Value, patch: &Value) -> bool { mod tests { use super::*; #[test] + fn customiser_missing_library_keeps_stock_scene_visible() { + use bevy::ecs::system::RunSystemOnce; + let mut app = App::new(); + app.add_plugins((MinimalPlugins, AssetPlugin::default())); + app.init_resource::() + .init_resource::() + .init_resource::() + .init_resource::>(); + let world = app.world_mut(); + world.insert_resource(Customiser { + open: true, enabled: true, just_opened: false, preview_yaw: 0., + index: Entry::default(), path: vec![], selected: 0, page_size: 6, + search: String::new(), draft: json!({"selections":{},"morphs":{}}), + settings: PathBuf::new(), status: String::new(), redraw: false, + }); + let player = world.spawn(crate::world::PlayerRoot).id(); + let stock = world.spawn((SceneRoot(default()), Visibility::Inherited, ChildOf(player))).id(); + world.run_system_once(crate::customiser_parts::update).unwrap(); + assert_eq!(*world.get::(stock).unwrap(), Visibility::Inherited); + assert!(world.resource::().status.contains("unavailable")); + assert!(world.resource::().applied.is_null()); + } + #[test] fn customiser_colour_choice_is_distinct_from_the_original() { let entry = Entry { options: vec![ diff --git a/crates/skate-game/src/customiser_material.rs b/crates/skate-game/src/customiser_material.rs index b45b6dc9..4ddd855c 100644 --- a/crates/skate-game/src/customiser_material.rs +++ b/crates/skate-game/src/customiser_material.rs @@ -11,6 +11,12 @@ pub(crate) type SkaterMaterial = ExtendedMaterial; #[derive(Asset, AsBindGroup, Reflect, Debug, Clone, Default)] pub(crate) struct SkinStamp { + #[texture(105)] + #[sampler(106)] + pub retail_mask: Option>, + #[uniform(107)] + #[reflect(ignore)] + pub retail: crate::retail_character::CharacterParams, #[texture(100)] #[sampler(101)] pub texture: Option>, @@ -137,6 +143,16 @@ mod tests { cache.set_shader(h.id(), shader); } for (source, path, prepass) in [ + ( + include_str!("retail_character_common.wgsl"), + "retail_character_common.wgsl", + false, + ), + ( + include_str!("retail_character.wgsl"), + "retail_character.wgsl", + false, + ), ( include_str!("customiser_stamp.wgsl"), "customiser_stamp.wgsl", diff --git a/crates/skate-game/src/customiser_parts.rs b/crates/skate-game/src/customiser_parts.rs index 5c90574a..fb3ac888 100644 --- a/crates/skate-game/src/customiser_parts.rs +++ b/crates/skate-game/src/customiser_parts.rs @@ -31,6 +31,8 @@ pub(crate) struct Material { tint: [f32; 3], metallic: f32, roughness: f32, + #[serde(default)] + lighting: Option, } impl Material { pub fn flag(&self, key: &str) -> &str { @@ -70,6 +72,16 @@ pub(crate) struct Parts { } #[derive(Component)] pub(crate) struct PartRoot(pub String); +pub(crate) fn asset_directory(assets: &std::path::Path) -> std::path::PathBuf { + let base = assets.join("private/customisation"); + if let Some(set) = std::fs::read(base.join("current.json")).ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .and_then(|v| v["set"].as_str().map(str::to_owned)) + .filter(|s| s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())) { + return base.join("sets").join(set); + } + base +} impl Parts { #[cfg(test)] pub fn for_test(library: Library) -> Self { @@ -416,6 +428,13 @@ impl Parts { let normal = m.normal.as_ref().map(|p| load(p, true)); let rough = m.rough.as_ref().map(|p| load(p, true)); let opacity = m.opacity.as_ref().map(|p| load(p, true)); + let retail_mask = m.lighting.as_ref().and_then(|l| l.specular.as_ref()).map(|p| load(p, true)); + let retail = m.lighting.as_ref().filter(|l| l.params.len() == 9).map(|l| crate::retail_character::CharacterParams { + tint: Vec4::ONE, + options: Vec4::new(0., f32::from(retail_mask.is_some()), -1., f32::from(l.shader.starts_with("character.hair"))), + rows: std::array::from_fn(|i| Vec4::from_array(l.params[i])), + ..default() + }).unwrap_or_default(); let material = materials.add(SkaterMaterial { base: StandardMaterial { base_color: Color::srgb(m.tint[0], m.tint[1], m.tint[2]), @@ -438,6 +457,8 @@ impl Parts { ..default() }, extension: SkinStamp { + retail, + retail_mask, hair_opacity: opacity, enabled: if m.opacity.is_some() { Vec4::Y @@ -455,12 +476,9 @@ pub(crate) fn setup( config: Res, server: Res, ) { - let library = std::fs::read( - config - .asset_root - .join("private/customisation/library-v3.json"), - ) - .or_else(|_| std::fs::read(config.asset_root.join("private/customisation/library.json"))) + let directory = asset_directory(&config.asset_root); + let library = std::fs::read(directory.join("library-v3.json")) + .or_else(|_| std::fs::read(directory.join("library.json"))) .ok() .and_then(|b| serde_json::from_slice::(&b).ok()) .unwrap_or_default(); @@ -524,6 +542,16 @@ pub(crate) fn update( )) }) .collect(); + // A missing/failed library is not a valid empty outfit. Never hide the + // fallback skater until a complete replacement can actually be published. + if desired.is_empty() || parts.resolve(&preview).is_err() { + let message = "Character assets are unavailable. Update this copy's character assets; your skater is unchanged."; + if state.status != message { + state.status = message.into(); + state.redraw = true; + } + return; + } if parts.applied == preview && !state.open { return; } @@ -615,7 +643,7 @@ pub(crate) fn update( let wanted: HashSet<_> = desired.iter().map(|(id, _)| id.as_str()).collect(); let mut rebind = false; for (entity, part, mut visible) in &mut scenes { - if !parents.iter_ancestors(entity).any(|p| p == root) { + if !parents.get(entity).is_ok_and(|p| p.parent() == root) { continue; } let target = if part.is_some_and(|p| wanted.contains(p.0.as_str())) { @@ -703,6 +731,8 @@ pub(crate) fn update( }); if changed { if let Some(m) = materials.get_mut(&handle) { + extension.retail = m.extension.retail.clone(); + extension.retail_mask = m.extension.retail_mask.clone(); m.extension = extension; } } @@ -710,6 +740,7 @@ pub(crate) fn update( if !parents.iter_ancestors(e).any(|p| p == entity) { continue; } + commands.entity(e).insert(bevy::camera::visibility::RenderLayers::from_layers(&[0, 28])); if let Some(mut material) = material { if material.0 != handle { material.0 = handle.clone(); diff --git a/crates/skate-game/src/customiser_stamp.wgsl b/crates/skate-game/src/customiser_stamp.wgsl index 8801336f..fa9b6038 100644 --- a/crates/skate-game/src/customiser_stamp.wgsl +++ b/crates/skate-game/src/customiser_stamp.wgsl @@ -23,6 +23,10 @@ struct SkinStamp { pbr_deferred_functions::deferred_output, } #else +#import skate_character_lighting::{CharacterParams, shade_character} +@group(#{MATERIAL_BIND_GROUP}) @binding(105) var retail_mask: texture_2d; +@group(#{MATERIAL_BIND_GROUP}) @binding(106) var retail_mask_sampler: sampler; +@group(#{MATERIAL_BIND_GROUP}) @binding(107) var retail: CharacterParams; #import bevy_pbr::{ forward_io::{VertexOutput, FragmentOutput}, pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing}, @@ -107,7 +111,13 @@ fn fragment( // in forward mode, we calculate the lit color immediately, and then apply some post-lighting effects here. // in deferred mode the lit color and these effects will be calculated in the deferred lighting shader var out: FragmentOutput; - if (pbr_input.material.flags & STANDARD_MATERIAL_FLAGS_UNLIT_BIT) == 0u { + if retail.light.w > 0.0 && retail.tint.w > 0.0 { + let mask = textureSample(retail_mask, retail_mask_sampler, in.uv).r; + out.color = shade_character(retail, pbr_input.N, pbr_input.world_position, + pbr_input.material.base_color.rgb, + select(pbr_input.material.base_color.a, mask, retail.options.y > 0.0), + pbr_input.material.base_color.a); + } else if (pbr_input.material.flags & STANDARD_MATERIAL_FLAGS_UNLIT_BIT) == 0u { out.color = apply_pbr_lighting(pbr_input); } else { out.color = pbr_input.material.base_color; diff --git a/crates/skate-game/src/retail_character.rs b/crates/skate-game/src/retail_character.rs index 3165ac60..2d8476b8 100644 --- a/crates/skate-game/src/retail_character.rs +++ b/crates/skate-game/src/retail_character.rs @@ -16,22 +16,29 @@ impl Plugin for CharacterLightingPlugin { fn build(&self, app: &mut App) { embedded_asset!(app, "retail_character.wgsl"); embedded_asset!(app, "retail_character_depth.wgsl"); + embedded_asset!(app, "retail_character_common.wgsl"); + let common: Handle = bevy::asset::load_embedded_asset!(app.world().resource::(), "retail_character_common.wgsl"); + app.insert_resource(CharacterShader(common)); app.add_plugins(MaterialPlugin::::default()) .add_systems(Startup, load) .add_systems(PreUpdate, load.after(crate::map_transition::MapTransitionSet).run_if(crate::retail_render::world_changed)) .add_systems(Update, (bind, shadow_views, update).chain()); } } -#[derive(Deserialize)] -struct MaterialData { - shader: String, - params: Vec<[f32; 4]>, - specular: Option, +#[derive(Clone, Debug, Deserialize)] +pub(crate) struct MaterialData { + pub shader: String, + pub params: Vec<[f32; 4]>, + pub specular: Option, } +#[derive(Resource)] +struct CharacterShader(#[allow(dead_code)] Handle); #[derive(Deserialize)] struct LightingData { materials: HashMap, default_sh: [[f32; 3]; 9], + #[serde(default)] + native: HashMap>, } #[derive(Resource)] struct Lighting { @@ -40,14 +47,14 @@ struct Lighting { light: Vec4, display_sh: Option<[Vec4; 9]>, } -#[derive(Clone, ShaderType)] -struct CharacterParams { - light: Vec4, - tint: Vec4, +#[derive(Clone, Debug, Default, ShaderType)] +pub(crate) struct CharacterParams { + pub light: Vec4, + pub tint: Vec4, // normal map present, dedicated specular mask, alpha cutoff, hair family - options: Vec4, - rows: [Vec4; 9], - sh: [Vec4; 9], + pub options: Vec4, + pub rows: [Vec4; 9], + pub sh: [Vec4; 9], } #[derive(Asset, TypePath, AsBindGroup, Clone)] struct CharacterMaterial { @@ -87,7 +94,9 @@ fn load(mut commands: Commands, config: Res, sources: Que retail: Res, originals: Query<(Entity, &OriginalCharacterMaterial)>, cameras: Query<(Entity, &RenderLayers), With>, + mut customiser: ResMut>, ) { + for (_, material) in customiser.iter_mut() { material.extension.retail.light = Vec4::ZERO; } commands.remove_resource::(); for e in &sources { commands.entity(e).despawn(); } *shadow = Default::default(); @@ -106,7 +115,7 @@ fn load(mut commands: Commands, config: Res, sources: Que return; }; let path = config.asset_root.join("private/character-lighting.json"); - let data = match std::fs::read(&path) + let mut data = match std::fs::read(&path) .map_err(|e| e.to_string()) .and_then(|b| serde_json::from_slice::(&b).map_err(|e| e.to_string())) { @@ -116,6 +125,8 @@ fn load(mut commands: Commands, config: Res, sources: Que return; } }; + data.native = std::fs::read(crate::customiser_parts::asset_directory(&config.asset_root).join("native-lighting.json")) + .ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); let name = map_path.file_stem().unwrap_or_default().to_string_lossy(); let overlay = config .asset_root @@ -225,13 +236,22 @@ fn bind( entities: Query<(Entity, &GltfMaterialName, &MeshMaterial3d, Option<&RenderLayers>)>, parents: Query<&ChildOf>, players: Query<(), With>, + parts: Query<(), With>, + native: Query<&crate::custom_models::NativeModelRoot>, ) { let Some(lighting) = lighting else { return; }; for (entity, name, handle, layers) in &entities { if !parents.iter_ancestors(entity).any(|e| players.contains(e)) { continue; } - let Some(data) = lighting.data.materials.get(&name.0) else { + // Modular CAC pieces own an extended material with tattoos/hair coverage. + // Giving them a second material races publication and can render twice. + if parents.iter_ancestors(entity).any(|e| parts.contains(e)) { continue; } + let native_key = parents.iter_ancestors(entity).find_map(|e| native.get(e).ok().map(|n| &n.0)); + let table = if let Some(key) = native_key { + lighting.data.native.get(key) + } else { Some(&lighting.data.materials) }; + let Some(data) = table.and_then(|m| m.get(&name.0)) else { continue; }; if data.params.len() != 9 { @@ -253,7 +273,7 @@ fn bind( f32::from(m.normal_map_texture.is_some()), f32::from(data.specular.is_some()), alpha_cutoff, - f32::from(data.shader == "character.hair"), + f32::from(data.shader.starts_with("character.hair")), ), rows: std::array::from_fn(|i| Vec4::from_array(data.params[i])), sh: lighting @@ -302,6 +322,7 @@ fn update( lighting: Option>, root: Query<&Transform, With>, mut materials: ResMut>, + mut customiser: ResMut>, mut shadow: ResMut, time: Res