mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Prepare complete character content on first run and restore stock and clothing lighting
This commit is contained in:
1 parent
72dfdfa9b2
commit
8bd052c4cc
20 files changed
+659
-94
No files matched your search
@@ -27,6 +27,14 @@ pub(crate) struct Entry {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
native: Option<NativeCharacter>,
|
||||
#[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<String>,
|
||||
entries: Vec<Entry>,
|
||||
directory: PathBuf,
|
||||
native_directory: PathBuf,
|
||||
native_prefix: String,
|
||||
request: Option<Option<String>>,
|
||||
pending: Option<Pending>,
|
||||
active_root: Option<Entity>,
|
||||
@@ -175,6 +187,16 @@ fn discover(directory: &Path) -> Vec<Entry> {
|
||||
});
|
||||
entries
|
||||
}
|
||||
fn discover_all(directory: &Path, native_directory: &Path, native_prefix: &str) -> Vec<Entry> {
|
||||
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<String>) -> 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::<crate::config::Config>().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::<Selection>(&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<CustomModels>) {
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&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<CustomModels>) {
|
||||
};
|
||||
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<SceneRoot>>,
|
||||
) {
|
||||
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<CustomModels>,
|
||||
server: Res<AssetServer>,
|
||||
skater: Res<crate::physics::SkaterRuntime>,
|
||||
mut animation: ResMut<crate::animation::AnimationStatus>,
|
||||
mut parts: ResMut<crate::customiser_parts::Parts>,
|
||||
roots: Query<Entity, With<crate::world::PlayerRoot>>,
|
||||
spawner: Res<SceneSpawner>,
|
||||
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<Query<(Entity, &ChildOf, &mut Visibility), With<SceneRoot>>> = SystemState::new(&mut world);
|
||||
restore_stock(&mut state, &mut parts, &mut animation, &mut queries.get_mut(&mut world));
|
||||
assert_eq!(*world.get::<Visibility>(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::{
|
||||
|
||||
@@ -678,9 +678,7 @@ fn setup(mut commands: Commands, config: Res<crate::config::Config>, 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::<crate::customiser_parts::Parts>()
|
||||
.init_resource::<crate::custom_models::CustomModels>()
|
||||
.init_resource::<crate::animation::AnimationStatus>()
|
||||
.init_resource::<Assets<crate::customiser_material::SkaterMaterial>>();
|
||||
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::<Visibility>(stock).unwrap(), Visibility::Inherited);
|
||||
assert!(world.resource::<Customiser>().status.contains("unavailable"));
|
||||
assert!(world.resource::<Parts>().applied.is_null());
|
||||
}
|
||||
#[test]
|
||||
fn customiser_colour_choice_is_distinct_from_the_original() {
|
||||
let entry = Entry {
|
||||
options: vec![
|
||||
|
||||
@@ -11,6 +11,12 @@ pub(crate) type SkaterMaterial = ExtendedMaterial<StandardMaterial, SkinStamp>;
|
||||
|
||||
#[derive(Asset, AsBindGroup, Reflect, Debug, Clone, Default)]
|
||||
pub(crate) struct SkinStamp {
|
||||
#[texture(105)]
|
||||
#[sampler(106)]
|
||||
pub retail_mask: Option<Handle<Image>>,
|
||||
#[uniform(107)]
|
||||
#[reflect(ignore)]
|
||||
pub retail: crate::retail_character::CharacterParams,
|
||||
#[texture(100)]
|
||||
#[sampler(101)]
|
||||
pub texture: Option<Handle<Image>>,
|
||||
@@ -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",
|
||||
|
||||
@@ -31,6 +31,8 @@ pub(crate) struct Material {
|
||||
tint: [f32; 3],
|
||||
metallic: f32,
|
||||
roughness: f32,
|
||||
#[serde(default)]
|
||||
lighting: Option<crate::retail_character::MaterialData>,
|
||||
}
|
||||
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::<Value>(&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<crate::config::Config>,
|
||||
server: Res<AssetServer>,
|
||||
) {
|
||||
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::<Library>(&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();
|
||||
|
||||
@@ -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<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(106) var retail_mask_sampler: sampler;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(107) var<uniform> 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;
|
||||
|
||||
@@ -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<Shader> = bevy::asset::load_embedded_asset!(app.world().resource::<AssetServer>(), "retail_character_common.wgsl");
|
||||
app.insert_resource(CharacterShader(common));
|
||||
app.add_plugins(MaterialPlugin::<CharacterMaterial>::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<String>,
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub(crate) struct MaterialData {
|
||||
pub shader: String,
|
||||
pub params: Vec<[f32; 4]>,
|
||||
pub specular: Option<String>,
|
||||
}
|
||||
#[derive(Resource)]
|
||||
struct CharacterShader(#[allow(dead_code)] Handle<Shader>);
|
||||
#[derive(Deserialize)]
|
||||
struct LightingData {
|
||||
materials: HashMap<String, MaterialData>,
|
||||
default_sh: [[f32; 3]; 9],
|
||||
#[serde(default)]
|
||||
native: HashMap<String, HashMap<String, MaterialData>>,
|
||||
}
|
||||
#[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<crate::config::Config>, sources: Que
|
||||
retail: Res<crate::retail_render::RetailScene>,
|
||||
originals: Query<(Entity, &OriginalCharacterMaterial)>,
|
||||
cameras: Query<(Entity, &RenderLayers), With<crate::camera::GameplayCamera>>,
|
||||
mut customiser: ResMut<Assets<crate::customiser_material::SkaterMaterial>>,
|
||||
) {
|
||||
for (_, material) in customiser.iter_mut() { material.extension.retail.light = Vec4::ZERO; }
|
||||
commands.remove_resource::<Lighting>();
|
||||
for e in &sources { commands.entity(e).despawn(); }
|
||||
*shadow = Default::default();
|
||||
@@ -106,7 +115,7 @@ fn load(mut commands: Commands, config: Res<crate::config::Config>, 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::<LightingData>(&b).map_err(|e| e.to_string()))
|
||||
{
|
||||
@@ -116,6 +125,8 @@ fn load(mut commands: Commands, config: Res<crate::config::Config>, 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<StandardMaterial>, Option<&RenderLayers>)>,
|
||||
parents: Query<&ChildOf>,
|
||||
players: Query<(), With<crate::world::PlayerRoot>>,
|
||||
parts: Query<(), With<crate::customiser_parts::PartRoot>>,
|
||||
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<ResMut<Lighting>>,
|
||||
root: Query<&Transform, With<crate::world::PlayerRoot>>,
|
||||
mut materials: ResMut<Assets<CharacterMaterial>>,
|
||||
mut customiser: ResMut<Assets<crate::customiser_material::SkaterMaterial>>,
|
||||
mut shadow: ResMut<crate::retail_render::ShadowState>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
@@ -320,6 +341,11 @@ fn update(
|
||||
for (_, material) in materials.iter_mut() {
|
||||
material.params.sh = displayed;
|
||||
}
|
||||
for (_, material) in customiser.iter_mut() {
|
||||
if material.extension.retail.tint.w == 0. { continue; }
|
||||
material.extension.retail.light = lighting.light;
|
||||
material.extension.retail.sh = displayed;
|
||||
}
|
||||
lighting.display_sh = Some(displayed);
|
||||
// Adapter floor: the local probe's direction-independent ambient term.
|
||||
// The native per-frame c8 shadow-colour controller remains unrecovered.
|
||||
@@ -329,6 +355,32 @@ fn update(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn customiser_material_binding_uses_native_rows_and_leaves_cac_material_owned() {
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, AssetPlugin::default()));
|
||||
app.init_resource::<Assets<StandardMaterial>>().init_resource::<Assets<CharacterMaterial>>();
|
||||
let world = app.world_mut();
|
||||
let material = world.resource_mut::<Assets<StandardMaterial>>().add(StandardMaterial::default());
|
||||
let authored = MaterialData { shader: "character.cloth".into(), params: vec![[2., 3., 4., 5.]; 9], specular: None };
|
||||
world.insert_resource(Lighting {
|
||||
data: LightingData { materials: HashMap::new(), default_sh: [[0.; 3]; 9],
|
||||
native: HashMap::from([("pro".into(), HashMap::from([("Retail_Torso".into(), authored)]))]) },
|
||||
probes: default(), light: Vec4::ONE, display_sh: None,
|
||||
});
|
||||
let player = world.spawn(crate::world::PlayerRoot).id();
|
||||
let pro = world.spawn((crate::custom_models::NativeModelRoot("pro".into()), ChildOf(player))).id();
|
||||
let torso = world.spawn((ChildOf(pro), GltfMaterialName("Retail_Torso".into()), MeshMaterial3d(material.clone()))).id();
|
||||
let part = world.spawn((crate::customiser_parts::PartRoot("shirt".into()), ChildOf(player))).id();
|
||||
let cloth = world.spawn((ChildOf(part), GltfMaterialName("Retail_Torso".into()), MeshMaterial3d(material))).id();
|
||||
world.run_system_once(bind).unwrap();
|
||||
let h = &world.get::<MeshMaterial3d<CharacterMaterial>>(torso).unwrap().0;
|
||||
assert_eq!(world.resource::<Assets<CharacterMaterial>>().get(h).unwrap().params.rows[0], Vec4::new(2., 3., 4., 5.));
|
||||
assert!(world.get::<MeshMaterial3d<StandardMaterial>>(torso).is_none());
|
||||
assert!(world.get::<MeshMaterial3d<CharacterMaterial>>(cloth).is_none());
|
||||
assert!(world.get::<MeshMaterial3d<StandardMaterial>>(cloth).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_scene_restores_character_and_removes_only_gameplay_shadow_layer() {
|
||||
@@ -341,6 +393,7 @@ mod tests {
|
||||
multiplayer: Default::default(), map_fingerprint: 0,
|
||||
});
|
||||
world.insert_resource(crate::retail_render::RetailScene(false));
|
||||
world.init_resource::<Assets<crate::customiser_material::SkaterMaterial>>();
|
||||
world.insert_resource(crate::retail_render::ShadowState(Vec4::ONE, Vec4::ONE, [Vec4::ONE; 7]));
|
||||
let material = Handle::<StandardMaterial>::default();
|
||||
let player = world.spawn((
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
#import bevy_pbr::{forward_io::VertexOutput, mesh_view_bindings as frame}
|
||||
#import bevy_pbr::shadows::fetch_directional_shadow
|
||||
struct CharacterParams {
|
||||
light: vec4<f32>, tint: vec4<f32>, options: vec4<f32>,
|
||||
rows: array<vec4<f32>,9>, sh: array<vec4<f32>,9>,
|
||||
}
|
||||
#import skate_character_lighting::{CharacterParams, shade_character}
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: CharacterParams;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler;
|
||||
@@ -21,16 +17,6 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let n = normalize(i.world_normal);
|
||||
let rpos = i.world_position.xyz-frame::view.world_position;
|
||||
let vd = -normalize(rpos);
|
||||
if p.options.w>0.0 {
|
||||
// Native hair response. The installed GLB supplies the selected dye
|
||||
// and composited strand coverage; live CAC colour rows are not present.
|
||||
let ndl=saturate(dot(n,p.light.xyz));
|
||||
let fres=pow(1.0-saturate(dot(n,vd)),max(p.rows[2].w,1.0));
|
||||
let hl=p.rows[1].rgb*(saturate(ndl*0.75+0.25)+p.rows[5].w*0.25)
|
||||
+p.rows[6].rgb*fres*saturate(ndl*1.75+0.25);
|
||||
if albedo.a<p.options.z { discard; }
|
||||
return vec4<f32>(d*hl*p.light.w,albedo.a*p.tint.a);
|
||||
}
|
||||
let dp1=dpdx(rpos); let dp2=dpdy(rpos);
|
||||
let du1=dpdx(i.uv); let du2=dpdy(i.uv);
|
||||
var tt=cross(dp2,n)*du1.x+cross(n,dp1)*du2.x;
|
||||
@@ -45,32 +31,5 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
|
||||
}
|
||||
var smask=albedo.a;
|
||||
if p.options.y>0.0 { smask=textureSample(mask_map,mask_sampler,i.uv).r; }
|
||||
let s=p.rows[7].y;
|
||||
let v=vn*s;
|
||||
let irr=saturate(p.sh[0].rgb+v.x*p.sh[1].rgb+v.y*p.sh[2].rgb+v.z*p.sh[3].rgb
|
||||
+v.x*v.z*p.sh[4].rgb+v.z*v.y*p.sh[5].rgb+v.y*v.x*p.sh[6].rgb
|
||||
+(3.0*v.z*v.z-1.0)*p.sh[7].rgb+(v.x*v.x-v.y*v.y)*p.sh[8].rgb);
|
||||
let ndl=dot(vn,p.light.xyz);
|
||||
let view_z=(frame::view.view_from_world*i.world_position).z;
|
||||
var shadow=1.0;
|
||||
for (var light_id=0u; light_id<frame::lights.n_directional_lights; light_id+=1u) {
|
||||
// All-caster visibility light does not affect lightmapped world diffuse.
|
||||
if (frame::lights.directional_lights[light_id].flags & 5u)==1u {
|
||||
shadow=fetch_directional_shadow(light_id,i.world_position,vn,view_z);
|
||||
break;
|
||||
}
|
||||
}
|
||||
var lit=p.rows[1].rgb*saturate(ndl)*shadow+irr*p.rows[5].w;
|
||||
let fb=1.0-saturate(dot(vn,vd));
|
||||
let kfres=pow(max(fb,1e-6),p.rows[1].w);
|
||||
let rfres=pow(max(fb,1e-6),p.rows[0].z);
|
||||
let rd=normalize(p.light.xyz*vec3<f32>(-1.,0.2,-1.)-vd);
|
||||
lit+=saturate(rfres*saturate(dot(vn,rd)))*p.rows[6].rgb;
|
||||
let kr=p.light.xyz-2.0*ndl*vn;
|
||||
let ks=pow(saturate(dot(vd,-kr)),p.rows[2].w)*select(0.0,1.0,ndl>=0.0)*shadow;
|
||||
let rr=rd-2.0*dot(vn,rd)*vn;
|
||||
let rs=pow(saturate(dot(vd,-rr)),p.rows[3].w)*rfres;
|
||||
let spec=saturate((ks*p.rows[2].rgb*kfres+rs*p.rows[3].rgb)*smask*smask);
|
||||
if albedo.a<p.options.z { discard; }
|
||||
return vec4<f32>((d*lit+spec)*p.rows[0].y*p.light.w,albedo.a*p.tint.a);
|
||||
return shade_character(p, vn, i.world_position, d, smask, albedo.a);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#define_import_path skate_character_lighting
|
||||
#import bevy_pbr::mesh_view_bindings as frame
|
||||
#import bevy_pbr::shadows::fetch_directional_shadow
|
||||
struct CharacterParams {
|
||||
light: vec4<f32>, tint: vec4<f32>, options: vec4<f32>,
|
||||
rows: array<vec4<f32>,9>, sh: array<vec4<f32>,9>,
|
||||
}
|
||||
|
||||
fn shade_character(p: CharacterParams, vn: vec3<f32>, position: vec4<f32>, d: vec3<f32>, smask: f32, alpha: f32) -> vec4<f32> {
|
||||
let vd = -normalize(position.xyz-frame::view.world_position);
|
||||
if p.options.w>0.0 {
|
||||
// Native hair response. The installed GLB supplies the selected dye
|
||||
// and composited strand coverage; live CAC colour rows are not present.
|
||||
let ndl=saturate(dot(vn,p.light.xyz));
|
||||
let fres=pow(1.0-saturate(dot(vn,vd)),max(p.rows[2].w,1.0));
|
||||
let hl=p.rows[1].rgb*(saturate(ndl*0.75+0.25)+p.rows[5].w*0.25)
|
||||
+p.rows[6].rgb*fres*saturate(ndl*1.75+0.25);
|
||||
if alpha<p.options.z { discard; }
|
||||
return vec4<f32>(d*hl*p.light.w,alpha*p.tint.a);
|
||||
}
|
||||
let s=p.rows[7].y;
|
||||
let v=vn*s;
|
||||
let irr=saturate(p.sh[0].rgb+v.x*p.sh[1].rgb+v.y*p.sh[2].rgb+v.z*p.sh[3].rgb
|
||||
+v.x*v.z*p.sh[4].rgb+v.z*v.y*p.sh[5].rgb+v.y*v.x*p.sh[6].rgb
|
||||
+(3.0*v.z*v.z-1.0)*p.sh[7].rgb+(v.x*v.x-v.y*v.y)*p.sh[8].rgb);
|
||||
let ndl=dot(vn,p.light.xyz);
|
||||
let view_z=(frame::view.view_from_world*position).z;
|
||||
var shadow=1.0;
|
||||
for (var light_id=0u; light_id<frame::lights.n_directional_lights; light_id+=1u) {
|
||||
// All-caster visibility light does not affect lightmapped world diffuse.
|
||||
if (frame::lights.directional_lights[light_id].flags & 5u)==1u {
|
||||
shadow=fetch_directional_shadow(light_id,position,vn,view_z);
|
||||
break;
|
||||
}
|
||||
}
|
||||
var lit=p.rows[1].rgb*saturate(ndl)*shadow+irr*p.rows[5].w;
|
||||
let fb=1.0-saturate(dot(vn,vd));
|
||||
let kfres=pow(max(fb,1e-6),p.rows[1].w);
|
||||
let rfres=pow(max(fb,1e-6),p.rows[0].z);
|
||||
let rd=normalize(p.light.xyz*vec3<f32>(-1.,0.2,-1.)-vd);
|
||||
lit+=saturate(rfres*saturate(dot(vn,rd)))*p.rows[6].rgb;
|
||||
let kr=p.light.xyz-2.0*ndl*vn;
|
||||
let ks=pow(saturate(dot(vd,-kr)),p.rows[2].w)*select(0.0,1.0,ndl>=0.0)*shadow;
|
||||
let rr=rd-2.0*dot(vn,rd)*vn;
|
||||
let rs=pow(saturate(dot(vd,-rr)),p.rows[3].w)*rfres;
|
||||
let spec=saturate((ks*p.rows[2].rgb*kfres+rs*p.rows[3].rgb)*smask*smask);
|
||||
if alpha<p.options.z { discard; }
|
||||
return vec4<f32>((d*lit+spec)*p.rows[0].y*p.light.w,alpha*p.tint.a);
|
||||
}
|
||||
@@ -23,10 +23,12 @@ pub(crate) fn asset_root() -> Result<PathBuf, String> {
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let root = exe.parent().ok_or("No executable directory")?;
|
||||
let base = root.join("data");
|
||||
let mut expected_customiser = None;
|
||||
let expected = match std::fs::read(root.join("release.json")) {
|
||||
Ok(bytes) => {
|
||||
let text = std::str::from_utf8(&bytes).map_err(|e| e.to_string())?.trim_start_matches('\u{feff}');
|
||||
let release: serde_json::Value = serde_json::from_str(text).map_err(|e| e.to_string())?;
|
||||
expected_customiser = release["character_customiser"].as_str().map(str::to_owned);
|
||||
release.get("asset_pipelines").cloned()
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
@@ -34,7 +36,8 @@ pub(crate) fn asset_root() -> Result<PathBuf, String> {
|
||||
};
|
||||
let existing = installed(&base)?;
|
||||
if let Some((assets, marker)) = &existing {
|
||||
if expected.as_ref().is_none_or(|versions| marker.get("pipelines") == Some(versions)) {
|
||||
if expected.as_ref().is_none_or(|versions| marker.get("pipelines") == Some(versions))
|
||||
&& customiser_current(assets, expected_customiser.as_deref()) {
|
||||
return Ok(assets.clone());
|
||||
}
|
||||
}
|
||||
@@ -55,5 +58,19 @@ pub(crate) fn asset_root() -> Result<PathBuf, String> {
|
||||
if expected.as_ref().is_some_and(|versions| marker.get("pipelines") != Some(versions)) {
|
||||
return Err("Setup helper does not match this release's asset extractors. Unpack the complete package.".into());
|
||||
}
|
||||
if !customiser_current(&assets, expected_customiser.as_deref()) {
|
||||
return Err("Character customiser preparation did not complete for this release.".into());
|
||||
}
|
||||
Ok(assets)
|
||||
}
|
||||
|
||||
fn customiser_current(assets: &Path, expected: Option<&str>) -> bool {
|
||||
expected.is_none_or(|expected| {
|
||||
std::fs::read(assets.join("private/customisation/current.json")).ok()
|
||||
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
|
||||
.is_some_and(|v| v["version"].as_u64() == Some(1) && v["fingerprint"].as_str() == Some(expected)
|
||||
&& v["set"].as_str().is_some_and(|s| s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())))
|
||||
&& ["library-v3.json", "extra-menu.json", "native-lighting.json", "native-roster/complete.json"].iter()
|
||||
.all(|name| crate::customiser_parts::asset_directory(assets).join(name).is_file())
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user