mirror of
https://github.com/SK8-ENGINE/skate-3-rust-engine.git
synced 2026-10-26 19:47:44 +00:00
Merge retail scenery, lighting and travel with current map lifecycle
This commit is contained in:
commit
fb6f6eddfb
56 files changed
+3958
-247
No files matched your search
@@ -1,7 +1,18 @@
|
||||
fn main() {
|
||||
let mut failed = false;
|
||||
let mut render_only = false;
|
||||
for path in std::env::args_os().skip(1) {
|
||||
match skate_data::skate_map::SkateMap::load(std::path::Path::new(&path)) {
|
||||
if path == "--render-only" {
|
||||
render_only = true;
|
||||
continue;
|
||||
}
|
||||
let result = if render_only {
|
||||
std::fs::read(&path).map_err(|e| e.to_string())
|
||||
.and_then(|bytes| skate_data::skate_map::SkateMap::parse_render_only(&bytes))
|
||||
} else {
|
||||
skate_data::skate_map::SkateMap::load(std::path::Path::new(&path))
|
||||
};
|
||||
match result {
|
||||
Ok(m) => {
|
||||
println!(
|
||||
"{} v{}: vertices={} triangles={} collision={} textures={} rails={} doors={} lights={} routes={} spawn={:?} heading={}",
|
||||
|
||||
@@ -368,6 +368,22 @@ impl SkateMap {
|
||||
/// Data tools can request one worker for reproducible serial comparisons.
|
||||
/// The decoder caps concurrency at eight and skips threading for small maps.
|
||||
pub fn parse_with_decode_workers(data: &[u8], workers: usize) -> Result<Self, String> {
|
||||
Self::parse_inner(data, workers, true)
|
||||
}
|
||||
|
||||
/// Decode a supplemental presentation package without requiring collision.
|
||||
/// Playable map loading continues to use `parse`/`load`.
|
||||
pub fn parse_render_only(data: &[u8]) -> Result<Self, String> {
|
||||
let map = Self::parse_inner(data, 4, false)?;
|
||||
if !map.geometry.collision.is_empty() || !map.rails.is_empty()
|
||||
|| !map.doors.is_empty() || !map.lights.is_empty() || !map.routes.is_empty()
|
||||
|| map.extensions.iter().any(|e| e.tag != *b"WMET")
|
||||
{
|
||||
return Err("SKATE render-only package contains non-presentation data".into());
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
fn parse_inner(data: &[u8], workers: usize, require_collision: bool) -> Result<Self, String> {
|
||||
let parse_started = Instant::now();
|
||||
let mut r = Reader { bytes: data, at: 0 };
|
||||
let magic = r.take(8)?;
|
||||
@@ -671,7 +687,7 @@ impl SkateMap {
|
||||
});
|
||||
}
|
||||
}
|
||||
if geometry.collision.is_empty() && !extensions.iter().any(|e| e.tag == *b"RWCM" && e.schema == 1 && !e.payload.is_empty()) {
|
||||
if require_collision && geometry.collision.is_empty() && !extensions.iter().any(|e| e.tag == *b"RWCM" && e.schema == 1 && !e.payload.is_empty()) {
|
||||
return Err("SKATE requires triangle collision or an embedded RWCM archive".into());
|
||||
}
|
||||
if r.at != data.len() {
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
use skate_data::skate_map::SkateMap;
|
||||
|
||||
#[test]
|
||||
fn presentation_loading_does_not_relax_playable_map_validation() {
|
||||
let mut data = fixture(1);
|
||||
let counts = 8 + 4 + 4 + "Fixture".len() + (4 + 12) * 4;
|
||||
data[counts + 16..counts + 24].fill(0); // No collision or rails.
|
||||
let needle: Vec<u8> = [0u32, 1, 2].into_iter().flat_map(u32::to_le_bytes).collect();
|
||||
let indices = data.windows(12).position(|w| w == needle).unwrap();
|
||||
data.truncate(indices + 12);
|
||||
assert!(SkateMap::parse(&data).unwrap_err().contains("requires triangle collision"));
|
||||
let render = SkateMap::parse_render_only(&data).unwrap();
|
||||
assert_eq!(render.geometry.indices, [0, 1, 2]);
|
||||
assert!(render.geometry.collision.is_empty());
|
||||
data[indices..indices+4].copy_from_slice(&99u32.to_le_bytes());
|
||||
assert!(SkateMap::parse_render_only(&data).unwrap_err().contains("indices"));
|
||||
assert!(SkateMap::parse_render_only(&fixture(1)).unwrap_err().contains("non-presentation"));
|
||||
}
|
||||
|
||||
fn u(b: &mut Vec<u8>, value: u32) {
|
||||
b.extend(value.to_le_bytes());
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ pub(crate) fn build(
|
||||
));
|
||||
app.add_plugins((crate::session_marker::SessionMarkerPlugin, crate::customiser::CustomiserPlugin));
|
||||
app.add_plugins(crate::custom_models::CustomModelsPlugin);
|
||||
crate::teleport_menu::install(&mut app);
|
||||
app.add_plugins(crate::updater::UpdaterPlugin);
|
||||
app.add_plugins(crate::multiplayer::MultiplayerPlugin);
|
||||
app.add_plugins(crate::scoring_hud::ScoringHudPlugin);
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(crate) struct Config {
|
||||
pub start_paused: bool,
|
||||
pub multiplayer: crate::multiplayer::Options,
|
||||
pub map_fingerprint: u64,
|
||||
pub teleport: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -26,6 +27,7 @@ impl Config {
|
||||
start_paused: false,
|
||||
multiplayer: crate::multiplayer::Options::default(),
|
||||
map_fingerprint: 0,
|
||||
teleport: None,
|
||||
};
|
||||
let mut difficulty_override = None;
|
||||
let mut explicit_map = false;
|
||||
@@ -67,6 +69,7 @@ impl Config {
|
||||
Some("--test-world") => { explicit_map = true; config.map = None; config.map_path = None; }
|
||||
Some("--check-assets") => config.check_assets = true,
|
||||
Some("--start-paused") => config.start_paused = true,
|
||||
Some("--teleport") => config.teleport = Some(args.next().ok_or("--teleport requires a destination ID")?.to_string_lossy().into_owned()),
|
||||
Some("--difficulty") => {
|
||||
let value = args.next().ok_or("--difficulty requires easy, normal or hardcore")?;
|
||||
difficulty_override = Some(crate::difficulty::Difficulty::parse(&value.to_string_lossy())?);
|
||||
@@ -101,6 +104,13 @@ impl Config {
|
||||
}
|
||||
config.map_fingerprint = map_fingerprint(config.map_path.as_deref())?;
|
||||
if config.multiplayer.direct.is_some() && config.multiplayer.session==0 {return Err("Direct multiplayer requires --net-session (a nonzero number shared by both players)".into());}
|
||||
if let Some(id) = &config.teleport {
|
||||
let destinations = crate::teleport_menu::load(&config.asset_root)?;
|
||||
let target = destinations.iter().find(|d| &d.id == id).ok_or("Unknown teleport destination")?;
|
||||
if target.matrix.is_none() || !config.map_path.as_ref().is_some_and(|p| crate::teleport_menu::same_map(p, &target.map)) {
|
||||
return Err("Teleport destination is unavailable or belongs to a different map".into());
|
||||
}
|
||||
}
|
||||
if let Some(path) = &mut config.verification_capture {
|
||||
if path.extension().and_then(|x| x.to_str()) != Some("png") {
|
||||
return Err("--verify output must be a PNG file".into());
|
||||
|
||||
@@ -239,7 +239,7 @@ impl Plugin for CustomiserPlugin {
|
||||
);
|
||||
}
|
||||
}
|
||||
fn navigation(mut nav: ResMut<Navigation>, time: Res<Time<Real>>, keys: Res<ButtonInput<KeyCode>>) {
|
||||
pub(crate) fn navigation(mut nav: ResMut<Navigation>, time: Res<Time<Real>>, keys: Res<ButtonInput<KeyCode>>) {
|
||||
let pad = (0..4).find_map(|i| crate::input::platform::poll(i).ok());
|
||||
// Remap outside the dead zone so a resting stick cannot drift the preview.
|
||||
let axis = pad.as_ref().map_or(0., |p| (p.state.right[0] as f32 / 32767.).clamp(-1., 1.));
|
||||
|
||||
@@ -218,7 +218,7 @@ fn setup(
|
||||
BackgroundColor(Color::srgb(0.035,0.055,0.08)))).with_children(|panel| {
|
||||
panel.spawn((Text::new("GAME MENU"),TextFont {font_size:32.,..default()},TextColor(Color::WHITE)));
|
||||
panel.spawn((Text::new("GAMEPLAY & GRAPHICS"),TextFont {font_size:16.,..default()},TextColor(Color::srgb(0.4,0.85,0.85))));
|
||||
for i in 0..14 {
|
||||
for i in 0..15 {
|
||||
panel.spawn((Button, MenuRow(i), Node {width:percent(100),min_height:px(32),padding:UiRect::all(px(6)),align_items:AlignItems::Center,border_radius:BorderRadius::all(px(5)),..default()},
|
||||
BackgroundColor(Color::srgb(0.08,0.11,0.15)))).with_children(|row| {
|
||||
row.spawn((MenuLabel(i),Text::new(""),TextFont {font_size:18.,..default()},TextColor(Color::WHITE)));
|
||||
@@ -273,13 +273,14 @@ pub(crate) fn interact(
|
||||
mut net: ResMut<crate::multiplayer::Multiplayer>,
|
||||
mut typing: MessageReader<bevy::input::keyboard::KeyboardInput>,
|
||||
mut updater: ResMut<crate::updater::Updater>,
|
||||
mut travel: ResMut<crate::teleport_menu::Travel>,
|
||||
) {
|
||||
if transition.busy() {
|
||||
menu.open = true;
|
||||
time.pause();
|
||||
return;
|
||||
}
|
||||
if customiser.open || custom_models.open { return; }
|
||||
if travel.open || travel.closed_this_frame || customiser.open || custom_models.open { return; }
|
||||
if keys.just_pressed(KeyCode::Escape) || nav.pressed & 0x10 != 0 {
|
||||
menu.open = !menu.open;
|
||||
}
|
||||
@@ -305,7 +306,7 @@ pub(crate) fn interact(
|
||||
}
|
||||
}
|
||||
if menu.open {
|
||||
let rows = if menu.multiplayer { 11 } else { 14 };
|
||||
let rows = if menu.multiplayer { 11 } else { 15 };
|
||||
if keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0 {
|
||||
menu.selected = (menu.selected + rows - 1) % rows;
|
||||
}
|
||||
@@ -425,6 +426,7 @@ pub(crate) fn interact(
|
||||
}
|
||||
12 => custom_models.begin(),
|
||||
13 => menu.status = updater.open(false),
|
||||
14 => travel.open = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -509,6 +511,7 @@ fn labels(
|
||||
time: Res<Time<Real>>,
|
||||
customiser: Res<crate::customiser::Customiser>,
|
||||
custom_models: Res<crate::custom_models::CustomModels>,
|
||||
travel: Res<crate::teleport_menu::Travel>,
|
||||
net: Res<crate::multiplayer::Multiplayer>,
|
||||
window: Single<&Window, With<PrimaryWindow>>,
|
||||
mut root: Single<&mut Node, With<MenuRoot>>,
|
||||
@@ -516,7 +519,7 @@ fn labels(
|
||||
mut status: Single<&mut Text, With<StatusLabel>>,
|
||||
mut buttons: Query<(&MenuRow, &Interaction, &mut BackgroundColor, &mut Node), Without<MenuRoot>>,
|
||||
) {
|
||||
root.display = if menu.open && !customiser.open && !custom_models.open {
|
||||
root.display = if menu.open && !travel.open && !customiser.open && !custom_models.open {
|
||||
Display::Flex
|
||||
} else {
|
||||
Display::None
|
||||
@@ -610,6 +613,7 @@ fn labels(
|
||||
10 => "Character customiser".into(),
|
||||
12 => "Custom models".into(),
|
||||
13 => "Updates".into(),
|
||||
14 => "Teleport…".into(),
|
||||
_ => "Multiplayer".into(),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,8 +34,11 @@ mod customiser;
|
||||
mod customiser_parts;
|
||||
mod customiser_material;
|
||||
mod custom_models;
|
||||
mod teleport_menu;
|
||||
mod render_capacity;
|
||||
mod retail_render;
|
||||
mod retail_character;
|
||||
mod retail_irradiance;
|
||||
mod presentation;
|
||||
mod replay;
|
||||
mod world;
|
||||
|
||||
@@ -18,6 +18,9 @@ pub(crate) struct StagedAssets<A: Asset> {
|
||||
values: Vec<(Handle<A>, A)>,
|
||||
}
|
||||
impl<A: Asset> StagedAssets<A> {
|
||||
pub fn iter_mut(&mut self) -> impl Iterator<Item = (AssetId<A>, &mut A)> {
|
||||
self.values.iter_mut().map(|(h, a)| (h.id(), a))
|
||||
}
|
||||
fn new(world: &World) -> Self {
|
||||
Self { provider: world.resource::<Assets<A>>().get_handle_provider(), values: Vec::new() }
|
||||
}
|
||||
@@ -92,9 +95,10 @@ impl PreparedScene {
|
||||
});
|
||||
if let Some(map) = map {
|
||||
crate::skate_world::spawn(map, &mut self.commands, &mut self.meshes,
|
||||
&mut self.materials, &mut self.retail, &mut self.images);
|
||||
&mut self.materials, &mut self.retail, &mut self.images, &crate::retail_render::MaterialTuning::load(root));
|
||||
crate::retail_render::spawn_backdrop(&map.name, root, &mut self.commands, &mut self.meshes, &mut self.materials, &mut self.retail, &mut self.images);
|
||||
crate::retail_render::spawn_sky(&map.name, root, &mut self.commands,
|
||||
&mut self.meshes, &mut self.images, &mut self.sky);
|
||||
&mut self.meshes, &mut self.images, &mut self.sky, &mut self.retail);
|
||||
} else {
|
||||
crate::world::spawn_test_world(&mut self.commands, &mut self.meshes, &mut self.materials);
|
||||
}
|
||||
|
||||
@@ -87,6 +87,11 @@ pub(crate) struct SkaterRuntime {
|
||||
}
|
||||
|
||||
impl SkaterRuntime {
|
||||
pub(crate) fn travel_to(&mut self, transform: [[f32; 4]; 4]) -> Result<(), String> {
|
||||
self.player_input.request_teleport(transform)?;
|
||||
self.teleport_state.request_manual(transform, true);
|
||||
Ok(())
|
||||
}
|
||||
pub fn load(
|
||||
asset_root: &Path,
|
||||
graphs: &StockGraphs,
|
||||
|
||||
@@ -32,6 +32,10 @@ impl Runtime {
|
||||
self.manual_on_board = Some(on_board);
|
||||
}
|
||||
pub fn take_manual_on_board(&mut self) -> Option<bool> { self.manual_on_board.take() }
|
||||
pub(super) fn set_checkpoint(&mut self, checkpoint: Checkpoint) {
|
||||
self.checkpoint = checkpoint;
|
||||
self.pending_reply = None;
|
||||
}
|
||||
pub fn new(checkpoint: Checkpoint) -> Self {
|
||||
Self {
|
||||
state: TeleportState::default(),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Authored presentation supplements outside the static district geometry.
|
||||
use bevy::prelude::*;
|
||||
use skate_data::skate_map::SkateMap;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn spawn_backdrop(
|
||||
name: &str,
|
||||
asset_root: &Path,
|
||||
commands: &mut crate::map_render::SceneCommands,
|
||||
meshes: &mut impl crate::map_render::AssetSink<Mesh>,
|
||||
materials: &mut impl crate::map_render::AssetSink<StandardMaterial>,
|
||||
retail_materials: &mut impl crate::map_render::AssetSink<super::RetailWorldMaterial>,
|
||||
images: &mut impl crate::map_render::AssetSink<Image>,
|
||||
) {
|
||||
for folder in ["native-backdrops", "native-props"] {
|
||||
spawn_package(name, asset_root, folder, commands, meshes, materials, retail_materials, images);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_package(
|
||||
name: &str,
|
||||
asset_root: &Path,
|
||||
folder: &str,
|
||||
commands: &mut crate::map_render::SceneCommands,
|
||||
meshes: &mut impl crate::map_render::AssetSink<Mesh>,
|
||||
materials: &mut impl crate::map_render::AssetSink<StandardMaterial>,
|
||||
retail_materials: &mut impl crate::map_render::AssetSink<super::RetailWorldMaterial>,
|
||||
images: &mut impl crate::map_render::AssetSink<Image>,
|
||||
) {
|
||||
let path = asset_root.join("private").join(folder).join(format!("{name}.skate"));
|
||||
if !path.is_file() {
|
||||
return;
|
||||
}
|
||||
let map = match std::fs::read(&path).map_err(|e| e.to_string())
|
||||
.and_then(|data| SkateMap::parse_render_only(&data)) {
|
||||
Ok(map) => map,
|
||||
Err(error) => {
|
||||
error!("SKATE_BACKDROP: {}: {error}", path.display());
|
||||
return;
|
||||
}
|
||||
};
|
||||
// This package contributes presentation only; never route it into physics.
|
||||
if map.name != name || !map.geometry.collision.is_empty()
|
||||
|| !map.lights.is_empty() || !map.doors.is_empty() || !map.rails.is_empty()
|
||||
{
|
||||
error!("SKATE_BACKDROP: invalid render-only package {}", path.display());
|
||||
return;
|
||||
}
|
||||
info!("SKATE_BACKDROP: {name} {folder} triangles={}", map.geometry.indices.len() / 3);
|
||||
crate::skate_world::spawn(&map, commands, meshes, materials, retail_materials, images, &super::MaterialTuning::load(asset_root));
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
//! Character SH/key/rim shader, independent of the world's additive lights.
|
||||
use crate::retail_irradiance::Irradiance;
|
||||
use bevy::{
|
||||
asset::embedded_asset,
|
||||
camera::visibility::RenderLayers,
|
||||
gltf::GltfMaterialName,
|
||||
prelude::*,
|
||||
render::render_resource::{AsBindGroup, ShaderType},
|
||||
shader::ShaderRef,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub(crate) struct CharacterLightingPlugin;
|
||||
impl Plugin for CharacterLightingPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
embedded_asset!(app, "retail_character.wgsl");
|
||||
embedded_asset!(app, "retail_character_depth.wgsl");
|
||||
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(Deserialize)]
|
||||
struct LightingData {
|
||||
materials: HashMap<String, MaterialData>,
|
||||
default_sh: [[f32; 3]; 9],
|
||||
}
|
||||
#[derive(Resource)]
|
||||
struct Lighting {
|
||||
data: LightingData,
|
||||
probes: Irradiance,
|
||||
light: Vec4,
|
||||
display_sh: Option<[Vec4; 9]>,
|
||||
}
|
||||
#[derive(Clone, ShaderType)]
|
||||
struct CharacterParams {
|
||||
light: Vec4,
|
||||
tint: Vec4,
|
||||
// normal map present, dedicated specular mask, alpha cutoff, hair family
|
||||
options: Vec4,
|
||||
rows: [Vec4; 9],
|
||||
sh: [Vec4; 9],
|
||||
}
|
||||
#[derive(Asset, TypePath, AsBindGroup, Clone)]
|
||||
struct CharacterMaterial {
|
||||
#[uniform(0)]
|
||||
params: CharacterParams,
|
||||
#[texture(1)]
|
||||
#[sampler(2)]
|
||||
diffuse: Option<Handle<Image>>,
|
||||
#[texture(3)]
|
||||
#[sampler(4)]
|
||||
normal: Option<Handle<Image>>,
|
||||
#[texture(5)]
|
||||
#[sampler(6)]
|
||||
mask: Option<Handle<Image>>,
|
||||
alpha: AlphaMode,
|
||||
}
|
||||
impl Material for CharacterMaterial {
|
||||
fn fragment_shader() -> ShaderRef {
|
||||
bevy::asset::AssetPath::from(bevy::asset::embedded_path!("retail_character.wgsl")).with_source("embedded").into()
|
||||
}
|
||||
fn prepass_fragment_shader() -> ShaderRef {
|
||||
bevy::asset::AssetPath::from(bevy::asset::embedded_path!("retail_character_depth.wgsl")).with_source("embedded").into()
|
||||
}
|
||||
fn alpha_mode(&self) -> AlphaMode {
|
||||
self.alpha
|
||||
}
|
||||
}
|
||||
#[derive(Component)]
|
||||
struct ShadowSource;
|
||||
fn load(mut commands: Commands, config: Res<crate::config::Config>, sources: Query<Entity, With<ShadowSource>>, mut shadow: ResMut<crate::retail_render::ShadowState>) {
|
||||
commands.remove_resource::<Lighting>();
|
||||
for e in &sources { commands.entity(e).despawn(); }
|
||||
*shadow = Default::default();
|
||||
let Some(map_path) = &config.map_path else {
|
||||
return;
|
||||
};
|
||||
let path = config.asset_root.join("private/character-lighting.json");
|
||||
let 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()))
|
||||
{
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
warn!("SKATE_CHARACTER_LIGHTING: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let name = map_path.file_stem().unwrap_or_default().to_string_lossy();
|
||||
let overlay = config
|
||||
.asset_root
|
||||
.join("private/native-lighting")
|
||||
.join(format!("{name}.irradiance"));
|
||||
let probe_path = if overlay.is_file() {
|
||||
overlay
|
||||
} else {
|
||||
map_path.with_extension("irradiance")
|
||||
};
|
||||
let probes = match std::fs::read(probe_path)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|b| Irradiance::parse(&b))
|
||||
{
|
||||
Ok(probes) => probes,
|
||||
Err(e) => {
|
||||
warn!("SKATE_CHARACTER_LIGHTING: spatial data unavailable: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let sky_path = config
|
||||
.asset_root
|
||||
.join("private/native-skies")
|
||||
.join(format!("{name}.json"));
|
||||
let sky: serde_json::Value = match std::fs::read(sky_path)
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
{
|
||||
Some(sky) => sky,
|
||||
None => {
|
||||
warn!("SKATE_CHARACTER_LIGHTING: missing authored light direction");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(sun) = sky["environment"]["sun_direction"].as_array() else {
|
||||
return;
|
||||
};
|
||||
if sun.len() != 3 {
|
||||
return;
|
||||
}
|
||||
let mut light = Vec4::new(0., 0., 0., 2.5); // Existing retail scene exposure.
|
||||
for i in 0..3 {
|
||||
let Some(v) = sun[i].as_f64() else {
|
||||
return;
|
||||
};
|
||||
light[i] = v as f32;
|
||||
}
|
||||
info!("SKATE_CHARACTER_LIGHTING: loaded authored {name} irradiance and character parameters");
|
||||
spawn_shadow_sources(&mut commands, light.truncate());
|
||||
commands.insert_resource(Lighting {
|
||||
data,
|
||||
probes,
|
||||
light,
|
||||
display_sh: None,
|
||||
});
|
||||
}
|
||||
fn spawn_shadow_sources(commands: &mut Commands, light: Vec3) {
|
||||
// World + skater casters, sampled only by the character shader.
|
||||
commands.spawn((
|
||||
ShadowSource,
|
||||
Name::new("Character shadow visibility"),
|
||||
DirectionalLight {
|
||||
illuminance: 0.,
|
||||
shadows_enabled: true,
|
||||
affects_lightmapped_mesh_diffuse: false,
|
||||
..default()
|
||||
},
|
||||
Transform::default().looking_to(-light, Vec3::Y),
|
||||
bevy::light::CascadeShadowConfigBuilder {
|
||||
maximum_distance: 100.,
|
||||
first_cascade_far_bound: 10.,
|
||||
..default()
|
||||
}
|
||||
.build(),
|
||||
));
|
||||
// Only skinned player pieces inhabit layer 28. The world receiver samples
|
||||
// this separate map so baked building/terrain shadows are not re-applied.
|
||||
commands.spawn((
|
||||
ShadowSource,
|
||||
Name::new("Player shadow onto baked world"),
|
||||
DirectionalLight {
|
||||
illuminance: 0.,
|
||||
shadows_enabled: true,
|
||||
affects_lightmapped_mesh_diffuse: true,
|
||||
// Receiver-only map: no self-shadow acne to hide with large bias.
|
||||
shadow_depth_bias: 0.002,
|
||||
shadow_normal_bias: 0.0,
|
||||
..default()
|
||||
},
|
||||
RenderLayers::layer(28),
|
||||
Transform::default().looking_to(-light, Vec3::Y),
|
||||
bevy::light::CascadeShadowConfigBuilder {
|
||||
num_cascades: 1,
|
||||
maximum_distance: 24.,
|
||||
..default()
|
||||
}
|
||||
.build(),
|
||||
));
|
||||
}
|
||||
|
||||
fn bind(
|
||||
mut commands: Commands,
|
||||
lighting: Option<Res<Lighting>>,
|
||||
source: Res<Assets<StandardMaterial>>,
|
||||
server: Res<AssetServer>,
|
||||
mut materials: ResMut<Assets<CharacterMaterial>>,
|
||||
entities: Query<(Entity, &GltfMaterialName, &MeshMaterial3d<StandardMaterial>)>,
|
||||
) {
|
||||
let Some(lighting) = lighting else {
|
||||
return;
|
||||
};
|
||||
for (entity, name, handle) in &entities {
|
||||
let Some(data) = lighting.data.materials.get(&name.0) else {
|
||||
continue;
|
||||
};
|
||||
if data.params.len() != 9 {
|
||||
continue;
|
||||
}
|
||||
let Some(m) = source.get(&handle.0) else {
|
||||
continue;
|
||||
};
|
||||
let alpha_cutoff = if let AlphaMode::Mask(cutoff) = m.alpha_mode {
|
||||
cutoff
|
||||
} else {
|
||||
-1.
|
||||
};
|
||||
let material = materials.add(CharacterMaterial {
|
||||
params: CharacterParams {
|
||||
light: lighting.light,
|
||||
tint: Vec4::from_array(m.base_color.to_linear().to_f32_array()),
|
||||
options: Vec4::new(
|
||||
f32::from(m.normal_map_texture.is_some()),
|
||||
f32::from(data.specular.is_some()),
|
||||
alpha_cutoff,
|
||||
f32::from(data.shader == "character.hair"),
|
||||
),
|
||||
rows: std::array::from_fn(|i| Vec4::from_array(data.params[i])),
|
||||
sh: lighting
|
||||
.data
|
||||
.default_sh
|
||||
.map(|v| Vec3::from_array(v).extend(0.)),
|
||||
},
|
||||
diffuse: m.base_color_texture.clone(),
|
||||
normal: m.normal_map_texture.clone(),
|
||||
mask: data.specular.as_ref().map(|path| {
|
||||
server.load_with_settings(
|
||||
path.clone(),
|
||||
|settings: &mut bevy::image::ImageLoaderSettings| {
|
||||
settings.is_srgb = false;
|
||||
},
|
||||
)
|
||||
}),
|
||||
alpha: m.alpha_mode,
|
||||
});
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<MeshMaterial3d<StandardMaterial>>()
|
||||
.insert((
|
||||
MeshMaterial3d(material),
|
||||
RenderLayers::from_layers(&[0, 28]),
|
||||
));
|
||||
}
|
||||
}
|
||||
fn shadow_views(
|
||||
mut commands: Commands,
|
||||
lighting: Option<Res<Lighting>>,
|
||||
cameras: Query<(Entity, Option<&RenderLayers>), With<Camera3d>>,
|
||||
) {
|
||||
if lighting.is_none() {
|
||||
return;
|
||||
}
|
||||
for (entity, layers) in &cameras {
|
||||
let layers = layers.cloned().unwrap_or_default();
|
||||
if !layers.intersects(&RenderLayers::layer(28)) {
|
||||
commands.entity(entity).insert(layers.with(28));
|
||||
}
|
||||
}
|
||||
}
|
||||
fn update(
|
||||
lighting: Option<ResMut<Lighting>>,
|
||||
root: Query<&Transform, With<crate::world::PlayerRoot>>,
|
||||
mut materials: ResMut<Assets<CharacterMaterial>>,
|
||||
mut shadow: ResMut<crate::retail_render::ShadowState>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
let (Some(mut lighting), Ok(root)) = (lighting, root.single()) else {
|
||||
return;
|
||||
};
|
||||
let fallback = lighting
|
||||
.data
|
||||
.default_sh
|
||||
.map(|v| Vec3::from_array(v).extend(0.));
|
||||
let sh = lighting.probes.sample(root.translation, fallback);
|
||||
let displayed = lighting.display_sh.map_or(sh, |old| {
|
||||
let weight = 1. - (-time.delta_secs().clamp(0., 0.05) / 0.35).exp();
|
||||
std::array::from_fn(|i| old[i].lerp(sh[i], weight))
|
||||
});
|
||||
for (_, material) in materials.iter_mut() {
|
||||
material.params.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.
|
||||
shadow.approach(sh[0].truncate(), time.delta_secs());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn world_receiver_source_excludes_world_casters_and_emits_no_light() {
|
||||
let mut world = World::new();
|
||||
let mut queue = bevy::ecs::world::CommandQueue::default();
|
||||
spawn_shadow_sources(&mut Commands::new(&mut queue, &world), Vec3::Y);
|
||||
queue.apply(&mut world);
|
||||
let player = RenderLayers::from_layers(&[0, 28]);
|
||||
let terrain = RenderLayers::default();
|
||||
let mut receivers = 0;
|
||||
let mut character_sources = 0;
|
||||
for (light, layers, cascades) in world
|
||||
.query::<(&DirectionalLight, Option<&RenderLayers>, &bevy::light::CascadeShadowConfig)>()
|
||||
.iter(&world)
|
||||
{
|
||||
let layers = layers.cloned().unwrap_or_default();
|
||||
assert_eq!(light.illuminance, 0.);
|
||||
assert!(light.shadows_enabled);
|
||||
assert!(layers.intersects(&player));
|
||||
if light.affects_lightmapped_mesh_diffuse {
|
||||
assert!(!layers.intersects(&terrain));
|
||||
assert_eq!(cascades.bounds, vec![24.]);
|
||||
assert_eq!(light.shadow_normal_bias, 0.);
|
||||
assert!(light.shadow_depth_bias < 0.005);
|
||||
receivers += 1;
|
||||
} else {
|
||||
assert!(layers.intersects(&terrain));
|
||||
character_sources += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!((receivers, character_sources), (1, 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
#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>,
|
||||
}
|
||||
@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;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var normal_map: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(4) var normal_sampler: sampler;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(5) var mask_map: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(6) var mask_sampler: sampler;
|
||||
|
||||
@fragment
|
||||
fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let albedo = textureSample(diffuse,diffuse_sampler,i.uv);
|
||||
// GLTF diffuse is sampled as sRGB; recover retail's gamma-two decode.
|
||||
let gamma = select(albedo.rgb*12.92, 1.055*pow(max(albedo.rgb,vec3<f32>(0.0)),vec3<f32>(1.0/2.4))-0.055, albedo.rgb>vec3<f32>(0.0031308));
|
||||
let d = gamma*gamma*p.tint.rgb;
|
||||
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;
|
||||
var bb=cross(dp2,n)*du1.y+cross(n,dp1)*du2.y;
|
||||
tt *= -inverseSqrt(max(dot(tt,tt),1e-12));
|
||||
bb *= inverseSqrt(max(dot(bb,bb),1e-12));
|
||||
var vn=n;
|
||||
if p.options.x>0.0 {
|
||||
// Installed normal PNGs already unpack DXT5nm into RGB.
|
||||
let nm=textureSample(normal_map,normal_sampler,i.uv).xy*2.0-1.0;
|
||||
vn=normalize(nm.x*tt+nm.y*bb+n*sqrt(saturate(1.0-dot(nm,nm))));
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#import bevy_pbr::prepass_io::VertexOutput
|
||||
#ifdef PREPASS_FRAGMENT
|
||||
#import bevy_pbr::prepass_io::FragmentOutput
|
||||
#endif
|
||||
#ifdef MOTION_VECTOR_PREPASS
|
||||
#import bevy_pbr::pbr_prepass_functions::calculate_motion_vector
|
||||
#endif
|
||||
struct CharacterParams { light: vec4<f32>, tint: vec4<f32>, options: vec4<f32>, rows: array<vec4<f32>,9>, sh: array<vec4<f32>,9> }
|
||||
@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;
|
||||
@fragment
|
||||
fn fragment(i: VertexOutput)
|
||||
#ifdef PREPASS_FRAGMENT
|
||||
-> FragmentOutput
|
||||
#endif
|
||||
{
|
||||
if textureSample(diffuse,diffuse_sampler,i.uv).a < p.options.z { discard; }
|
||||
#ifdef PREPASS_FRAGMENT
|
||||
var out: FragmentOutput;
|
||||
#ifdef NORMAL_PREPASS
|
||||
out.normal = vec4<f32>(normalize(i.world_normal)*0.5+0.5,1.0);
|
||||
#endif
|
||||
#ifdef MOTION_VECTOR_PREPASS
|
||||
out.motion_vector = calculate_motion_vector(i.world_position,i.previous_world_position);
|
||||
#endif
|
||||
#ifdef UNCLIPPED_DEPTH_ORTHO_EMULATION
|
||||
out.frag_depth = i.unclipped_depth;
|
||||
#endif
|
||||
return out;
|
||||
#endif
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
#ifdef MOTION_VECTOR_PREPASS
|
||||
#import bevy_pbr::pbr_prepass_functions::calculate_motion_vector
|
||||
#endif
|
||||
struct WorldParams { mode: vec4<f32>, surface: vec4<f32>, family: vec4<f32>, fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32> }
|
||||
struct WorldParams { mode: vec4<f32>, foliage_debug: vec4<f32>, surface: vec4<f32>, family: vec4<f32>, fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32>, sun_direction: vec4<f32>, decal: vec4<f32>, water: array<vec4<f32>, 4> }
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: WorldParams;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var diffuse_sampler: sampler;
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! GPU-only exposure adaptation. Native evaluator, portable luminance sampling.
|
||||
use bevy::{
|
||||
asset::embedded_asset,
|
||||
core_pipeline::{
|
||||
FullscreenShader,
|
||||
core_3d::graph::{Core3d, Node3d},
|
||||
},
|
||||
ecs::query::QueryItem,
|
||||
prelude::*,
|
||||
render::{
|
||||
Render, RenderApp, RenderStartup, RenderSystems,
|
||||
extract_component::ExtractComponentPlugin,
|
||||
extract_resource::{ExtractResource, ExtractResourcePlugin},
|
||||
render_graph::{
|
||||
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
|
||||
},
|
||||
render_resource::{binding_types::*, *},
|
||||
renderer::{RenderContext, RenderDevice, RenderQueue},
|
||||
view::ViewTarget,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Resource, Clone, ExtractResource)]
|
||||
struct Settings {
|
||||
tuning: Vec4,
|
||||
timing: Vec4,
|
||||
}
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
// Missing metadata retains the existing fixed exposure.
|
||||
Self {
|
||||
tuning: Vec4::new(0., 2.5, 2.5, 0.),
|
||||
timing: Vec4::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Authored {
|
||||
target_luminance: f32,
|
||||
min: f32,
|
||||
max: f32,
|
||||
damping: f32,
|
||||
}
|
||||
|
||||
pub(super) fn install(app: &mut App) {
|
||||
embedded_asset!(app, "retail_exposure.wgsl");
|
||||
app.init_resource::<Settings>()
|
||||
.add_plugins((
|
||||
ExtractResourcePlugin::<Settings>::default(),
|
||||
ExtractComponentPlugin::<super::RetailTone>::default(),
|
||||
))
|
||||
.add_systems(Startup, load)
|
||||
.add_systems(PreUpdate, load.after(crate::map_transition::MapTransitionSet).run_if(super::world_changed))
|
||||
.add_systems(Update, advance);
|
||||
if let Some(render) = app.get_sub_app_mut(RenderApp) {
|
||||
render
|
||||
.add_systems(RenderStartup, initialize)
|
||||
.add_systems(Render, upload.in_set(RenderSystems::PrepareResources))
|
||||
.add_render_graph_node::<ViewNodeRunner<ExposureNode>>(Core3d, ExposureLabel)
|
||||
.add_render_graph_edges(
|
||||
Core3d,
|
||||
(
|
||||
Node3d::Tonemapping,
|
||||
ExposureLabel,
|
||||
Node3d::EndMainPassPostProcessing,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn load(config: Res<crate::config::Config>, mut settings: ResMut<Settings>) {
|
||||
*settings = Settings::default();
|
||||
if std::env::var_os("SKATE_FIXED_EXPOSURE").is_some_and(|v| v == "1") {
|
||||
info!("RETAIL_EXPOSURE: fixed 2.5 comparison mode");
|
||||
return;
|
||||
}
|
||||
let profile = config.map_path.as_ref().and_then(|p| p.file_stem()).and_then(|name| {
|
||||
let bytes = std::fs::read(config.asset_root.join("private/exposure-profiles.json")).ok()?;
|
||||
let mut profiles: std::collections::BTreeMap<String, Authored> = serde_json::from_slice(&bytes).ok()?;
|
||||
profiles.remove(&name.to_string_lossy().to_lowercase())
|
||||
});
|
||||
let fallback = || std::fs::read(config.asset_root.join("private/exposure.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<Authored>(&b).ok());
|
||||
if let Some(a) = profile.or_else(fallback) {
|
||||
if [a.target_luminance, a.min, a.max, a.damping]
|
||||
.iter()
|
||||
.all(|x| x.is_finite())
|
||||
&& a.min > 0.
|
||||
&& a.max >= a.min
|
||||
&& a.target_luminance > 0.
|
||||
&& a.damping >= 0.
|
||||
{
|
||||
settings.tuning = Vec4::new(a.target_luminance, a.min, a.max, a.damping);
|
||||
info!(
|
||||
"RETAIL_EXPOSURE: authored target={} range={}..{} damping={}; portable GPU meter",
|
||||
a.target_luminance, a.min, a.max, a.damping
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn advance(time: Res<Time>, mut settings: ResMut<Settings>) {
|
||||
settings.timing.x = time.delta_secs().clamp(0., 0.05);
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct Pipeline {
|
||||
compute_layout: BindGroupLayoutDescriptor,
|
||||
tone_layout: BindGroupLayoutDescriptor,
|
||||
compute: CachedComputePipelineId,
|
||||
tone: CachedRenderPipelineId,
|
||||
settings: Buffer,
|
||||
state: Buffer,
|
||||
sampler: Sampler,
|
||||
}
|
||||
|
||||
fn initialize(
|
||||
mut commands: Commands,
|
||||
device: Res<RenderDevice>,
|
||||
cache: Res<PipelineCache>,
|
||||
assets: Res<AssetServer>,
|
||||
fullscreen: Res<FullscreenShader>,
|
||||
) {
|
||||
let compute_layout = BindGroupLayoutDescriptor::new(
|
||||
"retail exposure meter",
|
||||
&BindGroupLayoutEntries::sequential(
|
||||
ShaderStages::COMPUTE,
|
||||
(
|
||||
texture_2d(TextureSampleType::Float { filterable: true }),
|
||||
sampler(SamplerBindingType::Filtering),
|
||||
uniform_buffer::<[Vec4; 2]>(false),
|
||||
storage_buffer::<Vec4>(false),
|
||||
),
|
||||
),
|
||||
);
|
||||
let tone_layout = BindGroupLayoutDescriptor::new(
|
||||
"retail exposed tone",
|
||||
&BindGroupLayoutEntries::sequential(
|
||||
ShaderStages::FRAGMENT,
|
||||
(
|
||||
texture_2d(TextureSampleType::Float { filterable: true }),
|
||||
sampler(SamplerBindingType::Filtering),
|
||||
storage_buffer_read_only::<Vec4>(false),
|
||||
),
|
||||
),
|
||||
);
|
||||
let compute = cache.queue_compute_pipeline(ComputePipelineDescriptor {
|
||||
label: Some("retail exposure".into()),
|
||||
layout: vec![compute_layout.clone()],
|
||||
shader: assets.load(bevy::asset::AssetPath::from(bevy::asset::embedded_path!("retail_exposure.wgsl")).with_source("embedded")),
|
||||
entry_point: Some("meter".into()),
|
||||
..default()
|
||||
});
|
||||
let tone = cache.queue_render_pipeline(RenderPipelineDescriptor {
|
||||
label: Some("retail exposed tone".into()),
|
||||
layout: vec![tone_layout.clone()],
|
||||
vertex: fullscreen.to_vertex_state(),
|
||||
fragment: Some(FragmentState {
|
||||
shader: assets.load(bevy::asset::AssetPath::from(bevy::asset::embedded_path!("retail_tone.wgsl")).with_source("embedded")),
|
||||
targets: vec![Some(ColorTargetState {
|
||||
format: ViewTarget::TEXTURE_FORMAT_HDR,
|
||||
blend: None,
|
||||
write_mask: ColorWrites::ALL,
|
||||
})],
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
});
|
||||
let settings = device.create_buffer(&BufferDescriptor {
|
||||
label: Some("retail exposure settings"),
|
||||
size: 32,
|
||||
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let state = device.create_buffer_with_data(&BufferInitDescriptor {
|
||||
label: Some("retail exposure state"),
|
||||
contents: &bytes([Vec4::new(2.5, 0., 0., 0.)]),
|
||||
usage: BufferUsages::STORAGE,
|
||||
});
|
||||
commands.insert_resource(Pipeline {
|
||||
compute_layout,
|
||||
tone_layout,
|
||||
compute,
|
||||
tone,
|
||||
settings,
|
||||
state,
|
||||
sampler: device.create_sampler(&SamplerDescriptor {
|
||||
mag_filter: FilterMode::Linear,
|
||||
min_filter: FilterMode::Linear,
|
||||
..default()
|
||||
}),
|
||||
});
|
||||
}
|
||||
fn bytes<const N: usize>(values: [Vec4; N]) -> Vec<u8> {
|
||||
values
|
||||
.into_iter()
|
||||
.flat_map(|v| v.to_array())
|
||||
.flat_map(f32::to_le_bytes)
|
||||
.collect()
|
||||
}
|
||||
fn upload(settings: Res<Settings>, pipeline: Res<Pipeline>, queue: Res<RenderQueue>) {
|
||||
queue.write_buffer(
|
||||
&pipeline.settings,
|
||||
0,
|
||||
&bytes([settings.tuning, settings.timing]),
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
|
||||
struct ExposureLabel;
|
||||
#[derive(Default)]
|
||||
struct ExposureNode;
|
||||
impl ViewNode for ExposureNode {
|
||||
type ViewQuery = (&'static ViewTarget, &'static super::RetailTone);
|
||||
fn run<'w>(
|
||||
&self,
|
||||
_: &mut RenderGraphContext,
|
||||
context: &mut RenderContext,
|
||||
(view, _): QueryItem<Self::ViewQuery>,
|
||||
world: &'w World,
|
||||
) -> Result<(), NodeRunError> {
|
||||
let p = world.resource::<Pipeline>();
|
||||
let cache = world.resource::<PipelineCache>();
|
||||
let (Some(compute), Some(tone)) = (
|
||||
cache.get_compute_pipeline(p.compute),
|
||||
cache.get_render_pipeline(p.tone),
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
let post = view.post_process_write();
|
||||
let meter = context.render_device().create_bind_group(
|
||||
"retail meter",
|
||||
&cache.get_bind_group_layout(&p.compute_layout),
|
||||
&BindGroupEntries::sequential((
|
||||
post.source,
|
||||
&p.sampler,
|
||||
p.settings.as_entire_binding(),
|
||||
p.state.as_entire_binding(),
|
||||
)),
|
||||
);
|
||||
let output = context.render_device().create_bind_group(
|
||||
"retail exposed tone",
|
||||
&cache.get_bind_group_layout(&p.tone_layout),
|
||||
&BindGroupEntries::sequential((post.source, &p.sampler, p.state.as_entire_binding())),
|
||||
);
|
||||
{
|
||||
let mut pass = context
|
||||
.command_encoder()
|
||||
.begin_compute_pass(&ComputePassDescriptor {
|
||||
label: Some("retail exposure meter"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
pass.set_pipeline(compute);
|
||||
pass.set_bind_group(0, &meter, &[]);
|
||||
pass.dispatch_workgroups(1, 1, 1);
|
||||
}
|
||||
let mut pass = context.begin_tracked_render_pass(RenderPassDescriptor {
|
||||
label: Some("retail exposed tone"),
|
||||
color_attachments: &[Some(RenderPassColorAttachment {
|
||||
view: post.destination,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: Operations::default(),
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
pass.set_render_pipeline(tone);
|
||||
pass.set_bind_group(0, &output, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Native multiplicative evaluator (827F0D00) and centre weighting (827F0B78).
|
||||
// Portable meter: 16x16 bilinear HDR samples, not the console resolve surface.
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var source_sampler: sampler;
|
||||
struct Settings { tuning: vec4<f32>, timing: vec4<f32> }
|
||||
@group(0) @binding(2) var<uniform> settings: Settings;
|
||||
@group(0) @binding(3) var<storage, read_write> state: vec4<f32>;
|
||||
var<workgroup> sums: array<f32, 256>;
|
||||
@compute @workgroup_size(256)
|
||||
fn meter(@builtin(local_invocation_index) id: u32) {
|
||||
let uv=(vec2<f32>(f32(id%16u),f32(id/16u))+0.5)/16.0;
|
||||
let xy=uv*2.0-1.0;
|
||||
let weight=pow(1.0-abs(xy.x*xy.y),2.0);
|
||||
// Material output currently includes baseline exposure 2.5. Meter the
|
||||
// current adapted image, with a portable Rec.709 luminance conversion.
|
||||
let c=max(textureSampleLevel(source,source_sampler,uv,0.0).rgb,vec3<f32>(0.0))*state.x/2.5;
|
||||
sums[id]=min(dot(c,vec3<f32>(0.2126,0.7152,0.0722)),16.0)*weight;
|
||||
workgroupBarrier();
|
||||
for(var stride=128u;stride>0u;stride/=2u) {
|
||||
if id<stride { sums[id]+=sums[id+stride]; }
|
||||
workgroupBarrier();
|
||||
}
|
||||
if id==0u {
|
||||
let average=sums[0]/256.0*2.515;
|
||||
let factor=max(0.001,1.0+settings.tuning.w*(settings.tuning.x-average));
|
||||
// Native evaluator ticks per frame; adapt to a 30 Hz time basis here.
|
||||
state.x=clamp(state.x*pow(factor,settings.timing.x*30.0),settings.tuning.y,settings.tuning.z);
|
||||
state.y=average;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Spatial SH samples from RX2 0xEB0024. TU3 827E6AC8 selects a nearest sample.
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Probe {
|
||||
pub position: Vec3,
|
||||
pub sh: [Vec4; 9],
|
||||
}
|
||||
pub(crate) struct ProbeGroup {
|
||||
min: Vec3,
|
||||
max: Vec3,
|
||||
probes: Vec<Probe>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Irradiance {
|
||||
groups: Vec<ProbeGroup>,
|
||||
recent: Vec<Probe>,
|
||||
}
|
||||
impl Irradiance {
|
||||
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
|
||||
if bytes.get(..4) != Some(b"SHP1") {
|
||||
return Err("Invalid irradiance header".into());
|
||||
}
|
||||
let mut at = 4;
|
||||
fn word(bytes: &[u8], at: &mut usize) -> Result<u32, String> {
|
||||
let raw = bytes.get(*at..*at + 4).ok_or("Truncated irradiance")?;
|
||||
*at += 4;
|
||||
Ok(u32::from_le_bytes(raw.try_into().unwrap()))
|
||||
}
|
||||
let count = word(bytes, &mut at)?;
|
||||
let mut groups = Vec::new();
|
||||
for _ in 0..count {
|
||||
let n = word(bytes, &mut at)?;
|
||||
let mut probes = Vec::new();
|
||||
let mut min = Vec3::splat(f32::INFINITY);
|
||||
let mut max = Vec3::splat(f32::NEG_INFINITY);
|
||||
for _ in 0..n {
|
||||
let mut v = [0.; 39];
|
||||
for x in &mut v {
|
||||
*x = f32::from_bits(word(bytes, &mut at)?);
|
||||
}
|
||||
word(bytes, &mut at)?; // Runtime linked-list word, not lighting.
|
||||
if !v.iter().all(|v| v.is_finite()) {
|
||||
return Err("Non-finite irradiance".into());
|
||||
}
|
||||
let position = Vec3::from_slice(&v[36..39]);
|
||||
min = min.min(position);
|
||||
max = max.max(position);
|
||||
probes.push(Probe {
|
||||
position,
|
||||
sh: std::array::from_fn(|i| Vec4::from_slice(&v[i * 4..i * 4 + 4])),
|
||||
});
|
||||
}
|
||||
if !probes.is_empty() {
|
||||
groups.push(ProbeGroup { min, max, probes });
|
||||
}
|
||||
}
|
||||
if at != bytes.len() {
|
||||
return Err("Trailing irradiance bytes".into());
|
||||
}
|
||||
Ok(Self {
|
||||
groups,
|
||||
recent: Vec::new(),
|
||||
})
|
||||
}
|
||||
pub fn sample(&mut self, position: Vec3, fallback: [Vec4; 9]) -> [Vec4; 9] {
|
||||
// Native six-entry cache is accepted inside 2.5 metres of a sample.
|
||||
if let Some((i, _)) = self
|
||||
.recent
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| (i, p.position.distance_squared(position)))
|
||||
.filter(|(_, d)| *d < 6.25)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))
|
||||
{
|
||||
let probe = self.recent.remove(i);
|
||||
let sh = probe.sh;
|
||||
self.recent.insert(0, probe);
|
||||
return sh;
|
||||
}
|
||||
let mut best = None;
|
||||
let mut distance = f32::MAX;
|
||||
for group in &self.groups {
|
||||
if position.cmplt(group.min - Vec3::splat(15.)).any()
|
||||
|| position.cmpgt(group.max + Vec3::splat(15.)).any()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for p in &group.probes {
|
||||
let delta = (p.position - position).abs();
|
||||
if delta.x > 15. || delta.z > 15. {
|
||||
continue;
|
||||
}
|
||||
let d = p.position.distance_squared(position);
|
||||
if d < distance {
|
||||
distance = d;
|
||||
best = Some(p.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some(probe) => {
|
||||
let sh = probe.sh;
|
||||
self.recent.insert(0, probe);
|
||||
self.recent.truncate(6);
|
||||
sh
|
||||
}
|
||||
None => fallback,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn spatial_selection_and_outside_fallback() {
|
||||
let mut b = b"SHP1".to_vec();
|
||||
b.extend(1u32.to_le_bytes());
|
||||
b.extend(2u32.to_le_bytes());
|
||||
for (x, colour) in [(0f32, 0.2f32), (10., 0.8)] {
|
||||
let mut v = [0f32; 40];
|
||||
v[0] = colour;
|
||||
v[36] = x;
|
||||
for f in v {
|
||||
b.extend(f.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let mut data = Irradiance::parse(&b).unwrap();
|
||||
assert_eq!(data.sample(Vec3::ZERO, [Vec4::ZERO; 9])[0].x, 0.2);
|
||||
assert_eq!(data.sample(Vec3::X * 9., [Vec4::ZERO; 9])[0].x, 0.8);
|
||||
assert_eq!(data.sample(Vec3::X * 100., [Vec4::ONE; 9]), [Vec4::ONE; 9]);
|
||||
assert!(Irradiance::parse(&b[..b.len() - 1]).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn cache_retains_native_two_point_five_metre_hysteresis() {
|
||||
let a = Probe {
|
||||
position: Vec3::ZERO,
|
||||
sh: [Vec4::ZERO; 9],
|
||||
};
|
||||
let b = Probe {
|
||||
position: Vec3::X * 3.,
|
||||
sh: [Vec4::ONE; 9],
|
||||
};
|
||||
let mut data = Irradiance {
|
||||
groups: vec![ProbeGroup {
|
||||
min: Vec3::ZERO,
|
||||
max: b.position,
|
||||
probes: vec![a.clone(), b],
|
||||
}],
|
||||
recent: vec![a],
|
||||
};
|
||||
// B is closer, but A remains selected within the native cache radius.
|
||||
assert_eq!(
|
||||
data.sample(Vec3::X * 2., [Vec4::splat(2.); 9]),
|
||||
[Vec4::ZERO; 9]
|
||||
);
|
||||
assert_eq!(
|
||||
data.sample(Vec3::X * 2.5, [Vec4::splat(2.); 9]),
|
||||
[Vec4::ONE; 9]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
//! Retail world shading. See docs/retail-renderer.md for provenance and gaps.
|
||||
use bevy::{
|
||||
asset::{AssetPath, embedded_asset, embedded_path},
|
||||
core_pipeline::{
|
||||
core_3d::graph::Node3d,
|
||||
fullscreen_material::{FullscreenMaterial, FullscreenMaterialPlugin},
|
||||
},
|
||||
prelude::*,
|
||||
render::{
|
||||
extract_component::ExtractComponent,
|
||||
render_graph::{InternedRenderLabel, RenderLabel},
|
||||
render_resource::{AsBindGroup, ShaderType},
|
||||
},
|
||||
shader::ShaderRef,
|
||||
@@ -23,6 +18,15 @@ fn retail_shader(path: &str) -> ShaderRef {
|
||||
}
|
||||
impl Plugin for RetailRenderPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
shadow::install(app);
|
||||
exposure::install(app);
|
||||
if std::env::var_os("SKATE_WEATHERING_COMPARE").is_some_and(|v| v == "1") {
|
||||
app.add_systems(Update, compare_weathering);
|
||||
}
|
||||
app.add_plugins(crate::retail_character::CharacterLightingPlugin);
|
||||
if std::env::var_os("SKATE_DEBUG_FOLIAGE").is_some_and(|v| v == "1") {
|
||||
eprintln!("SKATE_FOLIAGE_DEBUG: solid cyan tree-wall cards, magenta other foliage; alpha rejection disabled for foliage only");
|
||||
}
|
||||
embedded_asset!(app, "retail_world.wgsl");
|
||||
embedded_asset!(app, "retail_tone.wgsl");
|
||||
embedded_asset!(app, "retail_depth.wgsl");
|
||||
@@ -30,153 +34,67 @@ impl Plugin for RetailRenderPlugin {
|
||||
app.add_plugins((
|
||||
MaterialPlugin::<RetailWorldMaterial>::default(),
|
||||
MaterialPlugin::<RetailSkyMaterial>::default(),
|
||||
FullscreenMaterialPlugin::<RetailTone>::default(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostic only: mutate materials once per keypress, never continuously.
|
||||
// Separate bits preserve the original texture-presence flags for restoration.
|
||||
fn compare_weathering(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut mode: Local<u32>,
|
||||
mut materials: ResMut<Assets<RetailWorldMaterial>>,
|
||||
mut windows: Query<&mut Window, With<bevy::window::PrimaryWindow>>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::F8) { return; }
|
||||
*mode = (*mode + 1) % 4;
|
||||
let label = match *mode {
|
||||
1 => "Repeating grime OFF; decals ON",
|
||||
2 => "Repeating grime ON; decals OFF",
|
||||
3 => "Repeating grime OFF; decals OFF",
|
||||
_ => "Normal grime and decals ON",
|
||||
};
|
||||
for (_, material) in materials.iter_mut() {
|
||||
let family = material.params.mode.x as u32;
|
||||
if !(1..=8).contains(&family) { continue; }
|
||||
let flags = material.params.mode.y as u32;
|
||||
material.params.mode.y = ((flags & !768) | (*mode << 8)) as f32;
|
||||
}
|
||||
for mut window in &mut windows {
|
||||
window.title = format!("Skate 3 - {label} [F8: next comparison]");
|
||||
}
|
||||
info!("WEATHERING_COMPARE: {label}");
|
||||
}
|
||||
|
||||
#[path = "retail_exposure.rs"]
|
||||
mod exposure;
|
||||
|
||||
#[path = "retail_shadow.rs"]
|
||||
mod shadow;
|
||||
pub(crate) use shadow::ShadowState;
|
||||
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct RetailScene(pub bool);
|
||||
|
||||
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
|
||||
pub(crate) struct RetailSkyMaterial {
|
||||
#[uniform(0)]
|
||||
pub params: Vec4,
|
||||
#[texture(1)]
|
||||
#[sampler(2)]
|
||||
pub diffuse: Handle<Image>,
|
||||
}
|
||||
impl Material for RetailSkyMaterial {
|
||||
fn enable_prepass() -> bool {
|
||||
false
|
||||
}
|
||||
fn enable_shadows() -> bool {
|
||||
false
|
||||
}
|
||||
fn vertex_shader() -> ShaderRef {
|
||||
retail_shader("retail_sky.wgsl")
|
||||
}
|
||||
fn fragment_shader() -> ShaderRef {
|
||||
Self::vertex_shader()
|
||||
}
|
||||
fn specialize(
|
||||
_: &bevy::pbr::MaterialPipeline,
|
||||
descriptor: &mut bevy::render::render_resource::RenderPipelineDescriptor,
|
||||
_: &bevy::mesh::MeshVertexBufferLayoutRef,
|
||||
_: bevy::pbr::MaterialPipelineKey<Self>,
|
||||
) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> {
|
||||
descriptor.primitive.cull_mode = None;
|
||||
if let Some(depth) = &mut descriptor.depth_stencil {
|
||||
depth.depth_write_enabled = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[path = "retail_sky.rs"]
|
||||
mod sky;
|
||||
pub(crate) use sky::{spawn_sky, RetailSkyMaterial};
|
||||
|
||||
pub(crate) fn spawn_sky(
|
||||
name: &str,
|
||||
root: &std::path::Path,
|
||||
commands: &mut crate::map_render::SceneCommands,
|
||||
meshes: &mut impl crate::map_render::AssetSink<Mesh>,
|
||||
images: &mut impl crate::map_render::AssetSink<Image>,
|
||||
materials: &mut impl crate::map_render::AssetSink<RetailSkyMaterial>,
|
||||
) {
|
||||
// Parks need their authored sky selection from the environment controller;
|
||||
// only these three district identities are currently resolved.
|
||||
if !matches!(name, "University" | "DownTown" | "Industrial") {
|
||||
return;
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Sky {
|
||||
width: u32,
|
||||
height: u32,
|
||||
positions: Vec<[f32; 3]>,
|
||||
uvs: Vec<[f32; 2]>,
|
||||
indices: Vec<u32>,
|
||||
}
|
||||
let load = || -> Result<(Sky, Vec<u8>), String> {
|
||||
let base = root.join("private/native-skies");
|
||||
let sky: Sky = serde_json::from_slice(
|
||||
&std::fs::read(base.join(format!("{name}.json"))).map_err(|e| e.to_string())?,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rgba = std::fs::read(base.join(format!("{name}.rgba"))).map_err(|e| e.to_string())?;
|
||||
if sky.width == 0
|
||||
|| sky.height == 0
|
||||
|| u64::from(sky.width) * u64::from(sky.height) * 4 != rgba.len() as u64
|
||||
|| sky.positions.len() != sky.uvs.len()
|
||||
|| sky
|
||||
.indices
|
||||
.iter()
|
||||
.any(|&i| i as usize >= sky.positions.len())
|
||||
{
|
||||
return Err("Invalid retail sky dimensions/geometry".into());
|
||||
}
|
||||
Ok((sky, rgba))
|
||||
};
|
||||
let (sky, rgba) = match load() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("Retail sky unavailable: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut image = Image::new(
|
||||
bevy::render::render_resource::Extent3d {
|
||||
width: sky.width,
|
||||
height: sky.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
bevy::render::render_resource::TextureDimension::D2,
|
||||
rgba,
|
||||
bevy::render::render_resource::TextureFormat::Rgba8Unorm,
|
||||
bevy::asset::RenderAssetUsages::RENDER_WORLD,
|
||||
);
|
||||
let mut sampler = bevy::image::ImageSamplerDescriptor::linear();
|
||||
sampler.address_mode_u = bevy::image::ImageAddressMode::Repeat;
|
||||
image.sampler = bevy::image::ImageSampler::Descriptor(sampler);
|
||||
let mesh = Mesh::new(
|
||||
bevy::mesh::PrimitiveTopology::TriangleList,
|
||||
bevy::asset::RenderAssetUsages::RENDER_WORLD,
|
||||
)
|
||||
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, sky.positions)
|
||||
.with_inserted_attribute(Mesh::ATTRIBUTE_UV_0, sky.uvs)
|
||||
.with_inserted_indices(bevy::mesh::Indices::U32(sky.indices));
|
||||
commands.spawn((
|
||||
Name::new("Retail sky dome"),
|
||||
Mesh3d(meshes.add(mesh)),
|
||||
MeshMaterial3d(materials.add(RetailSkyMaterial {
|
||||
params: Vec4::new(165., 2.5, 1., 0.),
|
||||
diffuse: images.add(image),
|
||||
})),
|
||||
Transform::default(),
|
||||
bevy::camera::visibility::NoFrustumCulling,
|
||||
bevy::light::NotShadowCaster,
|
||||
bevy::light::NotShadowReceiver,
|
||||
));
|
||||
}
|
||||
#[path = "retail_backdrop.rs"]
|
||||
mod backdrop;
|
||||
pub(crate) use backdrop::spawn_backdrop;
|
||||
|
||||
#[derive(Component, ExtractComponent, Clone, Copy, ShaderType, Default)]
|
||||
pub(crate) struct RetailTone {
|
||||
pub enabled: Vec4,
|
||||
}
|
||||
impl FullscreenMaterial for RetailTone {
|
||||
fn fragment_shader() -> ShaderRef {
|
||||
retail_shader("retail_tone.wgsl")
|
||||
}
|
||||
fn node_edges() -> Vec<InternedRenderLabel> {
|
||||
vec![
|
||||
Node3d::Tonemapping.intern(),
|
||||
Self::node_label().intern(),
|
||||
Node3d::EndMainPassPostProcessing.intern(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, ShaderType)]
|
||||
pub(crate) struct WorldParams {
|
||||
// family, texture flags, alpha cutoff (-1 for opaque), exposure
|
||||
pub mode: Vec4,
|
||||
// Diagnostic solid foliage colour; w=0 keeps retail shading.
|
||||
pub foliage_debug: Vec4,
|
||||
// macro UV scale, opacity, detail UV scale, material multiplier
|
||||
pub surface: Vec4,
|
||||
// tree LM scale/floor/tint, proxy multiplier: reference day capture
|
||||
@@ -184,6 +102,10 @@ pub(crate) struct WorldParams {
|
||||
pub fog_ramp: Vec4,
|
||||
pub fog_color: Vec4,
|
||||
pub shadow_color: Vec4,
|
||||
pub sun_direction: Vec4,
|
||||
// Wear-only visual tuning; artwork retains unit opacity.
|
||||
pub decal: Vec4,
|
||||
pub water: [Vec4; 4],
|
||||
}
|
||||
|
||||
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
|
||||
@@ -214,6 +136,8 @@ pub(crate) struct RetailWorldMaterial {
|
||||
pub specular: Option<Handle<Image>>,
|
||||
#[texture(15, dimension = "cube")]
|
||||
pub environment: Option<Handle<Image>>,
|
||||
#[storage(16, read_only)]
|
||||
pub shadow_state: Handle<bevy::render::storage::ShaderStorageBuffer>,
|
||||
pub alpha: AlphaMode,
|
||||
pub two_sided: bool,
|
||||
}
|
||||
@@ -263,6 +187,7 @@ pub(crate) struct Binding {
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct Definition {
|
||||
pub shader: String,
|
||||
pub family: u32,
|
||||
pub flags: u32,
|
||||
pub bindings: BTreeMap<String, Binding>,
|
||||
@@ -287,8 +212,11 @@ impl Definition {
|
||||
}
|
||||
let mut r = Reader(bytes);
|
||||
r.take(16)?;
|
||||
let _shader = r.text()?;
|
||||
let family = r.u32()?;
|
||||
let shader = r.text()?;
|
||||
let stored_family = r.u32()?;
|
||||
// Older map packages retained the complete bindings but classified
|
||||
// non-flowing water as unknown. Upgrade without re-exporting geometry.
|
||||
let family = if matches!(shader.as_str(), "water.default" | "water.alpha" | "water.skatepark") { 33 } else { stored_family };
|
||||
let flags = r.u32()?;
|
||||
let mut bindings = BTreeMap::new();
|
||||
for _ in 0..r.u32()? {
|
||||
@@ -318,6 +246,7 @@ impl Definition {
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
shader,
|
||||
family,
|
||||
flags,
|
||||
bindings,
|
||||
@@ -332,12 +261,19 @@ impl Definition {
|
||||
.ok()
|
||||
.filter(|v| v.is_finite())
|
||||
}
|
||||
pub fn supported(&self) -> bool {
|
||||
(1..=12).contains(&self.family) || self.family == 13
|
||||
pub fn supported(&self, tuning: &MaterialTuning) -> bool {
|
||||
(1..=13).contains(&self.family) || match self.family {
|
||||
14 | 32 => tuning.rows.get(&self.shader).is_some_and(|r| !r.is_empty()),
|
||||
31 => tuning.pca_available && tuning.rows.get(&self.shader).is_some_and(|r| r.len() == 3),
|
||||
30 => tuning.rows.get(&self.shader).is_some_and(|r| r.len() == 4),
|
||||
33 => tuning.pca_available && self.bindings.contains_key("normal") && self.bindings.contains_key("normal2") && tuning.rows.get(&self.shader).is_some_and(|r| r.len() == 4),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
pub fn build(
|
||||
&self,
|
||||
m: &skate_data::skate_map::Material,
|
||||
tuning: &MaterialTuning,
|
||||
texture: &mut impl FnMut(u32, u8) -> Option<Handle<Image>>,
|
||||
) -> RetailWorldMaterial {
|
||||
let mut fetch = |role: &str, fallback: u32, clamp: bool| {
|
||||
@@ -345,7 +281,7 @@ impl Definition {
|
||||
texture(
|
||||
b.map_or(fallback, |b| b.texture),
|
||||
if clamp || b.is_some_and(|b| b.u == 1 && b.v == 1) {
|
||||
4
|
||||
if role == "decal" { 6 } else { 4 }
|
||||
} else {
|
||||
3
|
||||
},
|
||||
@@ -354,7 +290,7 @@ impl Definition {
|
||||
let diffuse = fetch("diffuse", m.textures[0], false);
|
||||
let lightmap = fetch("lightmap", m.textures[1], true);
|
||||
let normal = fetch("normal", m.textures[2], false);
|
||||
let detail = fetch("detail", 0, false);
|
||||
let detail = fetch(if matches!(self.family, 31 | 33) { "normal2" } else { "detail" }, 0, false);
|
||||
let macro_map = fetch("macrooverlay", 0, false);
|
||||
let decal = fetch("decal", 0, self.family == 3);
|
||||
let specular = fetch("specular", 0, false);
|
||||
@@ -364,7 +300,7 @@ impl Definition {
|
||||
let detail_scale = self.scalar("detailNormalUVScale").unwrap_or(0.);
|
||||
let flags = u32::from(normal.is_some())
|
||||
| (u32::from(detail.is_some() && detail_scale > 0.) << 1)
|
||||
| (u32::from(macro_map.is_some() && macro_scale > 0. && macro_opacity > 0.) << 2)
|
||||
| (u32::from(macro_map.is_some() && macro_scale > 0. && (macro_opacity > 0. || self.family == 31)) << 2)
|
||||
| (u32::from(decal.is_some()) << 3)
|
||||
| (u32::from(specular.is_some()) << 4)
|
||||
| (u32::from(lightmap.is_some()) << 5)
|
||||
@@ -372,24 +308,50 @@ impl Definition {
|
||||
| (u32::from(detail.is_some()) << 7);
|
||||
// scene.hlsl's retail world ALPHAREF is 30, not the portable 0.5.
|
||||
let cutoff = 30. / 255.;
|
||||
let alpha = match m.alpha_mode {
|
||||
1 => AlphaMode::Mask(cutoff),
|
||||
2 => AlphaMode::Blend,
|
||||
let debug_foliage = matches!(self.family, 9 | 10)
|
||||
&& std::env::var_os("SKATE_DEBUG_FOLIAGE").is_some_and(|v| v == "1");
|
||||
let tree_wall = m.retail_definition.as_deref().is_some_and(|bytes| {
|
||||
bytes.windows(b"TreeWall".len()).any(|s| s == b"TreeWall")
|
||||
});
|
||||
let alpha = match (debug_foliage, m.alpha_mode) {
|
||||
(true, _) => AlphaMode::Opaque,
|
||||
(_, 1) => AlphaMode::Mask(cutoff),
|
||||
(_, 2) => AlphaMode::Blend,
|
||||
_ => AlphaMode::Opaque,
|
||||
};
|
||||
let alpha = if self.family == 32 || (matches!(self.family, 30 | 33) && self.shader.ends_with("alpha")) {
|
||||
AlphaMode::Blend
|
||||
} else { alpha };
|
||||
let mut water = [Vec4::ZERO; 4];
|
||||
if let Some(rows) = tuning.rows.get(&self.shader) {
|
||||
for (to, from) in water.iter_mut().zip(rows) { *to = Vec4::from_array(*from); }
|
||||
}
|
||||
if self.family == 14 {
|
||||
water[1] = Vec4::new(self.scalar("uAnimationSpeed").unwrap_or(0.), self.scalar("vAnimationSpeed").unwrap_or(0.), 0., 0.);
|
||||
}
|
||||
RetailWorldMaterial {
|
||||
params: WorldParams {
|
||||
mode: Vec4::new(
|
||||
self.family as f32,
|
||||
flags as f32,
|
||||
if m.alpha_mode == 1 { cutoff } else { -1. },
|
||||
if m.alpha_mode == 1 && !debug_foliage { cutoff } else { -1. },
|
||||
2.5,
|
||||
),
|
||||
foliage_debug: if !debug_foliage {
|
||||
Vec4::ZERO
|
||||
} else if tree_wall {
|
||||
Vec4::new(0., 1., 1., 1.)
|
||||
} else {
|
||||
Vec4::new(1., 0., 1., 1.)
|
||||
},
|
||||
surface: Vec4::new(macro_scale, macro_opacity, detail_scale, 1.),
|
||||
family: Vec4::new(0.3435, 0.02, 1., 0.45),
|
||||
fog_ramp: Vec4::new(0., 0., 1., 0.),
|
||||
fog_color: Vec4::ZERO,
|
||||
shadow_color: Vec4::ZERO,
|
||||
sun_direction: Vec3::new(4., 7., 4.).normalize().extend(0.),
|
||||
decal: Vec4::new(stain_opacity(self.parameters.get("decal").and_then(|v| v.first()).map(String::as_str).unwrap_or("")), 0., 0., 0.),
|
||||
water,
|
||||
},
|
||||
diffuse,
|
||||
lightmap,
|
||||
@@ -399,12 +361,36 @@ impl Definition {
|
||||
decal,
|
||||
specular,
|
||||
environment,
|
||||
shadow_state: shadow::BUFFER,
|
||||
alpha,
|
||||
two_sided: self.flags & 4 != 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The source labels distinguish weathering from graphics. This is an explicit
|
||||
// visual tuning choice, not a recovered native material constant. Keep arrows,
|
||||
// logos, paint, scratches and edge wear at their authored alpha.
|
||||
fn stain_opacity(texture_label: &str) -> f32 {
|
||||
let label = texture_label.to_ascii_lowercase();
|
||||
if ["grime", "grunge", "stain", "oildirt", "drainage", "ground_decals"]
|
||||
.iter().any(|word| label.contains(word)) { 0.35 } else { 1. }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stain_tests {
|
||||
use super::stain_opacity;
|
||||
#[test]
|
||||
fn weathering_tuning_preserves_artwork() {
|
||||
for name in ["decal_WEAR_WaterStain_01", "subway_grunge03", "decal_GrimePuddle", "OT_Ground_decals"] {
|
||||
assert_eq!(stain_opacity(name), 0.35);
|
||||
}
|
||||
for name in ["decal_Graphic_SP_UN_Shark_01", "decal_other_sp_arrowramps_01", "decal_Graphic_SP_UN_MegaRmp_01", "decal_Wear_GL_UN_MPedge_01", "decal_skateboardscratcheswood01", ""] {
|
||||
assert_eq!(stain_opacity(name), 1.);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Native renderer box-filters decoded UNORM cube faces down to 1x1.
|
||||
/// The same chain prevents aliasing of repeating world maps at distance.
|
||||
pub(crate) fn mip_chain(rgba: &[u8], width: u32, height: u32, layers: u32) -> (Vec<u8>, u32) {
|
||||
@@ -444,3 +430,21 @@ pub(crate) fn mip_chain(rgba: &[u8], width: u32, height: u32, layers: u32) -> (V
|
||||
}
|
||||
(bytes, count)
|
||||
}
|
||||
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MaterialTuning {
|
||||
rows: BTreeMap<String, Vec<[f32; 4]>>,
|
||||
pca_available: bool,
|
||||
}
|
||||
impl MaterialTuning {
|
||||
pub(crate) fn load(root: &std::path::Path) -> Self {
|
||||
let path = root.join("private/render-parameters.json");
|
||||
match std::fs::read(&path).ok().and_then(|b| serde_json::from_slice::<BTreeMap<String, Vec<[f32; 4]>>>(&b).ok()) {
|
||||
Some(rows) if rows.values().flatten().flatten().all(|x| x.is_finite()) => Self { rows, pca_available: shadow::pca_available(root) },
|
||||
_ => { warn!("Retail water/scroll tuning unavailable: {}", path.display()); Self::default() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn world_changed(mut messages: MessageReader<crate::map_transition::WorldChanged>) -> bool { messages.read().count() != 0 }
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Shared frame state: shadow floor, animation clock and authored ocean PCA.
|
||||
use bevy::{
|
||||
prelude::*,
|
||||
render::{
|
||||
Render, RenderApp, RenderSystems,
|
||||
extract_resource::{ExtractResource, ExtractResourcePlugin},
|
||||
render_asset::RenderAssets,
|
||||
render_resource::BufferUsages,
|
||||
renderer::RenderQueue,
|
||||
storage::{GpuShaderStorageBuffer, ShaderStorageBuffer},
|
||||
},
|
||||
};
|
||||
|
||||
pub(super) const BUFFER: Handle<ShaderStorageBuffer> =
|
||||
bevy::asset::uuid_handle!("cd736f89-4882-4a5d-8fcb-32273beeaaf4");
|
||||
|
||||
#[derive(Resource, Clone, Default, ExtractResource)]
|
||||
pub(crate) struct ShadowState(pub Vec4, pub Vec4, pub [Vec4; 7]);
|
||||
|
||||
impl ShadowState {
|
||||
pub(crate) fn approach(&mut self, target: Vec3, dt: f32) {
|
||||
let target = target.clamp(Vec3::ZERO, Vec3::ONE);
|
||||
let value = if self.0.w == 0. {
|
||||
target
|
||||
} else {
|
||||
// Adapter smoothing, not a recovered native constant. Cap a hitch's
|
||||
// contribution so one long frame cannot cause a darkness step.
|
||||
self.0
|
||||
.truncate()
|
||||
.lerp(target, 1. - (-dt.clamp(0., 0.05) / 0.35).exp())
|
||||
};
|
||||
self.0 = value.extend(1.);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install(app: &mut App) {
|
||||
app.init_resource::<ShadowState>()
|
||||
.add_plugins(ExtractResourcePlugin::<ShadowState>::default())
|
||||
.add_systems(Startup, (initialize, load_pca))
|
||||
.add_systems(Update, clock);
|
||||
if let Some(render) = app.get_sub_app_mut(RenderApp) {
|
||||
render.add_systems(Render, upload.in_set(RenderSystems::PrepareResources));
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize(mut buffers: ResMut<Assets<ShaderStorageBuffer>>) {
|
||||
let mut buffer = ShaderStorageBuffer::from([Vec4::ZERO; 9]);
|
||||
buffer.buffer_description.usage |= BufferUsages::COPY_DST;
|
||||
buffers
|
||||
.insert(BUFFER.id(), buffer)
|
||||
.expect("reserved shadow state asset");
|
||||
}
|
||||
|
||||
fn upload(
|
||||
state: Res<ShadowState>,
|
||||
buffers: Res<RenderAssets<GpuShaderStorageBuffer>>,
|
||||
queue: Res<RenderQueue>,
|
||||
) {
|
||||
if let Some(buffer) = buffers.get(BUFFER.id()) {
|
||||
let bytes: Vec<u8> = [state.0, state.1]
|
||||
.into_iter()
|
||||
.chain(state.2)
|
||||
.flat_map(|v| v.to_array())
|
||||
.flat_map(f32::to_le_bytes)
|
||||
.collect();
|
||||
// Keep the buffer and all material bind groups alive; upload 144 frame bytes.
|
||||
queue.write_buffer(&buffer.buffer, 0, &bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn probe_transition_is_gradual_and_hitches_are_bounded() {
|
||||
let mut state = ShadowState::default();
|
||||
state.approach(Vec3::splat(0.1), 0.016);
|
||||
assert_eq!(state.0, Vec3::splat(0.1).extend(1.));
|
||||
state.approach(Vec3::splat(0.3), 0.016);
|
||||
assert!(state.0.x > 0.1 && state.0.x < 0.12);
|
||||
let mut hitch = state.clone();
|
||||
state.approach(Vec3::splat(0.3), 0.05);
|
||||
hitch.approach(Vec3::splat(0.3), 10.);
|
||||
assert_eq!(state.0, hitch.0);
|
||||
for _ in 0..240 {
|
||||
state.approach(Vec3::splat(0.3), 1. / 60.);
|
||||
}
|
||||
assert!((state.0.x - 0.3).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource, serde::Deserialize)]
|
||||
struct OceanPca {
|
||||
hz: f32,
|
||||
frames: Vec<[[f32; 4]; 7]>,
|
||||
}
|
||||
fn read_pca(root: &std::path::Path) -> Option<OceanPca> {
|
||||
let pca = std::fs::read(root.join("private/ocean-pca.json"))
|
||||
.ok()
|
||||
.and_then(|b| serde_json::from_slice::<OceanPca>(&b).ok())?;
|
||||
(pca.hz == 30.
|
||||
&& pca.frames.len() == 30
|
||||
&& pca.frames.iter().flatten().flatten().all(|v| v.is_finite()))
|
||||
.then_some(pca)
|
||||
}
|
||||
pub(super) fn pca_available(root: &std::path::Path) -> bool {
|
||||
read_pca(root).is_some()
|
||||
}
|
||||
fn load_pca(mut commands: Commands, config: Res<crate::config::Config>) {
|
||||
if let Some(pca) = read_pca(&config.asset_root) {
|
||||
info!("RETAIL_OCEAN: loaded 30 authored PCA frames");
|
||||
commands.insert_resource(pca);
|
||||
}
|
||||
}
|
||||
fn clock(mut state: ResMut<ShadowState>, time: Res<Time>, pca: Option<Res<OceanPca>>) {
|
||||
state.1.x = time.elapsed_secs();
|
||||
if let Some(pca) = pca {
|
||||
let frame = ((time.elapsed_secs_f64() * f64::from(pca.hz)) as usize) % pca.frames.len();
|
||||
state.2 = pca.frames[frame].map(Vec4::from_array);
|
||||
state.1.y = 1.;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Authored camera-relative sky and its radial sun gradient.
|
||||
use super::RetailWorldMaterial;
|
||||
use bevy::{
|
||||
prelude::*,
|
||||
render::render_resource::{AsBindGroup, ShaderType},
|
||||
shader::ShaderRef,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, ShaderType)]
|
||||
pub(crate) struct SkyParams {
|
||||
// anchor height, scene exposure, sky multiplier, sun angular scale (0 disables).
|
||||
pub settings: Vec4,
|
||||
pub sun_direction: Vec4,
|
||||
}
|
||||
|
||||
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
|
||||
pub(crate) struct RetailSkyMaterial {
|
||||
#[uniform(0)]
|
||||
pub params: SkyParams,
|
||||
#[texture(1)]
|
||||
#[sampler(2)]
|
||||
pub diffuse: Handle<Image>,
|
||||
#[texture(3)]
|
||||
#[sampler(4)]
|
||||
pub sun: Option<Handle<Image>>,
|
||||
}
|
||||
impl Material for RetailSkyMaterial {
|
||||
fn enable_prepass() -> bool {
|
||||
false
|
||||
}
|
||||
fn enable_shadows() -> bool {
|
||||
false
|
||||
}
|
||||
fn vertex_shader() -> ShaderRef {
|
||||
bevy::asset::AssetPath::from(bevy::asset::embedded_path!("retail_sky.wgsl")).with_source("embedded").into()
|
||||
}
|
||||
fn fragment_shader() -> ShaderRef {
|
||||
Self::vertex_shader()
|
||||
}
|
||||
fn specialize(
|
||||
_: &bevy::pbr::MaterialPipeline,
|
||||
descriptor: &mut bevy::render::render_resource::RenderPipelineDescriptor,
|
||||
_: &bevy::mesh::MeshVertexBufferLayoutRef,
|
||||
_: bevy::pbr::MaterialPipelineKey<Self>,
|
||||
) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> {
|
||||
descriptor.primitive.cull_mode = None;
|
||||
if let Some(depth) = &mut descriptor.depth_stencil {
|
||||
depth.depth_write_enabled = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct FogFrame {
|
||||
ramp: [f32; 4],
|
||||
colour: [f32; 4],
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Environment {
|
||||
anchor_height: f32,
|
||||
sun_direction: [f32; 3],
|
||||
sun_scale: f32,
|
||||
multiplier: f32,
|
||||
location_chain: Vec<String>,
|
||||
#[serde(default)]
|
||||
fog_frame: Option<FogFrame>,
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Sky {
|
||||
width: u32,
|
||||
height: u32,
|
||||
positions: Vec<[f32; 3]>,
|
||||
uvs: Vec<[f32; 2]>,
|
||||
indices: Vec<u32>,
|
||||
#[serde(default)]
|
||||
sun_width: u32,
|
||||
#[serde(default)]
|
||||
sun_height: u32,
|
||||
environment: Option<Environment>,
|
||||
}
|
||||
fn load(root: &std::path::Path, name: &str) -> Result<(Sky, Vec<u8>, Option<Vec<u8>>), String> {
|
||||
if name.is_empty() || name.contains(['/', '\\', ':']) || matches!(name, "." | "..") {
|
||||
return Err("Invalid retail sky name".into());
|
||||
}
|
||||
let base = root.join("private/native-skies");
|
||||
let read = |suffix: &str| {
|
||||
std::fs::read(base.join(format!("{name}{suffix}"))).map_err(|e| e.to_string())
|
||||
};
|
||||
let sky: Sky = serde_json::from_slice(&read(".json")?).map_err(|e| e.to_string())?;
|
||||
let rgba = read(".rgba")?;
|
||||
if sky.width == 0
|
||||
|| sky.height == 0
|
||||
|| u64::from(sky.width) * u64::from(sky.height) * 4 != rgba.len() as u64
|
||||
|| sky.positions.is_empty()
|
||||
|| sky.positions.len() != sky.uvs.len()
|
||||
|| sky.indices.is_empty()
|
||||
|| sky.indices.len() % 3 != 0
|
||||
|| sky
|
||||
.indices
|
||||
.iter()
|
||||
.any(|&i| i as usize >= sky.positions.len())
|
||||
|| sky
|
||||
.positions
|
||||
.iter()
|
||||
.flatten()
|
||||
.chain(sky.uvs.iter().flatten())
|
||||
.any(|v| !v.is_finite())
|
||||
{
|
||||
return Err("Invalid retail sky dimensions/geometry".into());
|
||||
}
|
||||
let sun = if let Some(env) = &sky.environment {
|
||||
let direction = Vec3::from_array(env.sun_direction);
|
||||
if !env.anchor_height.is_finite()
|
||||
|| !direction.is_finite()
|
||||
|| !(0.99..=1.01).contains(&direction.length_squared())
|
||||
|| !env.sun_scale.is_finite()
|
||||
|| env.sun_scale <= 0.
|
||||
|| !env.multiplier.is_finite()
|
||||
|| env.multiplier < 0.
|
||||
|| sky.sun_width == 0
|
||||
|| sky.sun_height != 16
|
||||
{
|
||||
return Err("Invalid authored sky environment".into());
|
||||
}
|
||||
if let Some(fog) = &env.fog_frame {
|
||||
if !fog.ramp.iter().chain(fog.colour.iter()).all(|v| v.is_finite())
|
||||
|| fog.ramp[0] < 0. || fog.ramp[2] <= 0.
|
||||
|| !(-1. ..=0.).contains(&fog.colour[3]) {
|
||||
return Err("Invalid authored fog frame".into());
|
||||
}
|
||||
}
|
||||
let bytes = read(".sun.rgba")?;
|
||||
if u64::from(sky.sun_width) * u64::from(sky.sun_height) * 4 != bytes.len() as u64 {
|
||||
return Err("Invalid sun-gradient payload".into());
|
||||
}
|
||||
Some(bytes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((sky, rgba, sun))
|
||||
}
|
||||
fn image(rgba: Vec<u8>, width: u32, height: u32, repeat: bool) -> Image {
|
||||
use bevy::render::render_resource::*;
|
||||
let mut image = Image::new(
|
||||
Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
TextureDimension::D2,
|
||||
rgba,
|
||||
TextureFormat::Rgba8Unorm,
|
||||
bevy::asset::RenderAssetUsages::RENDER_WORLD,
|
||||
);
|
||||
let mut sampler = bevy::image::ImageSamplerDescriptor::linear();
|
||||
if repeat {
|
||||
sampler.address_mode_u = bevy::image::ImageAddressMode::Repeat;
|
||||
}
|
||||
image.sampler = bevy::image::ImageSampler::Descriptor(sampler);
|
||||
image
|
||||
}
|
||||
pub(crate) fn spawn_sky(
|
||||
name: &str,
|
||||
root: &std::path::Path,
|
||||
commands: &mut crate::map_render::SceneCommands,
|
||||
meshes: &mut impl crate::map_render::AssetSink<Mesh>,
|
||||
images: &mut impl crate::map_render::AssetSink<Image>,
|
||||
materials: &mut impl crate::map_render::AssetSink<RetailSkyMaterial>,
|
||||
world_materials: &mut crate::map_render::StagedAssets<RetailWorldMaterial>,
|
||||
) {
|
||||
let (sky, rgba, sun) = match load(root, name) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("Retail sky unavailable for {name}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Legacy sky packages retain their original parameters. The scene exposure
|
||||
// remains the adapter's capture default until auto-exposure is recovered.
|
||||
let mut params = SkyParams {
|
||||
settings: Vec4::new(165., 2.5, 1., 0.),
|
||||
sun_direction: Vec4::ZERO,
|
||||
};
|
||||
if let Some(env) = &sky.environment {
|
||||
params.settings = Vec4::new(env.anchor_height, 2.5, env.multiplier, env.sun_scale);
|
||||
params.sun_direction = Vec3::from_array(env.sun_direction).extend(0.);
|
||||
for (_, material) in world_materials.iter_mut() {
|
||||
// Only the existing tangent-sign term uses this vector. No added
|
||||
// directional light energy or imported-world shadow map.
|
||||
material.params.sun_direction = params.sun_direction;
|
||||
if let Some(fog) = &env.fog_frame {
|
||||
material.params.fog_ramp = Vec4::from_array(fog.ramp);
|
||||
material.params.fog_color = Vec4::from_array(fog.colour);
|
||||
}
|
||||
}
|
||||
info!(
|
||||
"SKATE_SKY_READY map={name:?} location={:?} anchor={} sun_scale={} multiplier={} authored_direction={:?}",
|
||||
env.location_chain, env.anchor_height, env.sun_scale, env.multiplier, env.sun_direction
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Retail sky {name} has legacy metadata; reconvert skies for authored parameters and sun"
|
||||
);
|
||||
}
|
||||
let sun = sun.map(|bytes| images.add(image(bytes, sky.sun_width, sky.sun_height, false)));
|
||||
let mesh = Mesh::new(
|
||||
bevy::mesh::PrimitiveTopology::TriangleList,
|
||||
bevy::asset::RenderAssetUsages::RENDER_WORLD,
|
||||
)
|
||||
.with_inserted_attribute(Mesh::ATTRIBUTE_POSITION, sky.positions)
|
||||
.with_inserted_attribute(Mesh::ATTRIBUTE_UV_0, sky.uvs)
|
||||
.with_inserted_indices(bevy::mesh::Indices::U32(sky.indices));
|
||||
commands.spawn((
|
||||
Name::new("Retail sky dome"),
|
||||
Mesh3d(meshes.add(mesh)),
|
||||
MeshMaterial3d(materials.add(RetailSkyMaterial {
|
||||
params,
|
||||
diffuse: images.add(image(rgba, sky.width, sky.height, true)),
|
||||
sun,
|
||||
})),
|
||||
Transform::default(),
|
||||
bevy::camera::visibility::NoFrustumCulling,
|
||||
bevy::light::NotShadowCaster,
|
||||
bevy::light::NotShadowReceiver,
|
||||
));
|
||||
}
|
||||
@@ -1,20 +1,38 @@
|
||||
#import bevy_pbr::{forward_io::Vertex, mesh_view_bindings::view}
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> settings: vec4<f32>;
|
||||
struct SkyParams { settings: vec4<f32>, sun_direction: vec4<f32> }
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: SkyParams;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var panorama: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var panorama_sampler: sampler;
|
||||
struct SkyOut { @builtin(position) position: vec4<f32>, @location(0) uv: vec2<f32> }
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var sun_gradient: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(4) var sun_sampler: sampler;
|
||||
struct SkyOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
@location(1) dome_position: vec3<f32>,
|
||||
}
|
||||
@vertex
|
||||
fn vertex(v: Vertex) -> SkyOut {
|
||||
var out: SkyOut;
|
||||
let world = v.position + vec3<f32>(view.world_position.x,settings.x,view.world_position.z);
|
||||
let world = v.position + vec3<f32>(view.world_position.x,p.settings.x,view.world_position.z);
|
||||
out.position = view.clip_from_world*vec4<f32>(world,1.0);
|
||||
// Reverse-Z far plane: the authored dome is larger than the world camera's far distance.
|
||||
// Reverse-Z far plane: the authored dome is larger than the camera's far distance.
|
||||
out.position.z = 0.0000001*out.position.w;
|
||||
out.uv = v.uv;
|
||||
out.dome_position = v.position;
|
||||
return out;
|
||||
}
|
||||
@fragment
|
||||
fn fragment(i: SkyOut) -> @location(0) vec4<f32> {
|
||||
let d = textureSample(panorama,panorama_sampler,i.uv).rgb;
|
||||
return vec4<f32>(d*d*settings.y*settings.z,1.0);
|
||||
var linear = d*d;
|
||||
if p.settings.w > 0.0 {
|
||||
// Native sky_defaultPS: sine-angle lookup in the material's specular
|
||||
// gradient, alpha controls the HDR core. World position minus the sky
|
||||
// anchor is exactly the local dome position used here.
|
||||
let dot_pl = saturate(dot(normalize(i.dome_position),p.sun_direction.xyz));
|
||||
let sin_angle = sqrt(saturate(1.0-dot_pl*dot_pl));
|
||||
let sun = textureSample(sun_gradient,sun_sampler,vec2<f32>(sin_angle/p.settings.w,0.5/16.0));
|
||||
linear += sun.rgb*sun.rgb/saturate(sun.a+0.01);
|
||||
}
|
||||
return vec4<f32>(linear*p.settings.y*p.settings.z,1.0);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var source_sampler: sampler;
|
||||
@group(0) @binding(2) var<uniform> enabled: vec4<f32>;
|
||||
@group(0) @binding(2) var<storage, read> exposure: vec4<f32>;
|
||||
|
||||
@fragment
|
||||
fn fragment(i: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
let c = textureSample(source,source_sampler,i.uv);
|
||||
let xe = max(c.rgb,vec3<f32>(0.0));
|
||||
let xe = max(c.rgb*exposure.x/2.5,vec3<f32>(0.0));
|
||||
let t = saturate(1.0-xe);
|
||||
let tm = max(xe*0.25+0.75,vec3<f32>(1.0))-t*t;
|
||||
let gamma = saturate(sqrt(max(tm*0.5,vec3<f32>(0.0)))*1.41);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#import bevy_pbr::{forward_io::VertexOutput, mesh_view_bindings as frame}
|
||||
#import bevy_pbr::shadows::fetch_directional_shadow
|
||||
|
||||
struct WorldParams {
|
||||
mode: vec4<f32>, surface: vec4<f32>, family: vec4<f32>,
|
||||
fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32>,
|
||||
mode: vec4<f32>, foliage_debug: vec4<f32>, surface: vec4<f32>, family: vec4<f32>,
|
||||
fog_ramp: vec4<f32>, fog_color: vec4<f32>, shadow_color: vec4<f32>, sun_direction: vec4<f32>, decal: vec4<f32>, water: array<vec4<f32>, 4>,
|
||||
}
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(0) var<uniform> p: WorldParams;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var diffuse: texture_2d<f32>;
|
||||
@@ -21,11 +22,22 @@ struct WorldParams {
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(14) var specular_sampler: sampler;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(15) var environment_map: texture_cube<f32>;
|
||||
|
||||
struct FrameState { shadow: vec4<f32>, clock: vec4<f32>, pca: array<vec4<f32>, 7> }
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(16) var<storage, read> frame_state: FrameState;
|
||||
|
||||
// Textures and base UVs both have V flipped by the exporter. Scale in the
|
||||
// authored coordinate system, then return to the flipped texture rows.
|
||||
fn scaled_uv(uv: vec2<f32>, scale: f32) -> vec2<f32> {
|
||||
return vec2<f32>(uv.x*scale, 1.0-(1.0-uv.y)*scale);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let fam = u32(p.mode.x);
|
||||
let flags = u32(p.mode.y);
|
||||
let a = textureSample(diffuse, diffuse_sampler, i.uv);
|
||||
var diffuse_uv=i.uv;
|
||||
if fam==14u { diffuse_uv+=fract(frame_state.clock.x*p.water[1].xy*vec2<f32>(1.0,-1.0)); }
|
||||
let a = textureSample(diffuse, diffuse_sampler, diffuse_uv);
|
||||
let lm = textureSampleLevel(lightmap, lm_sampler, i.uv_b, 0.0).rgb;
|
||||
// Sample before alpha rejection: implicit derivatives must be uniform.
|
||||
var nm = vec3<f32>(0.5,0.5,1.0);
|
||||
@@ -35,13 +47,13 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
|
||||
var masks = vec3<f32>(0.0);
|
||||
if (flags & 1u) != 0u && (fam <= 6u || fam == 13u) { nm = textureSample(normal_map,normal_sampler,i.uv).rgb; }
|
||||
if (flags & 2u) != 0u && fam != 2u {
|
||||
detail = textureSample(detail_map,detail_sampler,i.uv*p.surface.z).rg;
|
||||
detail = textureSample(detail_map,detail_sampler,scaled_uv(i.uv,p.surface.z)).rg;
|
||||
}
|
||||
if fam == 5u || fam == 6u || fam == 13u {
|
||||
// The reference folds the reflective material's constant detail texel.
|
||||
if (flags & 128u) != 0u { detail = textureLoad(detail_map,vec2<i32>(0),0).rg; }
|
||||
}
|
||||
if (flags & 4u) != 0u { overlay_sample = textureSample(macro_map,macro_sampler,i.uv*p.surface.x).rgb; }
|
||||
if (flags & 4u) != 0u { overlay_sample = textureSample(macro_map,macro_sampler,scaled_uv(i.uv,p.surface.x)).rgb; }
|
||||
if (flags & 8u) != 0u && (fam == 3u || fam == 4u) { art = textureSample(decal_map,decal_sampler,i.color.xy); }
|
||||
if (flags & 16u) != 0u { masks = textureSample(specular_map,specular_sampler,i.uv).rgb; }
|
||||
var wn = normalize(i.world_normal);
|
||||
@@ -64,14 +76,136 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
|
||||
#endif
|
||||
let rpos = i.world_position.xyz - frame::view.world_position;
|
||||
let vd = -normalize(rpos);
|
||||
// Static reference axis for the tangent-space sign terms. This is not
|
||||
// an additive light or a shadow source. Native frame direction remains
|
||||
// an extraction gap; preserve the adapter's prior direction for now.
|
||||
let sun = normalize(vec3<f32>(4.0,7.0,4.0));
|
||||
// Authored render-location direction for the tangent-space sign terms.
|
||||
// This does not add directional light energy or a shadow source.
|
||||
let sun = p.sun_direction.xyz;
|
||||
var d = a.rgb*a.rgb;
|
||||
var alpha = 1.0;
|
||||
var lin = vec3<f32>(0.0);
|
||||
if fam == 9u || fam == 10u {
|
||||
var baked = lm*lm;
|
||||
if frame_state.shadow.w>0.0 && (fam<=8u || fam==13u) {
|
||||
let view_z=(frame::view.view_from_world*i.world_position).z;
|
||||
for (var light_id=0u; light_id<frame::lights.n_directional_lights; light_id+=1u) {
|
||||
// The lightmapped receiver source contains only player/board casters.
|
||||
if (frame::lights.directional_lights[light_id].flags & 5u)==5u {
|
||||
let visibility=fetch_directional_shadow(light_id,i.world_position,wn,view_z);
|
||||
baked=min(baked,vec3<f32>(visibility)+frame_state.shadow.rgb);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if fam==14u {
|
||||
lin=d*p.water[0].y;
|
||||
} else if fam==32u {
|
||||
lin=d*p.water[0].x;
|
||||
alpha=saturate((i.world_position.y-p.water[0].z)/max(p.water[0].y-p.water[0].z,1e-4));
|
||||
} else if fam==31u {
|
||||
let raw_uv=vec2<f32>(i.uv.x,1.0-i.uv.y);
|
||||
let uv=raw_uv*p.water[1].w;
|
||||
let sample_uv=vec2<f32>(uv.x,1.0-uv.y);
|
||||
let c0=textureSample(normal_map,normal_sampler,sample_uv)*2.0-1.0;
|
||||
let c1=textureSample(detail_map,detail_sampler,sample_uv)*2.0-1.0;
|
||||
let pca=(vec3<f32>(dot(c0,frame_state.pca[1])+dot(c1,frame_state.pca[2]),
|
||||
dot(c0,frame_state.pca[3])+dot(c1,frame_state.pca[4]),
|
||||
dot(c0,frame_state.pca[5])+dot(c1,frame_state.pca[6]))+frame_state.pca[0].xyz)*2.0-1.0;
|
||||
var overlay=1.0;
|
||||
if (flags & 4u)!=0u {
|
||||
let uv_overlay=raw_uv*p.surface.x;
|
||||
overlay=textureSample(macro_map,macro_sampler,vec2<f32>(uv_overlay.x,1.0-uv_overlay.y)).r;
|
||||
}
|
||||
let tonedown=2.0*p.water[1].z*saturate(dot(c1,frame_state.pca[6])+overlay);
|
||||
let nt=normalize(max(abs(normalize(mix(vec3<f32>(0.0,0.0,1.0),pca,tonedown))),vec3<f32>(0.001)));
|
||||
let n=nt.xzy;
|
||||
let rv=vd-2.0*n*dot(vd,n);
|
||||
var cube=vec3<f32>(0.0);
|
||||
if (flags & 64u)!=0u { cube=textureSampleBias(environment_map,diffuse_sampler,vec3<f32>(-rv.x,-rv.y,rv.z),log2(frame::view.viewport.w/640.0)).rgb; }
|
||||
let olm=textureSampleLevel(lightmap,lm_sampler,i.uv_b+0.01*nt.xy*vec2<f32>(1.0,-1.0),0.0).rgb;
|
||||
let fres=0.25+0.75*pow(1.0-abs(dot(n,vd)),5.0);
|
||||
let u=normalize(vec3<f32>(-n.z,0.0,n.x));
|
||||
let v=cross(n,u);
|
||||
let ax=p.water[2].z/p.water[2].x;
|
||||
let ay=p.water[2].z/p.water[2].y;
|
||||
let h=normalize(sun+vd);
|
||||
let eu=dot(h,u)/ax;
|
||||
let ev=dot(h,v)/ay;
|
||||
let den=sqrt(abs(dot(n,sun)*dot(n,vd)))*12.566371*ax*ay;
|
||||
let ward=exp(-2.0*(eu*eu+ev*ev)/(1.0+dot(h,n)))/max(den,1e-6);
|
||||
lin=(cube*olm*olm*fres+ward*p.water[0].rgb)*p.water[1].y;
|
||||
alpha=1.0;
|
||||
} else if fam==30u || fam==33u {
|
||||
let t=frame_state.clock.x;
|
||||
// Convert to original UVs for scale/scroll, then back to flipped rows.
|
||||
let raw_uv=vec2<f32>(i.uv.x,1.0-i.uv.y);
|
||||
let uv_scale=select(1.0,p.water[3].x,fam==33u);
|
||||
let uv1=raw_uv*p.water[2].xy*uv_scale+p.water[1].xy*t;
|
||||
let uv2=raw_uv*p.water[2].zw*uv_scale+p.water[1].zw*t;
|
||||
let n1=textureSample(normal_map,normal_sampler,vec2<f32>(uv1.x,1.0-uv1.y));
|
||||
let n2=textureSample(normal_map,normal_sampler,vec2<f32>(uv2.x,1.0-uv2.y));
|
||||
var vn=normalize((2.0*n1.rgb+2.0*n2.rgb-2.0)*p.water[0].xzw);
|
||||
var water_n=normalize(vn.x*kt+vn.y*kb+vn.z*wn);
|
||||
var sample_uv=i.uv;
|
||||
if fam==33u {
|
||||
let c1=textureSample(detail_map,detail_sampler,vec2<f32>(uv1.x,1.0-uv1.y))*2.0-1.0;
|
||||
let c2=textureSample(detail_map,detail_sampler,vec2<f32>(uv2.x,1.0-uv2.y))*2.0-1.0;
|
||||
let a1=n1*2.0-1.0;
|
||||
let a2=n2*2.0-1.0;
|
||||
// water_defaultPS instructions 22..54: the native mean is XYZ,
|
||||
// weights are R/G/B pairs. FrameState stores the ocean's R/B/G
|
||||
// arrangement; recover those original registers here.
|
||||
let mean=frame_state.pca[0].xzy;
|
||||
let pca1=vec3<f32>(dot(a1,frame_state.pca[1])+dot(c1,frame_state.pca[2])+mean.x,
|
||||
dot(a1,frame_state.pca[5])+dot(c1,frame_state.pca[6])+mean.y,
|
||||
dot(a1,frame_state.pca[3])+dot(c1,frame_state.pca[4])+mean.z);
|
||||
let pca2=vec3<f32>(dot(a2,frame_state.pca[1])+dot(c2,frame_state.pca[2])+mean.x,
|
||||
dot(a2,frame_state.pca[5])+dot(c2,frame_state.pca[6])+mean.y,
|
||||
dot(a2,frame_state.pca[3])+dot(c2,frame_state.pca[4])+mean.z);
|
||||
let first=vec3<f32>((pca2.x*2.0-1.0)*p.water[0].z,
|
||||
1.0+2.0*(pca2.y-1.0)*p.water[0].z,(pca2.z*2.0-1.0)*p.water[0].z);
|
||||
let second=vec3<f32>((pca1.x*2.0-1.0)*p.water[0].w,
|
||||
1.0+2.0*(pca1.y-1.0)*p.water[0].w,(pca1.z*2.0-1.0)*p.water[0].w);
|
||||
water_n=first*inverseSqrt(max(dot(first,first),1e-12));
|
||||
let refract=second*inverseSqrt(max(dot(second,second),1e-12));
|
||||
sample_uv+=0.02*refract.xz*vec2<f32>(1.0,-1.0);
|
||||
d=textureSample(diffuse,diffuse_sampler,sample_uv).rgb;
|
||||
d*=d;
|
||||
vn=water_n;
|
||||
}
|
||||
let water_lm_uv=i.uv_b+0.01*vn.xz*vec2<f32>(1.0,-1.0);
|
||||
var wlm=textureSampleLevel(lightmap,lm_sampler,water_lm_uv,0.0).rgb;
|
||||
if fam==33u {
|
||||
// Native tf3 fetches at all four half-texel corners, averages,
|
||||
// then squares. Squaring each tap would change baked lighting.
|
||||
let texel=0.5/vec2<f32>(textureDimensions(lightmap));
|
||||
wlm=(textureSampleLevel(lightmap,lm_sampler,water_lm_uv+texel,0.0).rgb
|
||||
+textureSampleLevel(lightmap,lm_sampler,water_lm_uv-texel,0.0).rgb
|
||||
+textureSampleLevel(lightmap,lm_sampler,water_lm_uv+texel*vec2<f32>(-1.0,1.0),0.0).rgb
|
||||
+textureSampleLevel(lightmap,lm_sampler,water_lm_uv+texel*vec2<f32>(1.0,-1.0),0.0).rgb)*0.25;
|
||||
}
|
||||
var lml=wlm*wlm;
|
||||
if frame_state.shadow.w>0.0 {
|
||||
let vz=(frame::view.view_from_world*i.world_position).z;
|
||||
for(var id=0u;id<frame::lights.n_directional_lights;id+=1u) {
|
||||
if (frame::lights.directional_lights[id].flags & 5u)==5u {
|
||||
let floor=select(frame_state.shadow.rgb,vec3<f32>(0.09,0.13,0.05),fam==33u);
|
||||
lml=min(lml,vec3<f32>(fetch_directional_shadow(id,i.world_position,wn,vz))+floor); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let kd=dot(water_n,vec3<f32>(0.58*sign(sun.x),0.62*sign(sun.y),0.39))*2.39562;
|
||||
var wm=vec2<f32>(0.0);
|
||||
if (flags & 16u)!=0u { wm=saturate(textureSample(specular_map,specular_sampler,sample_uv).xz-p.water[3].y); }
|
||||
let reflected_light=2.0*water_n*dot(water_n,sun)-sun;
|
||||
let ks=pow(max(saturate(dot(vd,reflected_light)),1e-6),p.water[3].z);
|
||||
var spec=ks*wm.x*vec3<f32>(2.1,1.8,1.5)*saturate(lml.g-0.1);
|
||||
if (flags & 64u)!=0u {
|
||||
let rv=vd-2.0*water_n*dot(vd,water_n);
|
||||
let cube=textureSampleBias(environment_map,diffuse_sampler,vec3<f32>(-rv.x,-rv.y,rv.z),log2(frame::view.viewport.w/640.0)).rgb;
|
||||
let lum=0.3*saturate(4.0*wm.y-2.6);
|
||||
spec+=cube*(lml.g+lum*(1.0-lml.g))*wm.y*1.5;
|
||||
}
|
||||
lin=(lml*kd*d+spec)*p.water[0].y;
|
||||
alpha=max(spec.g,p.water[3].w);
|
||||
} else if fam == 9u || fam == 10u {
|
||||
lin = d * max(lm*lm,vec3<f32>(p.family.y)) * p.family.x;
|
||||
if fam == 9u { lin *= p.family.z; }
|
||||
alpha = a.a;
|
||||
@@ -79,8 +213,8 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
|
||||
lin = d;
|
||||
if fam == 11u { lin *= p.family.w; }
|
||||
} else {
|
||||
if (fam == 3u || fam == 4u) && (flags & 8u) != 0u { d = mix(d,art.rgb*art.rgb,art.a); }
|
||||
if (flags & 4u) != 0u && fam < 13u { d *= saturate((overlay_sample-0.5)*p.surface.y+0.5); }
|
||||
if (fam == 3u || fam == 4u) && (flags & 8u) != 0u && (flags & 512u) == 0u { d = mix(d,art.rgb*art.rgb,art.a*p.decal.x); }
|
||||
if (flags & 4u) != 0u && fam < 13u && (flags & 256u) == 0u { d *= saturate((overlay_sample-0.5)*p.surface.y+0.5); }
|
||||
var kd = 0.93429;
|
||||
if (fam <= 6u || fam == 13u) && (flags & 1u) != 0u {
|
||||
var dxy = vec2<f32>(0.5);
|
||||
@@ -103,7 +237,7 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
|
||||
wn = normalize(raw.x*tt+raw.y*bb+wn*max(raw.z,0.05));
|
||||
kd = (vnd.x*0.58*sign(dot(kt,sun))+vnd.y*0.62*sign(dot(kb,sun))+vnd.z*0.39)*2.39562;
|
||||
}
|
||||
var lml = lm*lm;
|
||||
var lml = baked;
|
||||
lin = lml*kd*d;
|
||||
if fam == 13u { lin = lml*d*a.a; }
|
||||
if (flags & 16u) != 0u {
|
||||
@@ -131,6 +265,7 @@ fn fragment(i: VertexOutput) -> @location(0) vec4<f32> {
|
||||
var xe = max((lin*fog_a+p.fog_color.rgb*f)*p.mode.w,vec3<f32>(0.0));
|
||||
// Reduced curve is the full curve with the linear input capped at one.
|
||||
if fam == 8u { xe = min(xe,vec3<f32>(1.0)); }
|
||||
if p.foliage_debug.w != 0.0 { return vec4<f32>(p.foliage_debug.rgb, 1.0); }
|
||||
if a.a < p.mode.z { discard; }
|
||||
return vec4<f32>(xe,alpha);
|
||||
}
|
||||
@@ -395,8 +395,7 @@ fn material_ids(
|
||||
m.alpha_mode,
|
||||
m.alpha_cutoff.to_bits(),
|
||||
];
|
||||
let retail = m.retail_definition.as_deref().and_then(crate::retail_render::Definition::parse)
|
||||
.filter(|d| d.supported());
|
||||
let retail = m.retail_definition.as_deref().and_then(crate::retail_render::Definition::parse);
|
||||
*unique.entry((key, retail)).or_insert(i)
|
||||
})
|
||||
.collect()
|
||||
@@ -427,6 +426,7 @@ pub(crate) fn spawn(
|
||||
materials: &mut impl crate::map_render::AssetSink<StandardMaterial>,
|
||||
retail_materials: &mut impl crate::map_render::AssetSink<crate::retail_render::RetailWorldMaterial>,
|
||||
images: &mut impl crate::map_render::AssetSink<Image>,
|
||||
tuning: &crate::retail_render::MaterialTuning,
|
||||
) {
|
||||
// Texture roles have different transfer functions even when sharing a record.
|
||||
let texture_ids = render_texture_ids(&map.textures);
|
||||
@@ -481,7 +481,7 @@ pub(crate) fn spawn(
|
||||
format,
|
||||
RenderAssetUsages::RENDER_WORLD,
|
||||
);
|
||||
if role == 3 || cube {
|
||||
if role == 3 || role == 6 || cube {
|
||||
let (bytes, levels) = crate::retail_render::mip_chain(&source.rgba, source.width, height, layers);
|
||||
image.data = Some(bytes);
|
||||
image.texture_descriptor.mip_level_count = levels;
|
||||
@@ -492,7 +492,7 @@ pub(crate) fn spawn(
|
||||
});
|
||||
}
|
||||
let mut sampler = bevy::image::ImageSamplerDescriptor::linear();
|
||||
if role != 1 && role != 4 && !cube {
|
||||
if role != 1 && role != 4 && role != 6 && !cube {
|
||||
sampler.address_mode_u = bevy::image::ImageAddressMode::Repeat;
|
||||
sampler.address_mode_v = bevy::image::ImageAddressMode::Repeat;
|
||||
}
|
||||
@@ -574,8 +574,8 @@ pub(crate) fn spawn(
|
||||
}
|
||||
}
|
||||
if let Some(definition) = m.retail_definition.as_deref()
|
||||
.and_then(crate::retail_render::Definition::parse).filter(|d| d.supported()) {
|
||||
let material = definition.build(m, &mut texture);
|
||||
.and_then(crate::retail_render::Definition::parse).filter(|d| d.supported(tuning)) {
|
||||
let material = definition.build(m, tuning, &mut texture);
|
||||
let material = retail_materials.add(material);
|
||||
commands.spawn((Name::new(m.name.clone()), Mesh3d(meshes.add(mesh)), MeshMaterial3d(material), Transform::default()));
|
||||
continue;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Authored FE travel destinations; private content is produced by the extractor.
|
||||
use std::path::Path;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub(crate) struct Destination {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub map: String,
|
||||
pub matrix: Option<[[f32; 4]; 4]>,
|
||||
#[serde(default)]
|
||||
pub unavailable_reason: Option<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Catalog { version: u32, destinations: Vec<Destination> }
|
||||
|
||||
pub(crate) fn load(assets: &Path) -> Result<Vec<Destination>, String> {
|
||||
let path = assets.join("private/teleports.json");
|
||||
let bytes = std::fs::read(&path).map_err(|e| format!("Teleport locations: {e}"))?;
|
||||
let catalog: Catalog = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||
if catalog.version != 1 { return Err("Unsupported teleport catalog version".into()); }
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for d in &catalog.destinations {
|
||||
if d.name.is_empty() || d.id.is_empty() || !ids.insert(&d.id)
|
||||
|| d.map.is_empty() || d.map.contains(['/', '\\', ':']) || matches!(d.map.as_str(), "." | "..") {
|
||||
return Err("Invalid teleport destination identity".into());
|
||||
}
|
||||
if let Some(m) = d.matrix {
|
||||
if !valid_matrix(m) { return Err(format!("Invalid teleport transform: {}", d.name)); }
|
||||
}
|
||||
}
|
||||
Ok(catalog.destinations)
|
||||
}
|
||||
|
||||
fn valid_matrix(m: [[f32; 4]; 4]) -> bool {
|
||||
m.iter().flatten().all(|v| v.is_finite())
|
||||
&& (0..3).all(|i| m[i][3].abs() < 1e-5)
|
||||
&& (m[3][3] - 1.).abs() < 1e-5
|
||||
&& m[2][0] * m[2][0] + m[2][2] * m[2][2] > 1e-6
|
||||
}
|
||||
|
||||
pub(crate) fn same_map(path: &Path, name: &str) -> bool {
|
||||
path.file_stem().is_some_and(|stem| stem.to_string_lossy().eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn rejects_invalid_destinations_and_preserves_authored_heading() {
|
||||
let mut m = skate_core::physics::skeleton_animation_record::IDENTITY;
|
||||
m[2] = [1., 0., 0., 0.];
|
||||
m[3] = [350.5, 140.2, -725.3, 1.];
|
||||
assert!(valid_matrix(m));
|
||||
m[3][1] = f32::NAN;
|
||||
assert!(!valid_matrix(m));
|
||||
assert!(same_map(Path::new("maps/DownTown.skate"), "Downtown"));
|
||||
assert!(!same_map(Path::new("maps/MegaPark.skate"), "University"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
use bevy::prelude::*;
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct Travel {
|
||||
pub open: bool,
|
||||
pub closed_this_frame: bool,
|
||||
shown: bool,
|
||||
rows: Vec<Destination>,
|
||||
selected: usize,
|
||||
generation: Option<u64>,
|
||||
}
|
||||
#[derive(Component)] struct TravelRoot;
|
||||
#[derive(Component)] struct TravelRow(usize);
|
||||
#[derive(Component)] struct TravelScroll;
|
||||
pub(crate) fn install(app: &mut App) {
|
||||
app.init_resource::<Travel>().add_systems(PreUpdate,
|
||||
interact.after(crate::customiser::navigation).before(crate::graphics_menu::MenuInput));
|
||||
}
|
||||
fn interact(
|
||||
mut commands: Commands, mut travel: ResMut<Travel>,
|
||||
config: Res<crate::config::Config>, map: Res<crate::map_transition::CurrentMap>,
|
||||
keys: Res<ButtonInput<KeyCode>>, nav: Res<crate::customiser::Navigation>,
|
||||
mut menu: ResMut<crate::graphics_menu::Menu>, mut skater: ResMut<crate::physics::SkaterRuntime>,
|
||||
roots: Query<Entity, With<TravelRoot>>,
|
||||
mut colors: Query<(&TravelRow, &mut BackgroundColor)>,
|
||||
buttons: Query<(&Interaction, &TravelRow), Changed<Interaction>>,
|
||||
mut scroll: Query<&mut ScrollPosition, With<TravelScroll>>,
|
||||
mut wheel: MessageReader<bevy::input::mouse::MouseWheel>,
|
||||
) {
|
||||
travel.closed_this_frame = false;
|
||||
let wheel_delta: f32 = wheel.read().map(|e| e.y * if e.unit == bevy::input::mouse::MouseScrollUnit::Line { 36. } else { 1. }).sum();
|
||||
if travel.generation != Some(map.generation) {
|
||||
travel.generation = Some(map.generation); travel.open = false; travel.shown = false;
|
||||
for e in &roots { commands.entity(e).despawn(); }
|
||||
travel.rows = load(&config.asset_root).unwrap_or_else(|e| { warn!("Travel destinations: {e}"); vec![] });
|
||||
travel.rows.retain(|d| d.matrix.is_some() && map.path.as_ref().is_some_and(|p| same_map(p, &d.map)));
|
||||
if let Some(id) = &config.teleport {
|
||||
if map.generation == 0 {
|
||||
if let Some(m) = travel.rows.iter().find(|d| &d.id == id).and_then(|d| d.matrix) { let _ = skater.travel_to(m); }
|
||||
}
|
||||
}
|
||||
}
|
||||
if travel.open && !travel.shown {
|
||||
travel.selected = 0; travel.shown = true;
|
||||
commands.spawn((TravelRoot, GlobalZIndex(11), Node { position_type:PositionType::Absolute, width:percent(100),height:percent(100),align_items:AlignItems::Center,justify_content:JustifyContent::Center,..default() }, BackgroundColor(Color::srgba(0.015,0.025,0.04,0.95))))
|
||||
.with_children(|root| {
|
||||
root.spawn(Node { width:px(680),max_width:percent(95),padding:UiRect::all(px(18)),flex_direction:FlexDirection::Column,..default() }).with_children(|panel| {
|
||||
panel.spawn((Text::new("TELEPORT"),TextFont { font_size:28.,..default() }));
|
||||
panel.spawn((TravelScroll, ScrollPosition::default(), Node {height:px(360),overflow:Overflow::scroll_y(),flex_direction:FlexDirection::Column,..default()})).with_children(|rows| {
|
||||
for (i,d) in travel.rows.iter().enumerate() {
|
||||
rows.spawn((Button,TravelRow(i),Node {height:px(36),flex_shrink:0.,padding:UiRect::all(px(6)),..default()},BackgroundColor(Color::srgb(0.08,0.11,0.15))))
|
||||
.with_child((Text::new(&d.name),TextFont {font_size:18.,..default()}));
|
||||
}
|
||||
if travel.rows.is_empty() { rows.spawn(Text::new("No destinations available on this map.")); }
|
||||
});
|
||||
panel.spawn((Button,TravelRow(travel.rows.len()),Node {height:px(36),..default()})).with_child(Text::new("Back to pause menu"));
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if travel.open {
|
||||
let count = travel.rows.len()+1;
|
||||
let up = keys.just_pressed(KeyCode::ArrowUp) || nav.pressed & 1 != 0;
|
||||
let down = keys.just_pressed(KeyCode::ArrowDown) || nav.pressed & 2 != 0;
|
||||
if up { travel.selected = (travel.selected+count-1)%count; }
|
||||
if down { travel.selected = (travel.selected+1)%count; }
|
||||
for mut p in &mut scroll {
|
||||
p.y = (p.y-wheel_delta).clamp(0., (travel.rows.len() as f32*36.-360.).max(0.));
|
||||
if (up || down) && travel.selected < travel.rows.len() {
|
||||
let y=travel.selected as f32*36.; p.y=p.y.min(y).max(y+36.-360.).max(0.);
|
||||
}
|
||||
}
|
||||
let mut chosen = (keys.just_pressed(KeyCode::Enter) || nav.pressed & 0x1000 != 0).then_some(travel.selected);
|
||||
for (interaction,row) in &buttons { if *interaction == Interaction::Pressed { chosen=Some(row.0); } }
|
||||
if keys.just_pressed(KeyCode::Escape) || nav.pressed & (0x2000|0x10) != 0 { chosen=Some(travel.rows.len()); }
|
||||
if let Some(i)=chosen {
|
||||
if let Some(m)=travel.rows.get(i).and_then(|d| d.matrix) {
|
||||
match skater.travel_to(m) { Ok(())=>menu.open=false, Err(e)=>warn!("Travel: {e}") }
|
||||
}
|
||||
travel.closed_this_frame = menu.open;
|
||||
travel.open=false;
|
||||
}
|
||||
}
|
||||
for (row, mut color) in &mut colors { color.0 = if row.0 == travel.selected { Color::srgb(0.10,0.30,0.34) } else { Color::srgb(0.08,0.11,0.15) }; }
|
||||
if !travel.open && travel.shown { for e in &roots { commands.entity(e).despawn(); } travel.shown=false; }
|
||||
}
|
||||
@@ -27,7 +27,7 @@ fn installed_worlds_prepare_commit_and_retire_without_simulation() {
|
||||
};
|
||||
let mut world = World::new();
|
||||
world.insert_resource(Config { asset_root: root.clone(), verification_capture: None,
|
||||
map: None, map_path: None, difficulty, check_assets: false, start_paused: false, multiplayer: Default::default(), map_fingerprint: crate::config::map_fingerprint(None).unwrap() });
|
||||
map: None, map_path: None, difficulty, check_assets: false, start_paused: false, teleport: None, multiplayer: Default::default(), map_fingerprint: crate::config::map_fingerprint(None).unwrap() });
|
||||
world.insert_resource(CurrentMap::from_package(None, None));
|
||||
world.insert_resource(graphs);
|
||||
world.insert_resource(physics);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Character lighting investigation
|
||||
|
||||
Status: position-dependent character lighting implemented; user-run visual validation pending.
|
||||
|
||||
Observed in the local reference renderer (`skate3_native_scene.cpp`, CaptureCharLighting,
|
||||
and `scene_char.hlsli`): defaultcharacter/CAC shading consumes a directional key,
|
||||
nine RGB spherical-harmonic rows, ambient multiplier, exposure, rim colour/direction,
|
||||
key/rim specular colours and powers, material multiplier, and shadow visibility.
|
||||
The character material path differs from the baked world-lightmap path. Hair uses
|
||||
its own wrapped diffuse/ambient/rim response. Skin needs its dedicated specular
|
||||
mask; opaque diffuse alpha is not a substitute. The player currently uses generic
|
||||
StandardMaterial, with imported-world directional lighting disabled.
|
||||
|
||||
Observed in TU3 generated PPC source:
|
||||
- 0x827E7280 reads nine Vector3 elements from field F45DFCAB6EC195CD, copies them
|
||||
into 16-byte rows at manager +16, and retains a default-probe mode byte.
|
||||
- Collections with class hash 6A2B5946514B8490 have default/dlc/cas/freeskate rows
|
||||
containing this complete nine-element array. Class 1FFDC8E3ACA07C1F also has it.
|
||||
- 0x827E7348 computes squared position differences against records at +160 and
|
||||
copies nine lighting vectors from a selected record. This supports a spatial
|
||||
lighting lookup, but does not yet establish the complete sampling/blending,
|
||||
registration, or fallback rule.
|
||||
- `light_probes` (class 0E902F3F038A483C) contains global directional-light structs,
|
||||
ambient, and arrays of cSimpleLightData. Do not assume these are the same as
|
||||
the spatial baked SH samples just because of the class name.
|
||||
|
||||
Initial investigation checklist (superseded by the implementation status below):
|
||||
1. Recover the spatial irradiance resource/registration and sampling path and
|
||||
resolve default probe selection from world/render state.
|
||||
2. Extract character frame inputs and per-material parameters through native
|
||||
references, retaining texture/normal/spec-mask semantics.
|
||||
3. Add a skinned character material adapter that evaluates native SH, key, rim,
|
||||
and highlights, updated from character position. Keep world materials and
|
||||
gameplay/physics unchanged.
|
||||
4. Supply character-only shadow visibility separately from the world's disabled
|
||||
additive sun/shadow rendering; establish the native source before choosing
|
||||
its renderer implementation.
|
||||
5. Validate in exposed and shaded locations and with skin/clothing/hair. No
|
||||
game launches are authorized by this investigation; prepare user launchers.
|
||||
|
||||
Reference caveat: community symbol labels are hypotheses. In particular the
|
||||
82E3B548 label must not be treated as a verified prototype. No copied renderer
|
||||
capture's lighting constants should become a universal outdoor preset.
|
||||
|
||||
## Implementation, 2026-09-08
|
||||
|
||||
`tools.asset_pipeline.irradiance` extracts RX2 type `0xEB0024` from the district's
|
||||
other presentation assets. Its section starts with count and relative array
|
||||
offset; each 160-byte record contains nine float4 SH rows, XYZ at byte 144 and a
|
||||
runtime link word at 156. The `SHP1` sidecar preserves groups, values and original
|
||||
world coordinates, converting only endianness. University contains 17,493 samples
|
||||
in 64 resources. Future map conversions write `<map>.irradiance` beside the map.
|
||||
|
||||
TU3 `0x827E6E28` registers these resources into a 41-by-41 spatial grid;
|
||||
`0x827E6AC8` selects the nearest sample in three dimensions from the XZ
|
||||
neighborhood and falls back to the default coefficients. `0x827E7348` checks a
|
||||
six-entry MRU cache with a strict squared-distance threshold of 6.25. Raw binary
|
||||
constant `0x820BD16C` is 15.0 (query margin); `0x822F8F38` is 6.25. The evidence
|
||||
is the generated PPC instructions corroborated with resource contents and the
|
||||
TU3 dump with SHA-256
|
||||
`F4AA113EB541BFBA03DBC108CF5AB43F58C965B20FA3B82F9C40938A0AD841C4`.
|
||||
Addresses use the dump's `0x82000000` base.
|
||||
|
||||
`retail_irradiance.rs` retains the native nearest-sample/cache behavior. Its
|
||||
static-world adaptation searches all loaded groups using expanded bounds and an
|
||||
XZ 15-metre neighborhood. It does not reproduce the original 12-resource
|
||||
streaming limit or grid-cell boundary quantization. The freeskate collection
|
||||
supplies the nine fallback rows; live changes of the manager's default mode are
|
||||
not implemented. This is not trilinear probe interpolation.
|
||||
|
||||
`tools.asset_pipeline.character_lighting` resolves each selected model's
|
||||
AttribulatorMaterialName through VLT inheritance and exports its nine complete
|
||||
material parameter rows. Skin/face specular textures preserve the original red
|
||||
channel separately; the GLB's grayscale inverse-roughness conversion is not used
|
||||
as the native specular mask. Source assets remain private.
|
||||
|
||||
`retail_character.rs` replaces the ten named retail GLB materials while retaining
|
||||
Bevy's skinned vertex path. It updates the SH rows from PlayerRoot's position.
|
||||
The fragment shader evaluates the native SH basis, authored directional key,
|
||||
ambient multiplier, rim response, key/rim highlights, normal maps and material
|
||||
multiplier. Diffuse sampling accounts for the GLB's sRGB texture format before
|
||||
the retail gamma-two decode. Output remains linear HDR for the existing shared
|
||||
tone pass. The separate depth shader retains alpha rejection.
|
||||
|
||||
A zero-lux directional shadow source provides world/self occlusion to the
|
||||
character's key and key specular terms. The baked world shader does not sample
|
||||
this shadow map; remaining StandardMaterials receive zero additive sunlight.
|
||||
This uses Bevy cascades/filtering/biases, not the console's exact cascade layout
|
||||
and 9-tap shadow filter. It does not add a skater-shadow receiver to baked world
|
||||
surfaces. No gameplay or physics code changes are involved.
|
||||
|
||||
Hair now uses the native wrapped diffuse/ambient/fresnel response, with the
|
||||
authored material colours and the existing GLB dye/coverage composition. The
|
||||
live CAC colour-register derivation and separate second-UV blended strand pass
|
||||
are not reconstructed; hair keeps the installed cutout mode. This is an explicit
|
||||
adapter limitation, not a claim of exact hair parity.
|
||||
|
||||
Scene exposure remains the existing 2.5 day-capture value shared with the world;
|
||||
the regional exposure controller, water and fog are still unfinished. Missing
|
||||
lighting sidecars leave the existing GLB material path active and log the reason.
|
||||
|
||||
Validation: 13 synthetic Python extraction/inheritance/GUID tests, two spatial
|
||||
selection/cache tests, and the windowless world-render adapter test pass. Character
|
||||
colour, shadow and normal-depth shader variants pass offline Naga composition
|
||||
against the vendored Bevy 0.18 shader modules. Release compilation passes. The
|
||||
game/recomp was not launched. User visual checks should cover exposed terrain,
|
||||
sheltered spots, skin/clothing highlights, hair coverage and body/board shadows.
|
||||
|
||||
## Player shadow receiver, 2026-09-08
|
||||
|
||||
The baked-world receiver is now connected. Native `scene.hlsl` evaluates
|
||||
`min(lightmap * lightmap, shadow_visibility + shadow_colour)` before diffuse,
|
||||
normal/detail lighting and specular. This is a component-wise minimum cap, not
|
||||
an extra multiplicative darkening pass. The local native reference's caster path
|
||||
is corroborated by TU3 `0x827EBEF0` (CSM setup), `0x827F82F8` (CSM render) and
|
||||
the character-family shadow shader names in the executable. The animated mesh
|
||||
and board are the caster geometry; no ground decal or circular blob is used.
|
||||
|
||||
Two zero-lux shadow sources separate the receiver contracts. The existing source
|
||||
includes world and player casters, and is sampled by the character. The new
|
||||
source includes only the ten skinned retail player/board pieces on reserved
|
||||
render layer 31. Camera views include that layer; ordinary world geometry stays
|
||||
on layer zero. World families 1–8 and 13 sample the player-only source and apply
|
||||
the recovered minimum clamp. This prevents terrain/building shadows from being
|
||||
applied a second time over the baked map. The source's lightmapped-diffuse flag
|
||||
identifies its role in the GPU light table, so entity ordering does not determine
|
||||
which shadow map the shaders select. Both lights have zero illuminance.
|
||||
|
||||
This reproduces the projected-mesh and lightmap-combination method, **not full
|
||||
console parity**. Shadow map projection/filtering/biases still use Bevy cascades.
|
||||
The original per-frame c8 shadow-colour controller has not been recovered: the
|
||||
adapter currently uses the local irradiance probe's constant RGB coefficient as
|
||||
the ambient shadow floor. At University's starting point that is approximately
|
||||
(0.071711, 0.095925, 0.127945), read from the source sample, not a global preset.
|
||||
The floor updates when the selected probe changes. Native caster exclusions,
|
||||
atlas coverage encoding/blur, shadow colour and pixel matching remain separate
|
||||
parity work. Unsupported world material families still have no custom receiver.
|
||||
|
||||
Offline validation: world, character and character depth shaders pass Naga
|
||||
composition/validation; a windowless ECS regression test verifies the caster-layer
|
||||
separation and zero illuminance. Release compilation passes. The prepared user
|
||||
launcher retains the texture-band repairs and prior character lighting. No game
|
||||
or recomp was launched, and no physics/animation implementation changed.
|
||||
|
||||
## Shadow stability follow-up, 2026-09-08
|
||||
|
||||
User testing reported thin/warped limbs in the shadow, heavy lag, a crash, and
|
||||
abrupt changes in darkness. The previous implementation changed every world
|
||||
material when nearest-probe selection changed. This both stepped the ambient
|
||||
floor and invalidated all those material assets. It is a confirmed code defect
|
||||
and a plausible source of the reported stalls; no contemporaneous crash dump or
|
||||
Application error record was found, so the crash itself is not diagnosed.
|
||||
|
||||
World materials now bind one persistent 16-byte storage buffer. An extracted
|
||||
resource supplies the shadow floor, and the render queue writes it in place
|
||||
after asset preparation. Neither the storage asset nor world materials are
|
||||
mutated during probe changes; existing bind groups remain valid. The floor uses
|
||||
an exponential 0.35-second transition, with each frame's contribution capped at
|
||||
50 ms to avoid a jump after a hitch. These are adapter settings, not recovered
|
||||
native constants. The initial sample is applied directly.
|
||||
|
||||
The player-only source now uses one 24-metre cascade instead of four extending
|
||||
to 100 metres. This removes its internal cascade transitions and three shadow
|
||||
views. Its receiver normal offset is zero and depth bias is 0.002 metres; the
|
||||
previous defaults were 1.8 texels and 0.02 metres. World-plus-character self
|
||||
shadowing retains its original source and bias. The new settings target the
|
||||
reported silhouette distortion, but visible improvement is not confirmed offline.
|
||||
A single cascade trades distant coverage for stability; shadows outside its
|
||||
24-metre camera range are intentionally unavailable. Exact console parity is
|
||||
still pending the native colour controller and filtering work above.
|
||||
|
||||
Offline world/character/depth shader validation and six filtered unit tests pass,
|
||||
including smooth probe transitions, hitch limiting, caster isolation and cascade
|
||||
configuration. Release compilation passes. The updated private University
|
||||
launcher records stdout/stderr, backtraces and the exit code in
|
||||
`logs/renderer-work/player-shadow-runtime.log`. It has not been executed.
|
||||
|
||||
|
||||
## Later visual pass
|
||||
|
||||
See [visual-parity-status.md](visual-parity-status.md) for the subsequent SH
|
||||
transition smoothing, second-UV blended hair coverage, GPU exposure meter and
|
||||
remaining shadow/CAC fidelity gaps. Earlier implementation sections are historical.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Authored props and grime follow-up
|
||||
|
||||
## Targeted decal correction after layer test
|
||||
|
||||
The user rechecked the comparison and identified decals as the unwanted layer.
|
||||
Repeating grime is unchanged. Wear/stain decals now use 35% of their texture's
|
||||
alpha; source UVs, dimensions, texture RGB and placement are retained. This is
|
||||
explicit visual tuning based on the user's feedback, not proof of the original
|
||||
game's intended opacity and not a recovered native shader constant.
|
||||
|
||||
Classification uses the material's authored `decal` texture label for grime,
|
||||
grunge, stain, oil-dirt, drainage and ground-decal entries. Arrows, logos,
|
||||
scratches and ramp edge paint retain full authored opacity. A regression test
|
||||
covers this separation. This is not a blanket fade on every decal material.
|
||||
|
||||
Clamped decals also now receive a mip chain, independently of lightmaps, which
|
||||
remain bilinear mip zero. Previously the shared clamp texture role omitted mips
|
||||
for both, leaving fine decal alpha detail unfiltered at distance. The new decal
|
||||
role retains clamp addressing while permitting mip filtering. No map re-export
|
||||
or owned texture modification is needed. GPU visual verification remains for
|
||||
the user; source and shader checks cannot confirm the preferred visual strength.
|
||||
|
||||
## Grime investigation after user retest
|
||||
|
||||
The UV-scale correction did not resolve the user's reported visual mismatch.
|
||||
The current runtime log confirms the pictured Super Ultra Mega Park area is in
|
||||
the University package. The separately named MegaPark district inspected earlier
|
||||
contains different stadium geometry; it is not the correct visual test location.
|
||||
|
||||
The actual University's MPboards/MPCutStn materials contain both repeating macro
|
||||
weathering and separate grime-puddle, long-grunge, water-stain and building-stain
|
||||
decals. Binary material channel GUIDs agree with the exported bindings. Fifteen
|
||||
sampled ramp/wood meshes in the current private University package match their
|
||||
source cache positions, base UVs and independent decal UVs exactly after the
|
||||
writer's V flip. This rules out a missing decal UV channel in those samples; it
|
||||
does not establish visual parity or prove which layer causes the reported marks.
|
||||
|
||||
An opt-in `SKATE_WEATHERING_COMPARE=1` diagnostic enables F8 to cycle authored
|
||||
layers, repeating grime off, decals off, and both off. The window title and log
|
||||
identify the selected mode. The comparison launcher fixes exposure at 2.5 so
|
||||
automatic brightness adaptation cannot counteract each layer change. Normal
|
||||
launches retain their original rendering. Material uniforms change only on a
|
||||
keypress; the original texture flags remain intact for restoration.
|
||||
|
||||
This is an isolation tool, not another claimed grime fix. The remaining
|
||||
strength/scale mismatch needs a user-run layer comparison and ideally a matching
|
||||
original-game view. No arbitrary opacity or UV scale change was applied.
|
||||
|
||||
## Hair rollback
|
||||
|
||||
Removed the hair-specific changes from 96adff5 at the user's request: coverage
|
||||
texture bindings/blending, depth coverage, exported secondary UVs, FLOAT2 vendor
|
||||
decoder correction and its test. The private character package was regenerated
|
||||
with the previous exporter. Character probe smoothing remains. The other
|
||||
worktree's hair implementation was not touched or imported.
|
||||
|
||||
## Grime coordinates
|
||||
|
||||
The map writer flips both texture rows and base UV V. The world shader formerly
|
||||
sampled macro/detail maps at `flippedUV * scale`. For a scale of 0.3 this gives
|
||||
`0.3 - 0.3*v`, but the matching texture-row coordinate is `1 - 0.3*v`: a 0.7
|
||||
texture-period displacement. MegaPark's authored macro scale is approximately
|
||||
0.3. This shifts stains onto different parts of the ramps and over decal paint.
|
||||
|
||||
Macro and detail UVs now scale in the original coordinate system, then flip V.
|
||||
The authored opacity and native-reference overlay equation are unchanged:
|
||||
`saturate((macro.rgb - 0.5) * opacity + 0.5)`, multiplied over the linear
|
||||
diffuse/decal composite. Reference: scene.hlsl in native renderer commit
|
||||
f6e0ae87fdfecbadb5c1e36c55d66a744187a3cd. No arbitrary global grime reduction
|
||||
was introduced. Perceived strength still requires an in-game comparison after
|
||||
the coordinate correction; adaptive exposure also changes overall brightness.
|
||||
|
||||
## Dynamic-object initial placement
|
||||
|
||||
Observed source: district Sim RX2 `0xEB001D` records have a 32-byte header,
|
||||
128-byte records, row-vector affine matrix at +0, world bounds at +64,
|
||||
instance/locator IDs at +96/+104, template ID at +112 and name offset at +124.
|
||||
Native TU3 `0x825876D0` (community symbol SimManager::SpawnStatic) loads the
|
||||
translation at +48 and template ID at +112 before the DMO model lookup.
|
||||
Mapped image SHA256:
|
||||
F4AA113EB541BFBA03DBC108CF5AB43F58C965B20FA3B82F9C40938A0AD841C4.
|
||||
|
||||
`worlddmo.big` supplies reusable templates. Their EB000D 160-byte tInstance
|
||||
records bind template IDs to EB0001 model sections. Model mesh tables reference
|
||||
EB0023 sections, which reference the vertex declarations. The exporter follows
|
||||
those indices, never mesh order or material display names. Material texture
|
||||
GUIDs come from the binary channel bindings: the DMO texture namespace's high
|
||||
bit is absent from display-name suffixes, so suffix matching loses textures.
|
||||
|
||||
The exporter composes model, template and locator matrices in row-vector order;
|
||||
normals use the inverse transpose. Reflections reverse triangle winding. It
|
||||
neither snaps objects to terrain nor changes their authored matrices. Structured
|
||||
JSON alongside each presentation package retains IDs, matrices, source hashes,
|
||||
offsets and unresolved locators for a future dynamic runtime.
|
||||
|
||||
Offline restored placements:
|
||||
|
||||
| Map | Placed | Unresolved |
|
||||
| --- | ---: | ---: |
|
||||
| University | 479 | 0 |
|
||||
| DownTown | 786 | 14 |
|
||||
| Industrial | 162 | 18 |
|
||||
| BlackBoxPark | 12 | 0 |
|
||||
| MaloofMoneyCup | 31 | 0 |
|
||||
| Remaining five maps | 0 | 0 |
|
||||
|
||||
All University placements resolve. 478 of 479 transformed render bounds agree
|
||||
with their authored locator bounds within 5 cm (most within millimetres). One
|
||||
open dumpster differs by about 14.5 cm; its authored matrix is retained, since
|
||||
render bounds and assembly/animated bounds need not coincide.
|
||||
|
||||
The 32 unresolved Downtown/Industrial locators reference six template IDs absent
|
||||
from the supplied DMO catalog. The named assets are also absent from parkassets
|
||||
by those IDs, and district presentation template tables do not define them.
|
||||
They remain explicit unresolved records; no substitute geometry is invented.
|
||||
|
||||
Runtime loads private/native-props presentation supplements alongside the
|
||||
existing backdrop packages. This restores visible initial placement only:
|
||||
objects are not yet pushable, droppable or collidable. Their current materials
|
||||
use the existing PBR fallback, not the native dynamicobject lighting shader.
|
||||
No static district collision, gameplay or animation logic was changed.
|
||||
|
||||
Validation: synthetic record, rotation/scale/normal and invalid-reference tests;
|
||||
original-data template resolution and placement-bound comparison; offline map
|
||||
readers and shader composition. Game/recomp was not launched. GPU execution,
|
||||
appearance, performance and gameplay interaction are not validated here.
|
||||
+186
-17
@@ -18,10 +18,19 @@ Implemented:
|
||||
colour and depth passes. Foliage is two-sided.
|
||||
- Lightmaps remain bilinear, clamped and fixed at mip zero. Repeating world
|
||||
textures have mipmaps.
|
||||
- The three major districts use their actual sky geometry and diffuse texture
|
||||
from `miscload.big`. Texture selection follows material GUIDs through the
|
||||
RX2 reference table. Sky X/Z follow the camera; Y uses the reference's
|
||||
documented 165-metre default.
|
||||
- All ten installed worlds resolve sky geometry/texture paths through their
|
||||
authored `world` collection inheritance. The parks inherit the default
|
||||
panorama; Downtown and Industrial override it. Sky X/Z follow the camera;
|
||||
Y comes from the selected `render_locations` chain, not camera height.
|
||||
- The sky uses its material's diffuse and specular GUID bindings. The latter
|
||||
is the 512x16 radial sun gradient: sine-angle lookup, squared RGB divided
|
||||
by saturated alpha + 0.01, then the material multiplier and scene exposure.
|
||||
`material_sky/default.m_params[0]` supplies scale 0.75 and multiplier 0.35;
|
||||
these are decoded inputs, not constants copied from one frame. This follows
|
||||
`scene.hlsl`'s sky branch and `skate3_native_scene.cpp`'s sky-bank layout.
|
||||
- The selected render location supplies the normalized sun vector used by
|
||||
the sky and existing world tangent-sign terms. This adds no directional
|
||||
light energy or shadow map to imported worlds.
|
||||
|
||||
The converter reads scalar material channels at record offset 0x10.
|
||||
Earlier exports incorrectly read their string pointer and saved empty strings.
|
||||
@@ -29,22 +38,182 @@ Reconvert maps to obtain those constants and complete reflection cubes.
|
||||
Old packages still load; missing constants disable the relevant layer and
|
||||
single-face environment textures are not treated as cubes.
|
||||
|
||||
The VLT converter now adds `array` to array fields, retaining the existing
|
||||
`type` and `data` unchanged. `array.items` contains complete big-endian element
|
||||
hex strings without padding; `capacity`, `element_size` and `alignment`
|
||||
describe the source layout. Text arrays additionally retain decoded
|
||||
`text_items` before the binary bank is discarded. The layout follows
|
||||
VaultLib's `VLTArrayType`: four big-endian u16 header lanes followed by
|
||||
individually aligned elements. Alignment in the schema is a base-two exponent.
|
||||
|
||||
Skies can be refreshed independently of map/character conversion:
|
||||
|
||||
```text
|
||||
python -m tools.asset_pipeline.sky --game-root OWNED_EXTRACTED_GAME --assets PRIVATE_ASSET_ROOT
|
||||
```
|
||||
|
||||
Use a private staging asset directory for experiments. This reads only the
|
||||
database pair and sky resources; it does not require an ISO or Blender.
|
||||
Legacy sky metadata retains its previous multiplier and has no sun gradient.
|
||||
|
||||
## Treeline investigation
|
||||
|
||||
The missing forest in the supplied University / Super Ultra Mega Park
|
||||
comparison is not resolved by this change. The camera-relative
|
||||
`WorldPresEntityOptimesh` anchor from `sub_82792900` is consistent with the sky
|
||||
placement above, but the decoded University panorama has mountains and city
|
||||
scenery, not a dense forest layer. Its RX2 contains one 380-triangle dome.
|
||||
The separate `DIST_MegaPark` archive contains 102 mesh parts and no tree mesh;
|
||||
it is not a source for additional trees at University's park.
|
||||
|
||||
The main University export retains 479 model assets, 8,546 mesh parts and 86
|
||||
named TreeWall parts. Those named walls are outside the immediate park area;
|
||||
their absence at that location does not prove geometry is missing. Nearby
|
||||
proxy geometry demonstrably overlaps the main trees: in cell 350/-750,
|
||||
451 of 456 proxy long-tree triangles match triangles in the 912-triangle
|
||||
main tree after quantization to one millimetre, independent of winding.
|
||||
The five nonmatching triangles are not evidence for an extra forest.
|
||||
The main tree's diffuse and transparent channels resolve the same DXT5
|
||||
texture, with nonconstant alpha, and its baked lightmap is retained. The
|
||||
renderer already uses the retail cutoff in colour/depth and two-sided foliage.
|
||||
|
||||
No proxy geometry has been added. `skate3_draw_distance.cpp` documents the
|
||||
ProxyWorld/full-detail exchange; rendering both whole sets is not a valid
|
||||
fix. Identifying the specific missing silhouette now requires matched-camera
|
||||
reference evidence connecting it to a submitted mesh/material or a streaming
|
||||
collection. The two supplied images have different viewpoints. Native mip
|
||||
selection and regional/streaming state also remain unverified.
|
||||
|
||||
## Remaining differences
|
||||
|
||||
This is not a claim of pixel parity. Native frame constants are supplied by
|
||||
game hooks in the reference; this engine does not yet recover that controller.
|
||||
Exposure 2.5, material multiplier 1, and tree/proxy values use documented
|
||||
day-capture defaults. Imported maps have no dynamic directional sun or realtime
|
||||
sun shadows: their world lighting comes from the baked lightmaps. Normal-map
|
||||
sign terms retain the adapter's fixed direction until native frame data is
|
||||
available; this adds no light energy or shadow map. The fog inputs
|
||||
are wired into the shader but remain disabled without authored frame rows.
|
||||
The current pass enables authored distance fog, GPU exposure adaptation, flowing
|
||||
water, scrolling emissive signs, the global reflective water sheets and additional
|
||||
hair strand coverage. See [visual-parity-status.md](visual-parity-status.md) for
|
||||
provenance, validation and the distinction between native equations and adapters.
|
||||
|
||||
Sky sun-gradient shading, park sky selection, native water, character shaders,
|
||||
SSAO, SSR, bloom and volumetric lighting are not yet ported. The character and
|
||||
unsupported world families still use Bevy materials beneath the shared tone
|
||||
curve. Reflection normals use the reference's analytic world-up construction;
|
||||
there has been no matched-camera pixel comparison against the recomp.
|
||||
This is not pixel parity. Native regional environment selection is not ported;
|
||||
static map selection supplies the fog/sun and the default exposure tuning. The
|
||||
exposure meter uses portable HDR luminance samples instead of the native resolve
|
||||
surface. Shadow colour/filtering and CAC hair colour-register composition still
|
||||
have documented approximations. Ocean PCA wave reconstruction now uses the owned 30-frame table; its native
|
||||
phase/reset controller and the separate non-flowing water shader remain unported. SSAO, SSR, bloom and volumetric lighting
|
||||
are also outside the currently implemented renderer. Missing parameters retain
|
||||
fallback materials; these effects are not silently labelled complete.
|
||||
|
||||
The shaders are embedded in the executable. Sky and map payloads are extracted
|
||||
from the user's game and must never be committed or bundled with releases.
|
||||
|
||||
### Treeline investigation and diagnostic (2026-09-08)
|
||||
|
||||
The user's mega-park screenshots refer to the bowl/cliffs in University, not
|
||||
DIST_MegaPark (the separate create-a-park venue). A read-only decode of the installed
|
||||
University SKATE14 package found 8,546 materials, 1,645,617 render triangles,
|
||||
1,180 tree-family mesh materials and all 86 TreeWall meshes from the source cache.
|
||||
TreeWall diffuse texture 0x2c70170a00053a88 retains its RGBA cutout, and the tree
|
||||
materials retain masked alpha and two-sided flags. A TOC audit of all 479 full
|
||||
models found no extra vertex or face buffers omitted by the parser.
|
||||
|
||||
Proxy comparisons must resolve mesh material GUIDs with
|
||||
`_bind_material_groups_by_guid`; `mdl_parser.Mesh.name` is in material-table order
|
||||
and can label proxy terrain as trees. With GUID bindings, the 14 proxy foliage
|
||||
meshes intersecting x=200..550, z=-850..-600 contain 3,878 distinct triangles;
|
||||
3,813 match full geometry after unordered-vertex quantization to 1 mm. The small
|
||||
remainder does not establish absent trees (different triangulation/rounding also
|
||||
changes these keys). DMO_University and DMO_Global contain movable props, not a
|
||||
second background forest. These checks do not prove visual parity or a fix.
|
||||
|
||||
For a user-run discriminating capture, `SKATE_DEBUG_FOLIAGE=1` renders tree-wall
|
||||
cards cyan and other tree-family surfaces magenta, opaque and unlit. Geometry,
|
||||
camera, depth testing and occlusion remain authored. This bypasses texture alpha
|
||||
in both colour and depth passes without adding proxy geometry. Return to the
|
||||
reported viewpoint: solid foliage appearing in the missing area implicates the
|
||||
normal shading/alpha path; an empty area requires checking placement, camera or
|
||||
source selection. Unset the variable to restore normal rendering. This diagnostic
|
||||
has been prepared without launching the game.
|
||||
|
||||
### Missing global tree-wall backdrop resolved (2026-09-08)
|
||||
|
||||
The user-run opaque-foliage diagnostic left the same empty background. The
|
||||
missing surfaces are in `world/models/DIST_Water_University.rx2`, not in
|
||||
`DIST_University` or its proxy stream. Despite its name, this global model has
|
||||
both an ocean plane and a separate tree material: mesh 1 contains 1,572 vertices
|
||||
and 786 tree-wall triangles. World fields `951898F6C0FA6856` and
|
||||
`CA5A157A65E75934` select this model and its texture resource. The global
|
||||
Industrial model likewise contains 716 foliage triangles.
|
||||
|
||||
`tools.asset_pipeline.backdrop` follows those world references and exports only
|
||||
the tree-family presentation meshes into `private/native-backdrops/<map>.skate`.
|
||||
Mesh-to-material and material-to-texture selection use binary GUIDs: texture
|
||||
name suffixes differ from the global texture resource GUIDs. Original positions,
|
||||
indices, UVs, masked alpha, two-sided rendering and baked lightmaps are retained.
|
||||
At that stage only tree-family meshes were selected. The later visual pass also
|
||||
retains the authored global reflective water sheets; no synthetic placements are added.
|
||||
|
||||
The renderer loads this supplemental package after the district and before sky
|
||||
setup, through the existing retail mesh/material path. `parse_render_only` is
|
||||
an explicit data-reader entry point for presentation packages; ordinary playable
|
||||
map loading still requires collision. Presentation loading rejects collision,
|
||||
rails, doors, lights, routes and extensions other than WMET. This does not alter
|
||||
the playable map's collision or gameplay state. Future installation runs prepare
|
||||
the supplement automatically; existing assets can be updated with:
|
||||
|
||||
```powershell
|
||||
python -m tools.asset_pipeline.backdrop --game-root <owned-game> --assets <assets-root>
|
||||
```
|
||||
|
||||
Static verification: the University supplement contains exactly the source's
|
||||
786 triangles and matching vertex positions/indices/UVs. Zero of these triangles
|
||||
match the 86 existing district TreeWall meshes after unordered-vertex 1 mm
|
||||
quantization. Both University and Industrial supplements pass the runtime data
|
||||
reader's offline inspector. Synthetic GUID tests and map-reader regression tests
|
||||
pass. The executable was compiled but not launched; visual confirmation remains
|
||||
for the user. The normal launcher clears SKATE_DEBUG_FOLIAGE.
|
||||
|
||||
For integration with staged map switching, prepare this supplement alongside the
|
||||
district, before applying sky lighting. Track its entities and assets in the
|
||||
same map lifetime. The current adapter is in `retail_backdrop.rs`.
|
||||
|
||||
### Black texture/lightmap bands (2026-09-08)
|
||||
|
||||
The University bark rings and mega-park shark logo expose an exporter defect in
|
||||
the shared Xbox texture decoder. `XGAddress2DTiledX/Y` returns coordinates in the
|
||||
padded physical tile. `_untile360` flattened `y * logical_width + x` before
|
||||
rejecting columns outside the logical width. Padding columns consequently aliased
|
||||
onto later valid rows, overwriting colour blocks with padding or other tile data.
|
||||
The NumPy implementation reproduced the scalar bug, including last-write wins.
|
||||
Both implementations now reject `x >= logical_width` before writing.
|
||||
|
||||
The logo's diffuse is `0x2c70170a001d01bb`; its 64x64 DXT1 lightmap is
|
||||
`0x8b068e5c4f70dd7a`. Re-decoding that lightmap removes the black stripes while
|
||||
the logo diffuse remains unchanged. Nine lightmaps bound to the spring/fall-tree
|
||||
bark diffuse textures `0x00007e2903e3870a` and `0x00007dc403e3870a` have the same
|
||||
corruption. The shared decoder affects colour textures as well: the 64x128 palm
|
||||
fringe texture `0x00007e3103e3870a` also loses artificial horizontal bands.
|
||||
This establishes an asset-decoding cause independently of shader lighting.
|
||||
|
||||
A separate colour-palette error was corrected in both decoders: DXT3/DXT5 colour
|
||||
blocks must retain four interpolated colours even when their endpoints are in
|
||||
ascending order. The old code reused DXT1's three-colour/black branch and merely
|
||||
made that black opaque. DXT1's transparent palette remains intact. See Microsoft's
|
||||
[block compression reference](https://learn.microsoft.com/en-us/windows/win32/direct3d10/d3d10-graphics-programming-guide-resources-block-compression)
|
||||
for the format layouts and separate BC2/BC3 alpha blocks.
|
||||
|
||||
The private University repair re-decodes the original RX2 sources and changes
|
||||
723 of 2,046 texture payloads: 720 DXT1 (logical widths 16/32/64), two DXT5 and
|
||||
one A8R8G8B8. It preserves the exporter's dedicated B5G6R5 decoder output. Every
|
||||
original payload was checked against its source cache before replacement;
|
||||
material definitions and the entire geometry/collision/metadata tail remain
|
||||
byte-identical. The repaired package passes the offline SKATE reader.
|
||||
|
||||
Regression tests cover narrow/wide tile layouts at 2/4/8/16 bytes per unit,
|
||||
DXT1 transparency, and DXT3/DXT5 reversed colour endpoints with independent alpha,
|
||||
using both scalar and NumPy paths. All 16 asset-pipeline tests pass. The game was
|
||||
not launched; visual confirmation remains user-run. Existing exported maps need
|
||||
their textures regenerated from RX2, since changing the decoder cannot repair
|
||||
already-baked PNG/RGBA cache files. Fresh conversions use the fix across maps.
|
||||
|
||||
Player-shadow stability follow-up: the ambient floor is now a smoothed shared
|
||||
16-byte GPU buffer, updated without rebuilding world materials. The player-only
|
||||
source uses one 24 m cascade and reduced receiver bias. Native colour/filter
|
||||
parity and runtime visual/performance verification remain outstanding; see the
|
||||
character lighting investigation. The private launcher now captures crash output.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Travel menu and still-water follow-up
|
||||
|
||||
## Travel
|
||||
|
||||
Esc → Teleport lists the original English frontend location names for usable
|
||||
destinations on the currently loaded map. A continuous list supports mouse-wheel
|
||||
scrolling, a draggable scrollbar and keyboard selection that scrolls into view.
|
||||
There are no pages or cross-map destinations in this menu. The installer
|
||||
now extracts `private/teleports.json` by joining `fe_locations`, the shipped label
|
||||
and English language tables, and global RX2 locator records. Names are joined by
|
||||
their label hashes, not string-table order. The special skate wordmark byte is
|
||||
represented as `skate.` in plain text.
|
||||
|
||||
The current owned installation yields 42 frontend destinations, 41 with unique
|
||||
authored transforms. `Stadium - Monster Park` remains in the catalog but is hidden
|
||||
from the menu because it is unavailable:
|
||||
its reference `vert_skp5_01_challengelocator_01` was not found in the global
|
||||
locators or the inspected mission payload. No substitute position is invented.
|
||||
Frontend templates and unnamed park-layout aliases are excluded.
|
||||
|
||||
Same-map travel queues the existing player-input teleport/reset service and
|
||||
updates the checkpoint. The `--teleport ID` startup adapter also checks that the
|
||||
destination belongs to the loaded map; menu travel does not restart the session.
|
||||
Authored height and orientation are preserved. Runtime arrivals and menu layout
|
||||
have not been checked in a running game.
|
||||
|
||||
Evidence: TU3 `cGlobalFileLocationManager::LoadGlobalLocations` at `0x828A3D50`
|
||||
loads `data/content/global_locators/*.rx2`. The RX2 `0xEB0009` section contains
|
||||
128-byte records with the transform at +0, name pointer at +104, tag pointer at
|
||||
+108 and identifier at +112. The parser validates record and string boundaries.
|
||||
All 41 resolved positions have collision surfaces beneath their authored X/Z in
|
||||
the exported maps; vertical offsets range from approximately -0.008 to 4.536 m.
|
||||
This is an offline placement check, not a gameplay arrival test.
|
||||
|
||||
## Rendering
|
||||
|
||||
Still-water materials (`water.default`, `water.alpha`, `water.skatepark`) now
|
||||
have a dedicated family. Retained shader names upgrade older map packages at
|
||||
load time. The shader implements the decoded two-layer PCA normal reconstruction,
|
||||
UV scrolling, refraction, four-tap lightmap sampling and literal shadow floor.
|
||||
Flowing-water materials keep their existing family. Still water requires the
|
||||
private ocean PCA sidecar; automatic extraction of that sidecar from a fresh
|
||||
installation remains outstanding, and missing data uses the existing fallback.
|
||||
|
||||
The default and alpha pixel shaders have 128 bytes of literal constants before
|
||||
their instructions. SHA-256 of their instruction streams:
|
||||
|
||||
- Default: `B5F39F3080C89A220250E87643C5AD15F4A94B42B2C3DCB6BAF090E036FE3B5A`
|
||||
- Alpha: `81D9905165B28B03C3623D18887E7E9D9303D3FFDA072993B8C183D24BD3A547`
|
||||
|
||||
An offline register evaluator compared 600 normal/refraction cases across all 30
|
||||
owned PCA frames with the implemented algebra (maximum error 2.22e-16). This
|
||||
does not establish full pixel-shader or GPU visual parity. Private disassembly
|
||||
and validation output are under `logs/renderer-work/shader-inspect`.
|
||||
|
||||
Exposure extraction now follows each map's render-location reference and its
|
||||
inherited exposure settings into `private/exposure-profiles.json`. Runtime
|
||||
selects the active map's profile, with the existing global fallback. The ten
|
||||
current profiles resolve to the same values, so selection alone need not produce
|
||||
a visible change.
|
||||
|
||||
## Remaining parity work
|
||||
|
||||
- Spatial lighting/colour transition volumes and their native controller.
|
||||
- Exact colour grading: profiles reference `cc_financial.rgb`, which was not
|
||||
found in the inspected content. The available `cc_petes.rgb` is not evidence
|
||||
that this different lookup table should be applied.
|
||||
- Native bloom composition, console shadow-atlas filtering and the per-frame
|
||||
shadow-colour controller.
|
||||
- Fresh-install ocean PCA extraction and in-game water/teleport validation.
|
||||
|
||||
Movable-object work is deferred at the user's request. Hair is unchanged.
|
||||
|
||||
## Checks
|
||||
|
||||
Release build, four filtered Rust menu tests, eight Python extraction/environment
|
||||
tests, and composed world/depth shader validation passed. All ten map packages
|
||||
passed the offline reader; their geometry/collision tails remain byte-identical
|
||||
to the installed inputs. No game or recomp was launched. Owned assets and private
|
||||
inspection artifacts are not committed.
|
||||
|
||||
|
||||
## Integration with the current engine
|
||||
|
||||
The Teleport option is a separate scrollable pause-menu panel alongside character
|
||||
customisation, multiplayer and updates. Its list refreshes after in-process map
|
||||
changes and contains only destinations in the active map. Travel uses the existing
|
||||
manual teleport reply so it does not replace automatic bail checkpoints.
|
||||
|
||||
Scenery supplements and authored skies are prepared through the asynchronous map
|
||||
asset staging path, then retired with the map. Character shadow casters use layer
|
||||
28, separate from the scoring HUD on layer 31. Map changes reload character lighting
|
||||
and exposure. These integration changes require manual visual checks across a map
|
||||
switch, a destination teleport and the scoring/character menus.
|
||||
@@ -0,0 +1,128 @@
|
||||
# Visual parity status – 2026-09-08
|
||||
|
||||
Follow-up: the hair changes below were rolled back at the user's request.
|
||||
See [dynamic-props-and-grime.md](dynamic-props-and-grime.md) for the current
|
||||
hair rollback, grime coordinate correction and authored prop placement status.
|
||||
|
||||
Implemented and compiled offline; game/recomp not launched. User-run checks are
|
||||
still needed for the previously reported crash, shadow silhouette and performance.
|
||||
No physics, collision, gameplay or animation logic was changed.
|
||||
|
||||
## Authored distance fog
|
||||
|
||||
**Observed:** TU3 `0x828012D0` loads the fog collection at manager +180. Schema
|
||||
`material_fog` offsets are colour=0, power=16, near=20, max=24, far=28. The routine
|
||||
writes ramp `(1/(far-near), -near/(far-near), power)` and colour
|
||||
`(colour.rgb*max, -max)`. The negative-one vector at `0x830BD4A0` is initialized by
|
||||
`0x82F825F0` from float -1 at `0x8216DEE0`; its zero bytes in a static dump must
|
||||
not be interpreted as the initialized value. Colour is not squared here.
|
||||
|
||||
The reference world shader applies `f=pow(saturate(distance*scale+bias),power)`
|
||||
and `linear*(1-max*f)+colour.rgb*max*f`, before exposure. The exporter retains
|
||||
both the authored inputs and these derived rows. All ten private sky metadata
|
||||
files are refreshed. Regional transitions remain unimplemented.
|
||||
|
||||
## Exposure
|
||||
|
||||
**Observed:** `0x827F0D00` reads the GPU measurement, scales the weighted mean by
|
||||
2.515 (`0x821A01E8`), and evaluates
|
||||
`clamp(previous*(1 + damping*(target-measured)), min, max)`.
|
||||
The tuning schema independently confirms offsets 120/124/128/132 for
|
||||
`auto_exposure_target_luminance/min/max/damping`. The owned default collection
|
||||
contains target .25, min .75, max 2.5, damping .5. `0x827F0B78` uses squared
|
||||
`1-abs(x*y)` centre weighting. The comparison renderer normally pins exposure at
|
||||
zone max because its resolve readbacks are disabled; that is not console adaptation.
|
||||
|
||||
**Adapter:** one 256-thread compute group meters a 16x16 bilinear sample grid of
|
||||
linear HDR using Rec.709 luminance, native centre weighting and the native update
|
||||
formula. It uses a 30 Hz time basis and caps hitch contribution at 50 ms. This is
|
||||
not the native packed, resolved measurement surface or regional tuning selection.
|
||||
The compute result stays on the GPU; the existing final tone pass consumes it.
|
||||
There is no synchronous CPU readback and no per-material exposure update.
|
||||
`SKATE_FIXED_EXPOSURE=1` retains 2.5 for comparison. Missing metadata also retains
|
||||
fixed exposure. No claim is made that this meter's measured values match console.
|
||||
|
||||
## Water and other world materials
|
||||
|
||||
The material adapter now retains its shader key and loads authored VLT parameter
|
||||
arrays from `private/render-parameters.json`. Additional supported families:
|
||||
|
||||
- 30, `water.flowing*`: two time-scrolled normal taps, mapped normal, refracted
|
||||
lightmap coordinates, player-shadow minimum clamp, sun specular, cube reflection
|
||||
and native alpha floor. Follows reference `scene_water.hlsli`'s flowing shader.
|
||||
Texture row/UV flips are accounted for in scale, scroll and refraction offsets.
|
||||
- 14, `incandescent.backlituvscroll`: authored `uAnimationSpeed/vAnimationSpeed`,
|
||||
squared diffuse and material multiplier, followed by fog/exposure.
|
||||
- 32, `ocean.reflection`: shader support for squared horizon colour and the authored
|
||||
height fade. This is not the animated ocean-surface shader (family 31).
|
||||
|
||||
Global model export also includes authored `environment.reflective_simple` water
|
||||
sheets previously excluded by the foliage-only filter. Their original GUID-bound
|
||||
textures and mesh positions are retained in render-only supplements. These have
|
||||
no collision and never enter physics. Three maps now have global supplements.
|
||||
|
||||
**Ocean PCA follow-up:** `0x827905B0` initializes a 30-frame table with means at
|
||||
`0x830118D8` (3 floats/frame) and weights at `0x83011A40` (24 floats/frame).
|
||||
`0x82790858` selects frames at 1/30-second intervals, scales mean/weights by
|
||||
1/255, and publishes the seven rows. The reference shader binding swizzles mean
|
||||
X/Z/Y and weight pairs R/B/G. The build-checked `ocean_pca` extractor preserves
|
||||
those tables in a private sidecar. Family 31 now reconstructs normals from both
|
||||
PCA component textures and these animated rows, then evaluates Fresnel cube
|
||||
reflection, squared lightmap and Ward sun specular from the native shader.
|
||||
The adapter selects the authored frame by elapsed time; native phase/reset and
|
||||
hitch advancement are not reproduced. It requires the supported owned mapped
|
||||
image for extraction; missing/invalid PCA metadata retains the prior fallback.
|
||||
It does not invent procedural waves.
|
||||
|
||||
**Still unported:** the distinct non-flowing `water.default`/`water.alpha` shader.
|
||||
The local reference also uses an empirical fallback for this family, so its exact
|
||||
shader contract needs separate recovery.
|
||||
|
||||
## Character and shadows
|
||||
|
||||
The native nearest-probe selection/cache remains intact. Displayed SH coefficients
|
||||
now ease between samples using the same bounded .35-second adapter transition as
|
||||
the shadow floor. All ten models retain their existing skinning and poses.
|
||||
|
||||
The GLB exporter now preserves the hair's raw second UV set. RX2 declaration
|
||||
`0x002C23A5` is format 37 (big-endian float2), confirmed against the native
|
||||
vertex decoder. The old skeleton parser incorrectly read it as four normalized
|
||||
shorts. That decoder is corrected, and glTF accessor shape checks now reject
|
||||
four-component data declared as VEC2. The character
|
||||
sidecar references the original coverage texture; the shader samples its red
|
||||
channel on that second UV, scales it by `m_params[6].x` (CAC hair c15.x), and uses
|
||||
blended strand coverage. Its depth path retains strand holes with the world
|
||||
30/255 threshold, an adapter choice. Existing dye/rim composition remains an
|
||||
approximation: live CAC colour-register derivation is still outstanding.
|
||||
|
||||
The previous player-only shadow source and smooth shared shadow floor remain.
|
||||
Its shared storage buffer is now 144 bytes to carry animation time and ocean PCA; neither
|
||||
world materials nor their bind groups are rebuilt as these values change. Exact
|
||||
console atlas/filtering and the per-frame shadow-colour controller remain pending.
|
||||
|
||||
## Existing maps
|
||||
|
||||
Freshly decoded RX2 caches refreshed 2,019 of 7,204 texture payloads across ten
|
||||
private maps. Each map passes the offline reader, and its entire geometry,
|
||||
collision and metadata tail is byte-identical to the installed input. Material
|
||||
prefixes are preserved. Original installed maps are untouched. New refresh tooling
|
||||
publishes atomically and refuses in-place input overwrite.
|
||||
|
||||
## Evidence and validation
|
||||
|
||||
PPC source and raw dump identity: base `0x82000000`, SHA-256
|
||||
`F4AA113EB541BFBA03DBC108CF5AB43F58C965B20FA3B82F9C40938A0AD841C4`.
|
||||
Local native renderer reference commit
|
||||
`f6e0ae87fdfecbadb5c1e36c55d66a744187a3cd`, shaders `scene.hlsl`,
|
||||
`scene_water.hlsli`, `scene_char.hlsli`, plus `skate3_native_render.cpp`.
|
||||
Private evidence lives under `logs/renderer-work`: `exposure-evaluator.txt`,
|
||||
`sub_828012D0-fog-candidate.txt`, per-map texture reports and
|
||||
`visual-refresh-validation.json`. Owned data is not committed.
|
||||
|
||||
Offline checks cover shader composition, exporter fog boundaries, invalid refresh
|
||||
rollback and existing asset regressions, plus the windowless Rust rendering tests.
|
||||
These do not validate GPU execution, visual parity, frame time or the prior crash.
|
||||
|
||||
Final offline validation: 20 Python regression tests, six filtered Rust tests,
|
||||
three render batching tests, six composed shaders and the release build passed.
|
||||
All ten refreshed maps passed the offline reader and preserved geometry tails.
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Retain foliage in authored global world models outside district streams."""
|
||||
import json
|
||||
import hashlib
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from .environment import Collections, field, key_hash
|
||||
from .map_writer import write
|
||||
from .sky import _texture
|
||||
from tools.owned_game.big import BigArchive
|
||||
|
||||
|
||||
def texture_groups(raw, table, channels):
|
||||
"""Read binary channel GUIDs; display-name suffixes are not resource IDs."""
|
||||
groups = []
|
||||
for section in table.entries:
|
||||
if section.type_id != 0x00eb0005:
|
||||
continue
|
||||
o = section.f0
|
||||
h = struct.unpack_from('>8I', raw, o)
|
||||
if not h[1] or (h[4] - h[3]) != h[1] * 32:
|
||||
raise ValueError('Unexpected global model material layout')
|
||||
for j in range(h[1]):
|
||||
v = struct.unpack_from('>8I', raw, o+h[3]+j*32)
|
||||
at = o+v[0]
|
||||
kind = raw[at:raw.index(b'\0', at)].decode('ascii')
|
||||
if kind == 'Name':
|
||||
groups.append({})
|
||||
if kind in channels:
|
||||
groups[-1][kind] = (v[4] << 32) | v[5]
|
||||
return groups
|
||||
|
||||
|
||||
def convert(game_root, assets, converted):
|
||||
vendor = Path(__file__).resolve().parents[1] / 'vendor'
|
||||
sys.path.insert(0, str(vendor / 'utt'))
|
||||
sys.path.insert(0, str(vendor / 'university/tools/vanilla_map_extraction/tools'))
|
||||
import numpy as np
|
||||
import mdl_parser
|
||||
import rx2_parser
|
||||
import prepare_hawaiian_dream as prep
|
||||
from retail_lightmap_uv import decode_lightmap_uvs
|
||||
from retail_texture_decode import B5G6R5_FORMAT_ID, decode_b5g6r5
|
||||
|
||||
archive = BigArchive(game_root / 'data/big/miscload.big')
|
||||
entries = {e.path: e for e in archive.entries}
|
||||
collections = Collections(converted)
|
||||
output = assets / 'private/native-backdrops'
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
available = {p.stem.removeprefix('world') for p in (game_root/'data/content').glob('worldDIST_*.big')}
|
||||
count = 0
|
||||
for (cls, _), row in collections.rows.items():
|
||||
if cls != key_hash('world'):
|
||||
continue
|
||||
own_stream = next((v['data'] for k, v in row['fields'].items()
|
||||
if key_hash(k) == key_hash('WorldStream')), None)
|
||||
if own_stream not in available:
|
||||
continue
|
||||
fields, _ = collections.resolve('world', row['key'])
|
||||
# These global presentation resources are separate from WorldStream.
|
||||
if key_hash('Hash_951898F6C0FA6856') not in fields:
|
||||
continue
|
||||
model_name = field(fields, 'Hash_951898F6C0FA6856')
|
||||
if not model_name:
|
||||
continue
|
||||
texture_name = field(fields, 'Hash_CA5A157A65E75934')
|
||||
raw = archive.read(entries['data/content/' + model_name + '.rx2'])
|
||||
model = mdl_parser.parse_rx2(raw)
|
||||
groups, bindings = prep._bind_material_groups_by_guid(
|
||||
raw, prep._group_material_parameters(model.materials), len(model.meshes),
|
||||
allow_import_order_fallback=False)
|
||||
selected = [(i, prep._material_metadata(groups, i)) for i in range(len(model.meshes))]
|
||||
selected = [(i, m) for i, m in selected if m['shader_name'] in ('tree.default', 'animated.tree', 'ocean.reflection', 'environment.reflective_simple')]
|
||||
if not selected:
|
||||
continue
|
||||
textures = rx2_parser.parse_rx2(archive.read(entries['data/content/' + texture_name + '.rx2']))
|
||||
table = rx2_parser.RX2File(raw)
|
||||
table.parse()
|
||||
channel_groups = texture_groups(raw, table, prep.RETAIL_TEXTURE_CHANNELS)
|
||||
with tempfile.TemporaryDirectory(prefix='skate-backdrop-') as temporary:
|
||||
root = Path(temporary)
|
||||
arrays, meshes, exported_textures = {}, [], {}
|
||||
for i, material in selected:
|
||||
mesh = model.meshes[i]
|
||||
binding = bindings[i]
|
||||
meshes.append(dict(material, index=i, name=groups[i]['Name'][0],
|
||||
retail_material_guid=f"0x{binding['material_guid']:016X}",
|
||||
retail_material_handle=f"0x{binding['material_handle']:08X}",
|
||||
retail_material_group_index=binding['group_index'], source_offsets=mesh.source_offsets))
|
||||
arrays[f'vertices_{i}'] = mesh.vertices
|
||||
arrays[f'faces_{i}'] = mesh.faces
|
||||
arrays[f'uvs_{i}'] = mesh.uvs
|
||||
arrays[f'normals_{i}'] = mesh.normals
|
||||
lm = decode_lightmap_uvs(raw, vertex_buffer_offset=mesh.source_offsets['vertex_buffer'],
|
||||
vertex_count=mesh.vertex_count, vertex_stride=mesh.vertex_stride, attributes=mesh.attributes)
|
||||
if lm is not None:
|
||||
arrays[f'lightmap_uvs_{i}'] = lm.values
|
||||
for role, name in material['retail_texture_ids'].items():
|
||||
if name in exported_textures:
|
||||
continue
|
||||
texture = _texture(textures, channel_groups[binding['group_index']][role])
|
||||
rgba = texture.rgba
|
||||
if texture.fmt_id == B5G6R5_FORMAT_ID:
|
||||
rgba = decode_b5g6r5(textures.data[texture.data_offset:texture.data_offset+texture.buffer_size],
|
||||
texture.width, texture.height)
|
||||
path = name + '.rgba'
|
||||
(root/path).write_bytes(rgba)
|
||||
exported_textures[name] = dict(width=texture.width, height=texture.height, rgba=path)
|
||||
np.savez(root/'model.npz', **arrays)
|
||||
name = own_stream.removeprefix('DIST_')
|
||||
manifest = dict(map_name=name, district_name=own_stream, models=[dict(
|
||||
asset_id='0x'+hashlib.sha256(raw).hexdigest()[:16], npz='model.npz', meshes=meshes)],
|
||||
textures=exported_textures, normal_texture_policy=dict(excluded_texture_ids=[]), grind_splines=[],
|
||||
source_model=model_name, source_textures=texture_name, source_sha256=hashlib.sha256(raw).hexdigest())
|
||||
path = root/'manifest.json'
|
||||
path.write_text(json.dumps(manifest))
|
||||
write(path, output/(name+'.skate'), None, render_only=True)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
from .vlt import convert as convert_vlt
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--game-root', type=Path, required=True)
|
||||
parser.add_argument('--assets', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
with tempfile.TemporaryDirectory(prefix='skate-backdrop-db-') as work:
|
||||
database = BigArchive(args.game_root/'data/big/db.big')
|
||||
needed = {'skaterschema.bin', 'skaterschema.vlt', 'skatercollections.bin', 'skatercollections.vlt'}
|
||||
database.extract_entries([e for e in database.entries if Path(e.path).name.lower() in needed], Path(work))
|
||||
stem = Path(work)/'data/db'
|
||||
names = (Path(__file__).parent/'names.txt').read_text(encoding='utf-8').splitlines()
|
||||
converted = convert_vlt(stem/'skaterschema', stem/'skatercollections', names)
|
||||
print('Prepared', convert(args.game_root, args.assets, converted), 'authored foliage backdrops')
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Export authored character shader parameters and default SH coefficients."""
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from PIL import Image
|
||||
from pathlib import Path
|
||||
from .environment import Collections, key_hash
|
||||
|
||||
|
||||
def convert(models, private, converted):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'vendor/utt'))
|
||||
import mdl_parser
|
||||
collections = Collections(converted)
|
||||
result = {'materials': {}}
|
||||
recipe = json.loads((Path(__file__).resolve().parents[1] / 'default_skater_retail_manifest.json').read_text())
|
||||
components = {c['slot']: c for c in recipe['components']}
|
||||
for slot in sorted(models.iterdir()):
|
||||
if not slot.is_dir():
|
||||
continue
|
||||
files = list(slot.glob('*.rx2'))
|
||||
if len(files) != 1:
|
||||
raise ValueError('Ambiguous character lighting model: '+slot.name)
|
||||
model = mdl_parser.parse_rx2(files[0].read_bytes())
|
||||
shaders = {p.value for p in model.materials if p.kind == 'AttribulatorMaterialName'}
|
||||
if len(shaders) != 1:
|
||||
raise ValueError('Ambiguous character shader: '+slot.name)
|
||||
shader = shaders.pop()
|
||||
family, key = shader.split('.', 1)
|
||||
fields, _ = collections.resolve('material_'+family, key)
|
||||
rows = fields[key_hash('m_params')]['array']['items']
|
||||
result['materials']['Retail_'+slot.name] = dict(shader=shader,
|
||||
params=[struct.unpack('>4f', bytes.fromhex(row)) for row in rows])
|
||||
specular = components[slot.name]['textures'].get('specular')
|
||||
if specular:
|
||||
# The native skin shader consumes R, not luminance or roughness.
|
||||
source = private / 'default_skater/textures/decoded' / (specular+'.png')
|
||||
destination = private / 'native-character' / (slot.name+'_specular.png')
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.open(source) as image:
|
||||
image.convert('RGB').getchannel('R').save(destination)
|
||||
result['materials']['Retail_'+slot.name]['specular'] = 'private/native-character/'+destination.name
|
||||
fields, _ = collections.resolve('Hash_6A2B5946514B8490', 'freeskate')
|
||||
result['default_sh'] = [struct.unpack('>3f', bytes.fromhex(row))
|
||||
for row in fields[key_hash('Hash_F45DFCAB6EC195CD')]['array']['items']]
|
||||
(private/'character-lighting.json').write_text(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--models', type=Path, required=True)
|
||||
p.add_argument('--private', type=Path, required=True)
|
||||
p.add_argument('--collections', type=Path, required=True)
|
||||
a = p.parse_args()
|
||||
convert(a.models, a.private, json.loads(a.collections.read_text()))
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Export authored district DMO locators and their referenced model geometry.
|
||||
|
||||
This preserves initial placement, not DMO simulation. Native 0x825876D0 reads
|
||||
the locator matrix and template ID at +112; RX2 EB001D records are 128 bytes.
|
||||
No name matching, random placement, ground snapping or collision synthesis.
|
||||
"""
|
||||
import argparse
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from .map_writer import write
|
||||
from tools.owned_game.big import BigArchive
|
||||
|
||||
|
||||
def sections(raw):
|
||||
if raw[:7] != b'\x89RW4xb2':
|
||||
raise ValueError('Expected Xbox RX2')
|
||||
count = struct.unpack_from('>I', raw, 32)[0]
|
||||
table = struct.unpack_from('>I', raw, 48)[0]
|
||||
if table + count*24 > len(raw):
|
||||
raise ValueError('Truncated RX2 section table')
|
||||
return [struct.unpack_from('>6I', raw, table+i*24) for i in range(count)]
|
||||
|
||||
|
||||
def matrix(raw, at):
|
||||
value = np.array(struct.unpack_from('>16f', raw, at)).reshape(4, 4)
|
||||
if not np.isfinite(value).all() or not np.allclose(value[:, 3], [0, 0, 0, 1], atol=1e-5):
|
||||
raise ValueError('Invalid DMO affine matrix')
|
||||
if abs(np.linalg.det(value[:3, :3])) < 1e-8:
|
||||
raise ValueError('Singular DMO matrix')
|
||||
return value
|
||||
|
||||
|
||||
def records(raw, kind, stride):
|
||||
for offset, _, size, _, _, type_id in sections(raw):
|
||||
if type_id != kind:
|
||||
continue
|
||||
if offset+size > len(raw) or size < 32:
|
||||
raise ValueError('Invalid DMO section extent')
|
||||
count = struct.unpack_from('>I', raw, offset+4)[0]
|
||||
start, strings = struct.unpack_from('>2I', raw, offset+12)
|
||||
if start != 32 or strings != start+count*stride or strings > size:
|
||||
raise ValueError('Unexpected DMO record layout')
|
||||
for index in range(count):
|
||||
yield offset, offset+start+index*stride, size
|
||||
|
||||
|
||||
def locators(raw):
|
||||
result = []
|
||||
for base, at, size in records(raw, 0xEB001D, 128):
|
||||
name_offset = struct.unpack_from('>I', raw, at+124)[0]
|
||||
if not 32 <= name_offset < size:
|
||||
raise ValueError('Invalid DMO locator name offset')
|
||||
end = raw.index(b'\0', base+name_offset, base+size)
|
||||
result.append(dict(instance_id=f'{struct.unpack_from(">Q", raw, at+96)[0]:016X}',
|
||||
locator_id=f'{struct.unpack_from(">Q", raw, at+104)[0]:016X}',
|
||||
template_id=f'{struct.unpack_from(">Q", raw, at+112)[0]:016X}',
|
||||
matrix=matrix(raw, at).tolist(), name=raw[base+name_offset:end].decode('utf-8'),
|
||||
source_offset=at, bounds=np.array(struct.unpack_from('>8f', raw, at+64)).reshape(2, 4)[:, :3].tolist()))
|
||||
return result
|
||||
|
||||
|
||||
def template_meshes(raw):
|
||||
"""Resolve EB000D tInstance -> EB0001 model -> EB0023 mesh -> declaration."""
|
||||
table = sections(raw)
|
||||
result = {}
|
||||
for _, at, _ in records(raw, 0xEB000D, 160):
|
||||
key = f'{struct.unpack_from(">Q", raw, at+104)[0]:016X}'
|
||||
index = struct.unpack_from('>I', raw, at+128)[0]
|
||||
if index >= len(table) or table[index][5] != 0xEB0001:
|
||||
raise ValueError('Unresolved DMO model reference')
|
||||
base, _, size, _, _, _ = table[index]
|
||||
start = struct.unpack_from('>I', raw, base+36)[0]
|
||||
count = struct.unpack_from('>H', raw, base+48)[0]
|
||||
if base+size > len(raw) or start+count*8 > size:
|
||||
raise ValueError('Invalid DMO model mesh table')
|
||||
mesh_info = []
|
||||
for j in range(count):
|
||||
mesh_index = struct.unpack_from('>I', raw, base+start+j*8)[0]
|
||||
if mesh_index >= len(table) or table[mesh_index][5] != 0xEB0023:
|
||||
raise ValueError('Unresolved DMO mesh reference')
|
||||
descriptor = struct.unpack_from('>I', raw, table[mesh_index][0]+40)[0]
|
||||
if descriptor >= len(table) or table[descriptor][5] != 0x200E9:
|
||||
raise ValueError('Unresolved DMO vertex declaration')
|
||||
mesh_info.append(table[descriptor][0])
|
||||
if key in result:
|
||||
raise ValueError('Duplicate DMO template ID')
|
||||
result[key] = dict(mesh_info=mesh_info, matrix=matrix(raw, at),
|
||||
model_matrix=matrix(raw, base+struct.unpack_from('>I', raw, base+32)[0]))
|
||||
return result
|
||||
|
||||
|
||||
def transform_mesh(arrays, index, transform):
|
||||
values = {key: np.array(value, copy=True) for key, value in arrays.items() if key.endswith('_'+str(index))}
|
||||
key = f'vertices_{index}'
|
||||
values[key] = (values[key] @ transform[:3, :3] + transform[3, :3]).astype('f4')
|
||||
normal_matrix = np.linalg.inv(transform[:3, :3]).T
|
||||
for prefix in ('normals', 'retail_normals'):
|
||||
key = f'{prefix}_{index}'
|
||||
if key in values:
|
||||
n = values[key] @ normal_matrix
|
||||
values[key] = (n/np.maximum(np.linalg.norm(n, axis=1, keepdims=True), 1e-20)).astype('f4')
|
||||
if np.linalg.det(transform[:3, :3]) < 0:
|
||||
values[f'faces_{index}'] = values[f'faces_{index}'][:, [0, 2, 1]]
|
||||
return values
|
||||
|
||||
|
||||
def catalog(cache_roots):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'vendor/utt'))
|
||||
import rx2_parser
|
||||
from .backdrop import texture_groups
|
||||
templates, textures = {}, {}
|
||||
for root in cache_roots:
|
||||
m = json.loads((root/'manifest.json').read_text())
|
||||
for key, value in m['textures'].items():
|
||||
entry = dict(value)
|
||||
for path_key in ('rgba', 'png'):
|
||||
if path_key in entry:
|
||||
entry[path_key] = str((root/entry[path_key]).resolve())
|
||||
if key in textures and Path(textures[key]['rgba']).read_bytes() != Path(entry['rgba']).read_bytes():
|
||||
raise ValueError('Conflicting DMO texture '+key)
|
||||
textures[key] = entry
|
||||
for model in m['models']:
|
||||
raw = (root/model['rx2']).read_bytes()
|
||||
table = rx2_parser.RX2File(raw)
|
||||
table.parse()
|
||||
roles = set(role for mesh in model['meshes'] for role in mesh['retail_texture_ids'])
|
||||
bindings = texture_groups(raw, table, roles)
|
||||
for key, definition in template_meshes(raw).items():
|
||||
meshes = [copy.deepcopy(mesh) for mesh in model['meshes'] if mesh['source_offsets']['mesh_info'] in definition['mesh_info']]
|
||||
for mesh in meshes:
|
||||
# DMO resources have a high-bit namespace that display names
|
||||
# omit. Bind the binary channel GUID; never strip that bit.
|
||||
group = bindings[mesh['retail_material_group_index']]
|
||||
mesh['retail_texture_ids'] = {role:f'0x{group[role]:016x}' for role in mesh['retail_texture_ids']}
|
||||
mesh['texture_id'] = mesh['retail_texture_ids'].get('diffuse', mesh['retail_texture_ids'].get('transparent'))
|
||||
if len(meshes) != len(definition['mesh_info']):
|
||||
raise ValueError('DMO model geometry omitted '+key)
|
||||
if key in templates:
|
||||
raise ValueError('Conflicting DMO template '+key)
|
||||
templates[key] = dict(definition, meshes=meshes, npz=root/model['npz'], asset_id=model['asset_id'])
|
||||
return templates, textures
|
||||
|
||||
|
||||
def export(manifest_path, cache_roots, output):
|
||||
district = json.loads(manifest_path.read_text())
|
||||
templates, textures = catalog(cache_roots)
|
||||
placements = {}
|
||||
for source in district['simulation_assets']:
|
||||
raw = (manifest_path.parent/source['rx2']).read_bytes()
|
||||
for item in locators(raw):
|
||||
item['source_asset'] = source['asset_id']
|
||||
item['source_sha256'] = hashlib.sha256(raw).hexdigest()
|
||||
key = item['instance_id']
|
||||
if key in placements and placements[key] != item:
|
||||
raise ValueError('Conflicting DMO locator '+key)
|
||||
placements[key] = item
|
||||
report = dict(map=district['map_name'], instances=[], unresolved=[], simulation='initial placement only')
|
||||
with tempfile.TemporaryDirectory(prefix='skate-dmo-') as work:
|
||||
root = Path(work); models = []; used = set()
|
||||
for number, item in enumerate(placements.values()):
|
||||
template = templates.get(item['template_id'])
|
||||
if template is None:
|
||||
report['unresolved'].append(item)
|
||||
continue
|
||||
transform = template['model_matrix'] @ template['matrix'] @ np.array(item['matrix'])
|
||||
arrays = {}; meshes = copy.deepcopy(template['meshes'])
|
||||
with np.load(template['npz'], allow_pickle=False) as original:
|
||||
for mesh in meshes:
|
||||
arrays.update(transform_mesh(original, mesh['index'], transform))
|
||||
used.update(mesh['retail_texture_ids'].values())
|
||||
path = f'{number}.npz'; np.savez(root/path, **arrays)
|
||||
models.append(dict(asset_id=item['instance_id'], meshes=meshes, npz=path))
|
||||
report['instances'].append(dict(item, model_asset=template['asset_id'], meshes=len(meshes)))
|
||||
if models:
|
||||
manifest = dict(map_name=district['map_name'], district_name=district['district_name'],
|
||||
models=models, textures={key:textures[key] for key in used},
|
||||
normal_texture_policy=dict(excluded_texture_ids=[]), grind_splines=[])
|
||||
(root/'manifest.json').write_text(json.dumps(manifest))
|
||||
# Publish only a complete package; preserve the previous one on failure.
|
||||
temporary = output.with_suffix('.skate.new')
|
||||
try:
|
||||
write(root/'manifest.json', temporary, None, render_only=True)
|
||||
temporary.replace(output)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
else:
|
||||
output.unlink(missing_ok=True)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.with_suffix('.json').write_text(json.dumps(report, indent=2))
|
||||
return len(report['instances']), len(report['unresolved'])
|
||||
|
||||
|
||||
def prepare_catalog(game_root, work):
|
||||
vendor = Path(__file__).resolve().parents[1]/'vendor'
|
||||
sys.path.insert(0, str(vendor/'university/tools/vanilla_map_extraction/tools'))
|
||||
from prepare_hawaiian_dream import prepare
|
||||
archive = BigArchive(game_root/'data/content/worlddmo.big')
|
||||
archive.extract_entries(archive.entries, work/'raw')
|
||||
roots = []
|
||||
for stream in (work/'raw/data/content/world/dmo').iterdir():
|
||||
root = work/'cache'/stream.name
|
||||
prepare(stream_directory=stream, output_root=root, utt_root=vendor/'utt',
|
||||
district_name=stream.name, map_name=stream.name, raw_texture_cache=True)
|
||||
roots.append(root)
|
||||
return roots
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--manifest', type=Path, required=True)
|
||||
p.add_argument('--cache', type=Path, nargs='+', required=True)
|
||||
p.add_argument('--output', type=Path, required=True)
|
||||
a = p.parse_args()
|
||||
print('DMO placed/unresolved:', export(a.manifest, a.cache, a.output))
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Resolve authored world/environment references without inventing frame rows."""
|
||||
import math
|
||||
import struct
|
||||
from .vlt import hash64
|
||||
|
||||
# Names recovered from schema/debug strings; unnamed fields keep their hashes.
|
||||
NAMES = ('world', 'render_locations', 'material_sky', 'material_fog',
|
||||
'sun_position', 'm_params', 'fog_far', 'fog_near', 'fog_colour',
|
||||
'fog_power', 'fog_max')
|
||||
|
||||
|
||||
def key_hash(name):
|
||||
return int(name[5:], 16) if name.startswith('Hash_') else hash64(name)
|
||||
|
||||
|
||||
class Collections:
|
||||
def __init__(self, data):
|
||||
self.rows = {(key_hash(r['class']), key_hash(r['key'])): r
|
||||
for r in data['collections']}
|
||||
|
||||
def resolve(self, cls, key):
|
||||
cls = key_hash(cls) if isinstance(cls, str) else cls
|
||||
key = key_hash(key) if isinstance(key, str) else key
|
||||
fields, chain, seen = {}, [], set()
|
||||
while key:
|
||||
if key in seen:
|
||||
raise ValueError('Cyclic environment inheritance')
|
||||
seen.add(key)
|
||||
row = self.rows[cls, key]
|
||||
chain.append(row['key'])
|
||||
for name, value in row['fields'].items():
|
||||
fields.setdefault(key_hash(name), value)
|
||||
key = key_hash(row['parent'])
|
||||
return fields, chain
|
||||
|
||||
|
||||
def field(fields, name):
|
||||
return fields[key_hash(name)]['data']
|
||||
|
||||
|
||||
def floats(raw, count):
|
||||
values = struct.unpack('>' + 'f' * count, bytes.fromhex(raw))
|
||||
if not all(math.isfinite(v) for v in values):
|
||||
raise ValueError('Non-finite environment value')
|
||||
return list(values)
|
||||
|
||||
|
||||
def world_environment(collections, world_key):
|
||||
world, world_chain = collections.resolve('world', world_key)
|
||||
location_key = struct.unpack('>Q', bytes.fromhex(
|
||||
field(world, 'Hash_7E23D10785C43717'))[:8])[0]
|
||||
location, location_chain = collections.resolve('render_locations', location_key)
|
||||
sun = floats(field(location, 'sun_position'), 3)
|
||||
length = math.sqrt(sum(v*v for v in sun))
|
||||
if length < 1e-6:
|
||||
raise ValueError('Invalid authored sun direction')
|
||||
fog_cls, fog_key, _ = struct.unpack('>3Q', bytes.fromhex(field(location, 'material_fog')))
|
||||
fog, fog_chain = collections.resolve(fog_cls, fog_key)
|
||||
return {
|
||||
'world_chain': world_chain, 'location_chain': location_chain,
|
||||
'sun_direction': [v/length for v in sun],
|
||||
'anchor_height': floats(field(location, 'Hash_2E9AAECD1C29F81F'), 1)[0],
|
||||
'sky_model': field(world, 'Hash_C7A0A84F018E87BA'),
|
||||
'sky_textures': field(world, 'Hash_2A0BF629355AFB15'),
|
||||
# Keep the authored values alongside the derived native shader rows.
|
||||
'fog': {'chain': fog_chain, **{n: floats(field(fog, n), 4 if n == 'fog_colour' else 1)
|
||||
for n in ('fog_near', 'fog_far', 'fog_colour', 'fog_power', 'fog_max')}},
|
||||
}
|
||||
|
||||
|
||||
def sky_parameters(collections, shader):
|
||||
effect, key = shader.split('.', 1)
|
||||
if effect != 'sky':
|
||||
raise ValueError('Not a sky material: ' + shader)
|
||||
fields, chain = collections.resolve('material_sky', key)
|
||||
params = fields[hash64('m_params')].get('array')
|
||||
if not params or not params['items']:
|
||||
raise ValueError('Sky parameters require complete VLT array payloads')
|
||||
row = floats(params['items'][0], 4)
|
||||
if row[0] <= 0 or row[1] < 0:
|
||||
raise ValueError('Invalid authored sky parameters')
|
||||
return {'material_chain': chain, 'sun_scale': row[0], 'multiplier': row[1]}
|
||||
|
||||
|
||||
def fog_parameters(fog):
|
||||
"""TU3 0x828012D0: schema offsets 20/28/16/24 -> ramp and colour.
|
||||
|
||||
0x82F825F0 initializes the negative-one vector used for colour alpha.
|
||||
Colour RGB is already linear here; do not square it again.
|
||||
"""
|
||||
near, far, power, maximum = (fog[n][0] for n in
|
||||
('fog_near', 'fog_far', 'fog_power', 'fog_max'))
|
||||
colour = fog['fog_colour']
|
||||
if (len(colour) != 4 or not all(math.isfinite(v) for v in
|
||||
[near, far, power, maximum, *colour]) or far <= near or near < 0
|
||||
or power <= 0 or not 0 <= maximum <= 1):
|
||||
raise ValueError('Invalid authored fog range/colour')
|
||||
return {'ramp': [1 / (far - near), -near / (far - near), power, 0],
|
||||
'colour': [v * maximum for v in colour[:3]] + [-maximum]}
|
||||
@@ -120,6 +120,12 @@ def convert_map(archive,work,maps,stage,game_exe,log,report):
|
||||
build_archive(manifest_path,collision)
|
||||
final=maps/(label+'.skate')
|
||||
write_map(manifest_path,final,collision,report)
|
||||
from .dynamic_props import export as write_props
|
||||
caches=list((work/'dmo/cache').glob('DMO_*'))
|
||||
if not caches:
|
||||
raise RuntimeError('Missing prepared DMO catalog')
|
||||
placed, unresolved=write_props(manifest_path,caches,stage/'assets/private/native-props'/(label+'.skate'))
|
||||
report(f'{label}: placed {placed} authored DMO instances, {unresolved} unresolved templates')
|
||||
report('Checking converted map: '+label)
|
||||
run([game_exe,'--assets',stage/'assets','--map',final,'--check-assets'],log,report)
|
||||
entry={'name':label,'path':'maps/'+final.name,'sha256':digest(final)}
|
||||
@@ -188,13 +194,26 @@ def install(iso,base,game_exe,report,game_root=None):
|
||||
report('Building the skater model and rig')
|
||||
from .character_glb import convert as write_character
|
||||
write_character(character/'selected/models',private,manifest)
|
||||
from .character_lighting import convert as write_character_lighting
|
||||
write_character_lighting(character/'selected/models',private,converted)
|
||||
game_manifest={'version':1,'character_scene':'private/skater.glb','initial_animation':'R_IDLE_HCOM_000',
|
||||
'action_graph':'private/stock/data/state/ActionGraph_OnBoard.stategraph',
|
||||
'motion_graph':'private/stock/data/state/MotionGraph_OnBoard.stategraph'}
|
||||
(private/'game.json').write_text(json.dumps(game_manifest),encoding='utf-8')
|
||||
report('Preparing retail sky domes')
|
||||
from .sky import convert as write_skies
|
||||
write_skies(game_root,stage/'assets')
|
||||
write_skies(game_root,stage/'assets',converted)
|
||||
from .render_parameters import convert as write_render_parameters
|
||||
write_render_parameters(stage/'assets',converted)
|
||||
report('Extracting original travel destinations and location names')
|
||||
from .teleports import convert as write_teleports
|
||||
write_teleports(game_root,stage/'assets',converted)
|
||||
report('Preparing global foliage backdrops')
|
||||
from .backdrop import convert as write_backdrops
|
||||
write_backdrops(game_root,stage/'assets',converted)
|
||||
report('Preparing authored movable-object models')
|
||||
from .dynamic_props import prepare_catalog
|
||||
prepare_catalog(game_root,work/'dmo')
|
||||
report('Validating skater, input and animation data')
|
||||
run([game_exe,'--assets',stage/'assets','--test-world','--check-assets'],log,report)
|
||||
archives=list((game_root/'data/content').glob('worldDIST_*.big'))
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Export retail RWOBJECTTYPE_IRRADIANCEDATA (0xEB0024) without resampling."""
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def decode(raw):
|
||||
if raw[:7] != b'\x89RW4xb2':
|
||||
raise ValueError('Not an Xbox RX2 resource')
|
||||
count, table = struct.unpack_from('>I', raw, 32)[0], struct.unpack_from('>I', raw, 48)[0]
|
||||
groups = []
|
||||
for i in range(count):
|
||||
offset, _, size, _, _, kind = struct.unpack_from('>6I', raw, table + i*24)
|
||||
if kind != 0xEB0024:
|
||||
continue
|
||||
n, start = struct.unpack_from('>2I', raw, offset)
|
||||
if start < 8 or start+n*160 > size or offset+size > len(raw):
|
||||
raise ValueError('Invalid irradiance record extent')
|
||||
records = []
|
||||
for j in range(n):
|
||||
values = struct.unpack_from('>39fI', raw, offset+start+j*160)
|
||||
if not all(math.isfinite(x) for x in values[:39]):
|
||||
raise ValueError('Non-finite irradiance sample')
|
||||
records.append(struct.pack('<39fI', *values))
|
||||
if records:
|
||||
groups.append(records)
|
||||
return groups
|
||||
|
||||
|
||||
def write(manifest_path, output):
|
||||
manifest = json.loads(manifest_path.read_text())
|
||||
groups = []
|
||||
for asset in manifest['other_presentation_assets']:
|
||||
groups.extend(decode((manifest_path.parent / asset['rx2']).read_bytes()))
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output.open('wb') as f:
|
||||
f.write(b'SHP1' + struct.pack('<I', len(groups)))
|
||||
for records in groups:
|
||||
f.write(struct.pack('<I', len(records)))
|
||||
f.write(b''.join(records))
|
||||
return sum(map(len, groups))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--manifest', type=Path, required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
print('Exported', write(args.manifest, args.output), 'irradiance samples')
|
||||
@@ -59,7 +59,7 @@ def spawn_point(manifest,root):
|
||||
if best is None:raise ValueError('No supported spawn surface in '+manifest['map_name'])
|
||||
return best[1]
|
||||
|
||||
def write(manifest_path,output,collision,report=lambda _:None):
|
||||
def write(manifest_path,output,collision,report=lambda _:None, *, render_only=False):
|
||||
root=manifest_path.parent;m=json.loads(manifest_path.read_text());textures=m['textures']
|
||||
ids={name:i+1 for i,name in enumerate(sorted(textures))}
|
||||
excluded=set(m['normal_texture_policy']['excluded_texture_ids'])
|
||||
@@ -115,7 +115,7 @@ def write(manifest_path,output,collision,report=lambda _:None):
|
||||
record['frame'][:,:3]=np.rint(np.clip(binormal,-1,1)*127).astype('i1');record['frame'][:,3]=np.rint(sign*127).astype('i1')
|
||||
if not np.isfinite(pos).all():raise ValueError('Non-finite map geometry')
|
||||
vertices.write(record.tobytes());indices.write((faces+nv).astype('<u4').tobytes());nv+=len(pos);ni+=faces.size;nm+=1
|
||||
report('Selecting starting position: '+m['map_name']);spawn=spawn_point(m,root)
|
||||
report('Selecting starting position: '+m['map_name']);spawn=(0.,0.,0.) if render_only else spawn_point(m,root)
|
||||
# Match the supplied exporter's environment defaults. Native sky shaders
|
||||
# remain a separate runtime feature; no geometry is synthesized here.
|
||||
environment=[.10,.36,.75,.64,.82,1.,.18,.24,.30,0.,11.,0.,18.,0.,
|
||||
@@ -145,10 +145,15 @@ def write(manifest_path,output,collision,report=lambda _:None):
|
||||
raw=bytes.fromhex(segment)
|
||||
if len(raw)!=120:raise ValueError('Invalid native spline segment')
|
||||
f.write(np.frombuffer(raw,dtype='>u4').astype('<u4').tobytes())
|
||||
u(f,2)
|
||||
for tag,data in [(b'RWCM',collision.read_bytes()),(b'WMET',json.dumps(m,separators=(',',':')).encode())]:
|
||||
extensions=[(b'WMET',json.dumps(m,separators=(',',':')).encode())]
|
||||
if not render_only:extensions.insert(0,(b'RWCM',collision.read_bytes()))
|
||||
u(f,len(extensions))
|
||||
for tag,data in extensions:
|
||||
f.write(tag);u(f,1,len(data));stored(f,data)
|
||||
report('Map written: '+m['map_name'])
|
||||
if not render_only:
|
||||
from tools.asset_pipeline.irradiance import write as write_irradiance
|
||||
write_irradiance(manifest_path, output.with_suffix('.irradiance'))
|
||||
|
||||
if __name__=='__main__':
|
||||
import argparse
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Extract TU3's built-in PCA ocean animation from an owned mapped image.
|
||||
|
||||
Addresses are corroborated by cPCAWaterAnimationData::Init (827905B0) and
|
||||
its update routine (82790858). No game content is embedded in this tool.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def extract(image, output, base=0x82000000):
|
||||
data = Path(image).read_bytes()
|
||||
identity = hashlib.sha256(data).hexdigest()
|
||||
if identity != 'f4aa113eb541bfba03dbc108cf5ab43f58c965b20fa3b82f9c40938a0ad841c4':
|
||||
raise ValueError('Unsupported mapped executable build for PCA table extraction')
|
||||
frames = []
|
||||
for frame in range(30):
|
||||
mean = struct.unpack_from('>3f', data, 0x830118D8-base+frame*12)
|
||||
weights = struct.unpack_from('>24f', data, 0x83011A40-base+frame*96)
|
||||
if not all(math.isfinite(v) for v in (*mean, *weights)):
|
||||
raise ValueError('Non-finite ocean PCA table')
|
||||
# Shader model order: mean X/Z/Y and weight pairs R/B/G.
|
||||
rows = [[mean[0]/255., mean[2]/255., mean[1]/255., 0.]]
|
||||
for start in (0, 4, 16, 20, 8, 12):
|
||||
rows.append([v/255. for v in weights[start:start+4]])
|
||||
frames.append(rows)
|
||||
output = Path(output)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(dict(source_sha256=identity, hz=30., frames=frames)), encoding='utf-8')
|
||||
return len(frames)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--image', type=Path, required=True)
|
||||
p.add_argument('--output', type=Path, required=True)
|
||||
a = p.parse_args()
|
||||
print('Extracted', extract(a.image, a.output), 'ocean PCA frames')
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Refresh SKATE14 texture payloads from a newly decoded RX2 manifest cache.
|
||||
|
||||
Material, geometry, collision and metadata bytes remain unchanged. Publication
|
||||
is atomic and never overwrites the input map. No renderer is launched.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import tempfile
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
|
||||
|
||||
def refresh(manifest, source, output):
|
||||
manifest, source, output = map(Path, (manifest, source, output))
|
||||
if source.resolve() == output.resolve():
|
||||
raise ValueError('Refresh requires a separate output map')
|
||||
data = json.loads(manifest.read_text())
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = None
|
||||
try:
|
||||
with source.open('rb') as src:
|
||||
def read(n):
|
||||
value = src.read(n)
|
||||
if len(value) != n:
|
||||
raise ValueError('Truncated SKATE14 package')
|
||||
return value
|
||||
def uint():
|
||||
return struct.unpack('<I', read(4))[0]
|
||||
def text():
|
||||
return read(uint()).decode('utf-8')
|
||||
if read(8) != b'SKATE14\0' or uint() != 0x12345678:
|
||||
raise ValueError('Texture refresh requires SKATE14')
|
||||
text()
|
||||
read(49 * 4)
|
||||
counts = [uint() for _ in range(9)]
|
||||
for _ in range(counts[0]):
|
||||
text()
|
||||
read(4 + 7*4 + 8 + 4 + 16 + 4 + 16)
|
||||
if uint():
|
||||
read(16)
|
||||
text()
|
||||
read(8)
|
||||
for _ in range(uint()):
|
||||
text()
|
||||
read(16)
|
||||
for _ in range(uint()):
|
||||
text()
|
||||
for _ in range(uint()):
|
||||
text()
|
||||
text()
|
||||
prefix_end = src.tell()
|
||||
src.seek(0)
|
||||
prefix = read(prefix_end)
|
||||
changed = []
|
||||
with tempfile.NamedTemporaryFile(dir=output.parent, suffix='.skate.tmp', delete=False) as dst:
|
||||
temporary = Path(dst.name)
|
||||
dst.write(prefix)
|
||||
for _ in range(counts[1]):
|
||||
start = src.tell()
|
||||
name = text()
|
||||
width, height, _space = uint(), uint(), uint()
|
||||
data_start = src.tell()
|
||||
method, size = uint(), uint()
|
||||
packed = read(size)
|
||||
if method not in (0, 1):
|
||||
raise ValueError('Unsupported texture compression')
|
||||
old = zlib.decompress(packed) if method else packed
|
||||
end = src.tell()
|
||||
entry = data['textures'][name]
|
||||
if (width, height) != (entry['width'], entry['height']):
|
||||
raise ValueError('Texture dimensions changed: ' + name)
|
||||
cache = (manifest.parent / entry['rgba']).read_bytes()
|
||||
if len(cache) != width*height*4 or len(old) != len(cache):
|
||||
raise ValueError('Texture byte count mismatch: ' + name)
|
||||
array = np.frombuffer(cache, np.uint8).reshape(height, width, 4)
|
||||
new = (array if entry.get('cube_faces') == 6 else array[::-1]).tobytes()
|
||||
src.seek(start)
|
||||
dst.write(read(data_start-start))
|
||||
src.seek(end)
|
||||
if new != old:
|
||||
replacement = zlib.compress(new, 1)
|
||||
dst.write(struct.pack('<II', 1, len(replacement)))
|
||||
dst.write(replacement)
|
||||
changed.append(dict(name=name, width=width, height=height, format=entry.get('format'),
|
||||
changed_pixels=int(np.any(np.frombuffer(old, np.uint8).reshape(-1, 4) !=
|
||||
np.frombuffer(new, np.uint8).reshape(-1, 4), axis=1).sum())))
|
||||
else:
|
||||
dst.write(struct.pack('<II', method, size))
|
||||
dst.write(packed)
|
||||
old_tail_offset, new_tail_offset = src.tell(), dst.tell()
|
||||
tail_hash = hashlib.sha256()
|
||||
while chunk := src.read(1024*1024):
|
||||
tail_hash.update(chunk)
|
||||
dst.write(chunk)
|
||||
os.replace(temporary, output)
|
||||
temporary = None
|
||||
return dict(changed=changed, count=len(changed), textures=counts[1],
|
||||
prefix_sha256=hashlib.sha256(prefix).hexdigest(), tail_sha256=tail_hash.hexdigest(),
|
||||
old_tail_offset=old_tail_offset, new_tail_offset=new_tail_offset)
|
||||
finally:
|
||||
if temporary is not None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Export complete authored parameter rows for additional presentation shaders."""
|
||||
import json
|
||||
import struct
|
||||
from .environment import Collections, key_hash
|
||||
|
||||
|
||||
def exposure_parameters(collections, location):
|
||||
fields, chain = collections.resolve('render_locations', location)
|
||||
key = int(fields[key_hash('Hash_C345507C4B9B6F62')]['data'][:16], 16)
|
||||
rendering, rendering_chain = collections.resolve('rendering', key)
|
||||
values = {n: struct.unpack('>f', bytes.fromhex(rendering[key_hash('auto_exposure_'+n)]['data']))[0]
|
||||
for n in ('target_luminance', 'min', 'max', 'damping')}
|
||||
return dict(values, location_chain=chain, rendering_chain=rendering_chain)
|
||||
|
||||
|
||||
def convert(assets, converted):
|
||||
collections = Collections(converted)
|
||||
result = {}
|
||||
aliases = ('default', 'reflection', 'backlituvscroll', 'transparent',
|
||||
'flowing', 'flowingalpha', 'alpha', 'skatepark', 'videoscreen')
|
||||
for (cls, key), row in collections.rows.items():
|
||||
for family in ('water', 'ocean', 'incandescent'):
|
||||
if cls != key_hash('material_' + family):
|
||||
continue
|
||||
fields, _ = collections.resolve(cls, key)
|
||||
raw = fields.get(key_hash('m_params'), {}).get('array', {}).get('items', [])
|
||||
if not raw:
|
||||
continue
|
||||
name = next((n for n in aliases if key_hash(n) == key), row['key'])
|
||||
result[family + '.' + name] = [struct.unpack('>4f', bytes.fromhex(r)) for r in raw]
|
||||
path = assets / 'private/render-parameters.json'
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(result), encoding='utf-8')
|
||||
fields, _ = collections.resolve('Hash_1FFDC8E3ACA07C1F', 'auto_exposure')
|
||||
exposure = {n: struct.unpack('>f', bytes.fromhex(fields[key_hash('auto_exposure_'+n)]['data']))[0]
|
||||
for n in ('target_luminance', 'min', 'max', 'damping')}
|
||||
(assets / 'private/exposure.json').write_text(json.dumps(exposure), encoding='utf-8')
|
||||
profiles = {}
|
||||
for (cls, key), row in collections.rows.items():
|
||||
if cls != key_hash('world'):
|
||||
continue
|
||||
own_stream = next((v['data'] for k, v in row['fields'].items() if key_hash(k) == key_hash('WorldStream')), None)
|
||||
if not own_stream or not own_stream.startswith('DIST_'):
|
||||
continue
|
||||
world, _ = collections.resolve(cls, key)
|
||||
location = int(world[key_hash('Hash_7E23D10785C43717')]['data'][:16], 16)
|
||||
profiles[own_stream.removeprefix('DIST_').casefold()] = exposure_parameters(collections, location)
|
||||
(assets / 'private/exposure-profiles.json').write_text(json.dumps(profiles, indent=2), encoding='utf-8')
|
||||
return len(result)
|
||||
@@ -31,6 +31,8 @@ def _retail_shader_family(shader_name: str) -> int:
|
||||
return 12
|
||||
if shader.startswith("water.flowing"):
|
||||
return 30
|
||||
if shader in ("water.default", "water.alpha", "water.skatepark"):
|
||||
return 33
|
||||
if shader.startswith("ocean.default"):
|
||||
return 31
|
||||
if shader.startswith("ocean.reflection"):
|
||||
|
||||
+108
-39
@@ -1,43 +1,112 @@
|
||||
"""Extract retail sky geometry and textures through RX2 GUID bindings."""
|
||||
import json,struct,sys
|
||||
"""Extract authored retail skies through world references and RX2 GUID bindings."""
|
||||
import json
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from tools.owned_game.big import BigArchive
|
||||
from .environment import Collections, NAMES, field, key_hash, sky_parameters, world_environment, fog_parameters
|
||||
|
||||
def convert(game_root, assets):
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'vendor/utt'))
|
||||
import mdl_parser,rx2_parser
|
||||
archive=BigArchive(game_root/'data/big/miscload.big')
|
||||
entries={e.path:e for e in archive.entries}
|
||||
output=assets/'private/native-skies';output.mkdir(parents=True,exist_ok=True)
|
||||
for name,suffix in [('University',''),('DownTown','_downtown'),('Industrial','_industrial')]:
|
||||
prefix='data/content/world/models/DIST_skybox'+suffix
|
||||
raw=archive.read(entries[prefix+'.rx2'])
|
||||
model=mdl_parser.parse_rx2(raw)
|
||||
table=rx2_parser.RX2File(raw);table.parse()
|
||||
diffuse=None
|
||||
for section in table.entries:
|
||||
if section.type_id!=0x00eb0005:continue
|
||||
o=section.f0;h=struct.unpack_from('>8I',raw,o)
|
||||
if (h[4]-h[3])//h[1]!=32:raise ValueError('Unexpected sky material layout')
|
||||
for i in range(h[1]):
|
||||
v=struct.unpack_from('>8I',raw,o+h[3]+i*32)
|
||||
at=o+v[0];kind=raw[at:raw.index(b'\0',at)]
|
||||
if kind==b'diffuse':diffuse=(v[4]<<32)|v[5]
|
||||
textures=rx2_parser.parse_rx2(archive.read(entries[prefix+'_Textures.rx2']))
|
||||
handle=None
|
||||
for section in textures.entries:
|
||||
if section.type_id!=0x00eb000b:continue
|
||||
o=section.f0;n,start=struct.unpack_from('>2I',textures.data,o)
|
||||
for i in range(n):
|
||||
_,_,hi,lo,kind,index=struct.unpack_from('>6I',textures.data,o+start+i*24)
|
||||
if (hi<<32)|lo==diffuse:handle=index
|
||||
texture=next((t for t in textures.textures if t.index==handle),None)
|
||||
if texture is None:raise ValueError('Sky diffuse GUID did not resolve: '+name)
|
||||
if len(model.meshes)!=1 or model.meshes[0].uvs is None:raise ValueError('Unexpected sky mesh layout')
|
||||
mesh=model.meshes[0]
|
||||
(output/(name+'.rgba')).write_bytes(texture.rgba)
|
||||
|
||||
def _material(raw, table):
|
||||
channels = {}
|
||||
for section in table.entries:
|
||||
if section.type_id != 0x00eb0005:
|
||||
continue
|
||||
o = section.f0
|
||||
h = struct.unpack_from('>8I', raw, o)
|
||||
if not h[1] or (h[4]-h[3])//h[1] != 32:
|
||||
raise ValueError('Unexpected sky material layout')
|
||||
for i in range(h[1]):
|
||||
v = struct.unpack_from('>8I', raw, o+h[3]+i*32)
|
||||
at = o+v[0]
|
||||
kind = raw[at:raw.index(b'\0', at)].decode('ascii')
|
||||
if kind in ('diffuse', 'specular'):
|
||||
channels[kind] = (v[4]<<32)|v[5]
|
||||
return channels
|
||||
|
||||
|
||||
def _texture(textures, guid):
|
||||
handle = None
|
||||
for section in textures.entries:
|
||||
if section.type_id != 0x00eb000b:
|
||||
continue
|
||||
o = section.f0
|
||||
n, start = struct.unpack_from('>2I', textures.data, o)
|
||||
for i in range(n):
|
||||
_, _, hi, lo, _, index = struct.unpack_from('>6I', textures.data, o+start+i*24)
|
||||
if (hi<<32)|lo == guid:
|
||||
handle = index
|
||||
texture = next((t for t in textures.textures if t.index == handle), None)
|
||||
if texture is None:
|
||||
raise ValueError(f'Sky texture GUID did not resolve: {guid:016X}')
|
||||
return texture
|
||||
|
||||
|
||||
def convert(game_root, assets, converted):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'vendor/utt'))
|
||||
import mdl_parser
|
||||
import rx2_parser
|
||||
collections = Collections(converted)
|
||||
archive = BigArchive(game_root/'data/big/miscload.big')
|
||||
entries = {e.path: e for e in archive.entries}
|
||||
output = assets/'private/native-skies'
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
available = {p.stem.removeprefix('world') for p in (game_root/'data/content').glob('worldDIST_*.big')}
|
||||
count = 0
|
||||
for (cls, _), row in collections.rows.items():
|
||||
if cls != key_hash('world'):
|
||||
continue
|
||||
own_stream = next((v['data'] for k, v in row['fields'].items()
|
||||
if key_hash(k) == key_hash('WorldStream')), None)
|
||||
if own_stream not in available:
|
||||
continue
|
||||
name = own_stream.removeprefix('DIST_')
|
||||
env = world_environment(collections, row['key'])
|
||||
raw = archive.read(entries['data/content/'+env['sky_model']+'.rx2'])
|
||||
model = mdl_parser.parse_rx2(raw)
|
||||
table = rx2_parser.RX2File(raw)
|
||||
table.parse()
|
||||
channels = _material(raw, table)
|
||||
textures = rx2_parser.parse_rx2(archive.read(entries['data/content/'+env['sky_textures']+'.rx2']))
|
||||
diffuse = _texture(textures, channels['diffuse'])
|
||||
sun = _texture(textures, channels['specular'])
|
||||
shader = next(p.value for p in model.materials if p.kind == 'AttribulatorMaterialName')
|
||||
env.update(sky_parameters(collections, shader))
|
||||
env['fog_frame'] = fog_parameters(env['fog'])
|
||||
if len(model.meshes) != 1 or model.meshes[0].uvs is None:
|
||||
raise ValueError('Unexpected sky mesh layout')
|
||||
mesh = model.meshes[0]
|
||||
if len(sun.rgba) != sun.width*sun.height*4 or sun.height != 16:
|
||||
raise ValueError('Unexpected sky sun-gradient dimensions')
|
||||
(output/(name+'.rgba')).write_bytes(diffuse.rgba)
|
||||
(output/(name+'.sun.rgba')).write_bytes(sun.rgba)
|
||||
(output/(name+'.json')).write_text(json.dumps({
|
||||
'width':texture.width,'height':texture.height,
|
||||
'positions':mesh.vertices.tolist(),'uvs':mesh.uvs.tolist(),
|
||||
'indices':mesh.faces.reshape(-1).tolist(),
|
||||
},separators=(',',':')),encoding='utf-8')
|
||||
'width': diffuse.width, 'height': diffuse.height,
|
||||
'positions': mesh.vertices.tolist(), 'uvs': mesh.uvs.tolist(),
|
||||
'indices': mesh.faces.reshape(-1).tolist(),
|
||||
'environment': env, 'sun_width': sun.width, 'sun_height': sun.height,
|
||||
'source_sha256': row['sha256'],
|
||||
}, separators=(',', ':')), encoding='utf-8')
|
||||
count += 1
|
||||
if not count:
|
||||
raise ValueError('No authored world skies resolved')
|
||||
return count
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
import tempfile
|
||||
from .vlt import convert as convert_vlt
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--game-root', type=Path, required=True)
|
||||
parser.add_argument('--assets', type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
# A targeted update reads only the small database pair and sky resources.
|
||||
with tempfile.TemporaryDirectory(prefix='skate-sky-') as work:
|
||||
database = BigArchive(args.game_root/'data/big/db.big')
|
||||
needed = {'skaterschema.bin', 'skaterschema.vlt', 'skatercollections.bin', 'skatercollections.vlt'}
|
||||
database.extract_entries([e for e in database.entries if Path(e.path).name.lower() in needed], Path(work))
|
||||
stem = Path(work)/'data/db'
|
||||
names = (Path(__file__).parent/'names.txt').read_text(encoding='utf-8').splitlines()
|
||||
converted = convert_vlt(stem/'skaterschema', stem/'skatercollections', [*names, *NAMES])
|
||||
print('Prepared', convert(args.game_root, args.assets, converted), 'authored skies')
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Join retail FE destinations to localized names and RX2 location matrices.
|
||||
|
||||
Owned content stays in private sidecars. No guessed coordinates or renamed spots.
|
||||
LocationManager 828A3D50 loads data/content/global_locators; EB0009 records
|
||||
contain a 64-byte matrix and a section-relative name pointer at +104.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from tools.owned_game.big import BigArchive
|
||||
from .dynamic_props import sections, matrix
|
||||
from .environment import Collections, key_hash
|
||||
from .vlt import vault
|
||||
|
||||
|
||||
def cstring(raw, offset, limit=None):
|
||||
limit = len(raw) if limit is None else limit
|
||||
if not 0 <= offset < limit <= len(raw):
|
||||
raise ValueError('Invalid location string offset')
|
||||
return raw[offset:raw.index(b'\0', offset, limit)]
|
||||
|
||||
|
||||
def language_table(raw):
|
||||
if len(raw) < 36 or struct.unpack_from('<I', raw)[0] != 0x39000:
|
||||
raise ValueError('Unsupported retail language table')
|
||||
count, start, strings = struct.unpack_from('<3I', raw, 8)
|
||||
start += 8
|
||||
strings += 8
|
||||
if start + count * 8 != strings or strings > len(raw):
|
||||
raise ValueError('Invalid retail language index')
|
||||
result = {}
|
||||
for index in range(count):
|
||||
key, offset = struct.unpack_from('<2I', raw, start + index * 8)
|
||||
if key in result:
|
||||
raise ValueError('Duplicate language key')
|
||||
result[key] = cstring(raw, strings + offset)
|
||||
return result
|
||||
|
||||
|
||||
def location_records(raw):
|
||||
result = []
|
||||
for base, _, size, _, _, kind in sections(raw):
|
||||
if kind != 0xEB0009:
|
||||
continue
|
||||
count, _, _, start, end = struct.unpack_from('>5I', raw, base)
|
||||
if base + size > len(raw) or start != 32 or end != start + count * 128 or end > size:
|
||||
raise ValueError('Invalid location record table')
|
||||
for index in range(count):
|
||||
at = base + start + index * 128
|
||||
pointer = struct.unpack_from('>I', raw, at + 104)[0]
|
||||
if not end <= pointer < size:
|
||||
raise ValueError('Location name overlaps records')
|
||||
result.append({'locator': cstring(raw, base + pointer, base + size).decode('utf-8'),
|
||||
'matrix': matrix(raw, at).tolist(), 'source_offset': at})
|
||||
return result
|
||||
|
||||
|
||||
def convert(game_root, assets, converted):
|
||||
game_root, assets = Path(game_root), Path(assets)
|
||||
archive = BigArchive(game_root / 'data/big/miscboot.big')
|
||||
tables = {}
|
||||
for language in ('english', 'labels'):
|
||||
entry = next(e for e in archive.entries if f'/{language}/' in e.path and '_Global_' in e.path)
|
||||
tables[language] = language_table(archive.read(entry))
|
||||
labels = {value.strip().decode('ascii'): key for key, value in tables['labels'].items()}
|
||||
database = BigArchive(game_root / 'data/big/db.big')
|
||||
with tempfile.TemporaryDirectory(prefix='skate-teleports-') as tmp:
|
||||
root = Path(tmp)
|
||||
entries = [e for e in database.entries if e.path in
|
||||
('data/db/skatercollections.bin', 'data/db/skatercollections.vlt')]
|
||||
database.extract_entries(entries, root)
|
||||
_, binary, _ = vault(root / 'data/db/skatercollections')
|
||||
locators = {}
|
||||
for path in sorted((game_root / 'data/content/global_locators').rglob('*.rx2')):
|
||||
raw = path.read_bytes()
|
||||
stream = path.parent.name
|
||||
for record in location_records(raw):
|
||||
record.update(map=stream.removeprefix('DIST_'), source=str(path.relative_to(game_root)),
|
||||
source_sha256=hashlib.sha256(raw).hexdigest())
|
||||
locators.setdefault((stream.casefold(), record['locator']), []).append(record)
|
||||
collections = Collections(converted)
|
||||
destinations = []
|
||||
# Own location artwork distinguishes selectable FE destinations from base
|
||||
# templates, startup checkpoints and park-layout aliases.
|
||||
for (cls, key), row in collections.rows.items():
|
||||
if cls != key_hash('fe_locations') or not any(key_hash(k) == key_hash('Hash_786A2D24F475342B') for k in row['fields']):
|
||||
continue
|
||||
fields, _ = collections.resolve(cls, key)
|
||||
label = fields[key_hash('Hash_174B301910A902C9')]['data']
|
||||
if label not in labels:
|
||||
continue # Template placeholders have no shipped localized name.
|
||||
text = tables['english'][labels[label]]
|
||||
# 0xAB is the retail font's skate wordmark glyph. The portable UI has
|
||||
# no such font glyph; spell out the same brand instead of showing «.
|
||||
name = text.replace(b'\xab', b'skate.').decode('ascii').strip()
|
||||
locator = cstring(binary, int(fields[key_hash('location')]['data'], 16)).decode('utf-8')
|
||||
world_key = int(fields[key_hash('World')]['data'][:16], 16)
|
||||
world, _ = collections.resolve('world', world_key)
|
||||
stream = world[key_hash('WorldStream')]['data']
|
||||
if not (game_root / 'data/content' / ('world' + stream + '.big')).exists():
|
||||
continue
|
||||
matches = locators.get((stream.casefold(), locator), [])
|
||||
unique = {json.dumps(m['matrix']) for m in matches}
|
||||
if len(unique) > 1:
|
||||
raise ValueError('Ambiguous authored teleport: ' + locator)
|
||||
item = dict(id=row['key'], name=name, map=stream.removeprefix('DIST_'),
|
||||
label_key=label, locator=locator, matrix=None)
|
||||
if matches:
|
||||
item.update(matches[0])
|
||||
else:
|
||||
item['unavailable_reason'] = 'Authored destination locator was not found in the extracted content'
|
||||
destinations.append(item)
|
||||
destinations.sort(key=lambda d: (d['map'].casefold(), d['name'].casefold()))
|
||||
output = assets / 'private/teleports.json'
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps({'version': 1, 'destinations': destinations}, indent=2), encoding='utf-8')
|
||||
return destinations
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Global resource GUID selection without owned-game fixtures."""
|
||||
import struct
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from .backdrop import texture_groups
|
||||
from .sky import _texture
|
||||
|
||||
|
||||
class BackdropTests(unittest.TestCase):
|
||||
def test_missing_channels_do_not_shift_later_materials(self):
|
||||
# Ocean has no diffuse here; foliage follows it. Name text and GUIDs
|
||||
# deliberately differ, as they do in the global University resource.
|
||||
parameters = [('Name', 0), ('normal', 19), ('Name', 0),
|
||||
('diffuse', 0x123456789ABCDEF0), ('lightmap', 23)]
|
||||
raw = bytearray(512)
|
||||
struct.pack_into('>8I', raw, 0, 0, len(parameters), 0, 32,
|
||||
32 + len(parameters)*32, 0, 0, 0)
|
||||
at = 256
|
||||
for i, (kind, guid) in enumerate(parameters):
|
||||
word = kind.encode() + b'\0'
|
||||
raw[at:at+len(word)] = word
|
||||
struct.pack_into('>8I', raw, 32+i*32, at, 0, 0, 0,
|
||||
guid >> 32, guid & 0xffffffff, 0, 0)
|
||||
at += len(word)
|
||||
table = SimpleNamespace(entries=[SimpleNamespace(type_id=0xeb0005, f0=0)])
|
||||
self.assertEqual(texture_groups(bytes(raw), table, ('diffuse', 'normal', 'lightmap')),
|
||||
[{'normal': 19}, {'diffuse': 0x123456789ABCDEF0, 'lightmap': 23}])
|
||||
|
||||
def test_texture_guid_uses_handle_not_list_position(self):
|
||||
raw = bytearray(64)
|
||||
struct.pack_into('>2I', raw, 0, 1, 8)
|
||||
struct.pack_into('>6I', raw, 8, 0, 0, 0x12345678, 0x9abcdef0, 0, 27)
|
||||
wanted = SimpleNamespace(index=27)
|
||||
textures = SimpleNamespace(data=bytes(raw),
|
||||
entries=[SimpleNamespace(type_id=0xeb000b, f0=0)],
|
||||
textures=[SimpleNamespace(index=1), wanted])
|
||||
self.assertIs(_texture(textures, 0x123456789ABCDEF0), wanted)
|
||||
with self.assertRaises(ValueError):
|
||||
_texture(textures, 123)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,52 @@
|
||||
import struct
|
||||
import unittest
|
||||
import numpy as np
|
||||
|
||||
from .dynamic_props import locators, transform_mesh, template_meshes
|
||||
|
||||
|
||||
def resource(kind, payload):
|
||||
raw = bytearray(88+len(payload))
|
||||
raw[:7] = b'\x89RW4xb2'
|
||||
struct.pack_into('>I', raw, 32, 1)
|
||||
struct.pack_into('>I', raw, 48, 64)
|
||||
struct.pack_into('>6I', raw, 64, 88, 0, len(payload), 0, 0, kind)
|
||||
raw[88:] = payload
|
||||
return raw
|
||||
|
||||
|
||||
class DynamicPropsTests(unittest.TestCase):
|
||||
def test_locator_id_and_row_matrix(self):
|
||||
payload = bytearray(166)
|
||||
struct.pack_into('>5I', payload, 0, 0, 1, 1, 32, 160)
|
||||
m = np.eye(4); m[3, :3] = [10, 20, -30]
|
||||
struct.pack_into('>16f', payload, 32, *m.ravel())
|
||||
struct.pack_into('>3Q2I', payload, 128, 123, 456, 789, 0, 160)
|
||||
payload[160:] = b'bench\0'
|
||||
rows = locators(resource(0xEB001D, payload))
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]['template_id'], '0000000000000315')
|
||||
self.assertEqual(rows[0]['matrix'][3], [10, 20, -30, 1])
|
||||
self.assertEqual(rows[0]['name'], 'bench')
|
||||
struct.pack_into('>I', payload, 16, 159)
|
||||
with self.assertRaisesRegex(ValueError, 'layout'):
|
||||
locators(resource(0xEB001D, payload))
|
||||
|
||||
def test_scaled_rotation_and_normal_transform(self):
|
||||
m = np.array([[0, 0, -2, 0], [0, 3, 0, 0], [4, 0, 0, 0], [10, 20, 30, 1]], dtype=float)
|
||||
arrays = {'vertices_0': np.array([[1, 2, 3]]), 'normals_0': np.array([[0, 0, 1]]), 'faces_0': np.array([[0, 0, 0]])}
|
||||
transformed = transform_mesh(arrays, 0, m)
|
||||
np.testing.assert_allclose(transformed['vertices_0'], [[22, 26, 28]])
|
||||
np.testing.assert_allclose(transformed['normals_0'], [[1, 0, 0]])
|
||||
np.testing.assert_array_equal(arrays['vertices_0'], [[1, 2, 3]])
|
||||
|
||||
def test_missing_template_reference_is_rejected(self):
|
||||
payload = bytearray(192)
|
||||
struct.pack_into('>5I', payload, 0, 0, 1, 1, 32, 192)
|
||||
struct.pack_into('>I', payload, 160, 100)
|
||||
with self.assertRaisesRegex(ValueError, 'model reference'):
|
||||
template_meshes(resource(0xEB000D, payload))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Synthetic environment references, independent of owned game assets."""
|
||||
import struct
|
||||
import unittest
|
||||
from .environment import Collections, field, key_hash, sky_parameters, world_environment, fog_parameters
|
||||
from .render_parameters import exposure_parameters
|
||||
|
||||
|
||||
def raw(*values):
|
||||
return struct.pack('>'+'f'*len(values), *values).hex()
|
||||
|
||||
|
||||
def row(cls, key, parent='', **fields):
|
||||
return {'class': cls, 'key': key, 'parent': parent,
|
||||
'fields': {k: {'type': 'fixture', 'data': v} for k, v in fields.items()}}
|
||||
|
||||
|
||||
class EnvironmentTests(unittest.TestCase):
|
||||
def test_exposure_follows_location_reference_and_inheritance(self):
|
||||
rows = [row('render_locations', 'park', Hash_C345507C4B9B6F62=struct.pack('>2Q', key_hash('park_exposure'), 0).hex()),
|
||||
row('rendering', 'base', auto_exposure_target_luminance=raw(.25), auto_exposure_min=raw(.75), auto_exposure_max=raw(2.5), auto_exposure_damping=raw(.5)),
|
||||
row('rendering', 'park_exposure', 'base', auto_exposure_max=raw(1.5))]
|
||||
result = exposure_parameters(Collections({'collections': rows}), 'park')
|
||||
self.assertEqual(result['max'], 1.5)
|
||||
self.assertEqual(result['min'], .75)
|
||||
self.assertEqual(result['rendering_chain'], ['park_exposure', 'base'])
|
||||
def test_world_reference_and_inheritance_select_default_fog(self):
|
||||
rows = [
|
||||
row('world', 'base', Hash_C7A0A84F018E87BA='world/models/sky',
|
||||
Hash_2A0BF629355AFB15='world/models/sky_Textures',
|
||||
Hash_7E23D10785C43717=struct.pack('>2Q', key_hash('park'), 0).hex()),
|
||||
row('world', 'test', 'base'),
|
||||
row('render_locations', 'default', sun_position=raw(1, 0, 0),
|
||||
Hash_2E9AAECD1C29F81F=raw(150),
|
||||
material_fog=struct.pack('>3Q', key_hash('material_fog'), key_hash('default'), 0).hex()),
|
||||
row('render_locations', 'park', 'default', sun_position=raw(0, 3, 4)),
|
||||
row('material_fog', 'default', fog_near=raw(20), fog_far=raw(1000),
|
||||
fog_colour=raw(.1, .2, .3, 1), fog_power=raw(2), fog_max=raw(.5)),
|
||||
row('material_fog', 'fog_default', 'default', fog_far=raw(200)),
|
||||
]
|
||||
env = world_environment(Collections({'collections': rows}), 'test')
|
||||
self.assertEqual(env['sun_direction'], [0, .6, .8])
|
||||
self.assertEqual(env['anchor_height'], 150)
|
||||
self.assertEqual(env['location_chain'], ['park', 'default'])
|
||||
self.assertEqual(env['fog']['fog_far'], [1000])
|
||||
self.assertEqual(env['sky_model'], 'world/models/sky')
|
||||
|
||||
def test_native_fog_rows_and_distance_boundaries(self):
|
||||
fog = dict(fog_near=[100.], fog_far=[2000.], fog_power=[2.],
|
||||
fog_max=[.5], fog_colour=[.1, .2, .3, 1.])
|
||||
frame = fog_parameters(fog)
|
||||
self.assertEqual(frame['colour'], [.05, .1, .15, -.5])
|
||||
def opacity(distance):
|
||||
x = max(0., min(1., distance * frame['ramp'][0] + frame['ramp'][1]))
|
||||
return .5 * x ** frame['ramp'][2]
|
||||
self.assertEqual(opacity(0.), 0.)
|
||||
self.assertAlmostEqual(opacity(100.), 0.)
|
||||
self.assertAlmostEqual(opacity(1050.), .125)
|
||||
self.assertAlmostEqual(opacity(2000.), .5)
|
||||
self.assertAlmostEqual(opacity(3000.), .5)
|
||||
fog['fog_far'] = [100.]
|
||||
with self.assertRaises(ValueError): fog_parameters(fog)
|
||||
|
||||
def test_unnamed_hashes_and_cycle_rejection(self):
|
||||
cls = f'Hash_{key_hash("world"):016X}'
|
||||
c = Collections({'collections': [row(cls, 'a', 'b', value='child'),
|
||||
row(cls, 'b', value='parent')]})
|
||||
values, _ = c.resolve('world', 'a')
|
||||
self.assertEqual(field(values, 'value'), 'child')
|
||||
c.rows[key_hash('world'), key_hash('b')]['parent'] = 'a'
|
||||
with self.assertRaises(ValueError):
|
||||
c.resolve('world', 'a')
|
||||
|
||||
def test_sky_requires_complete_array_and_shader_collection(self):
|
||||
r = row('material_sky', 'default')
|
||||
r['fields']['m_params'] = {'type': 'Attrib::Types::Vector4', 'data': 'header'}
|
||||
c = Collections({'collections': [r]})
|
||||
with self.assertRaises(ValueError):
|
||||
sky_parameters(c, 'sky.default')
|
||||
r['fields']['m_params']['array'] = {'items': [raw(.5, .25, 0, 0)]}
|
||||
self.assertEqual(sky_parameters(c, 'sky.default')['multiplier'], .25)
|
||||
with self.assertRaises(KeyError):
|
||||
sky_parameters(c, 'sky.missing')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,37 @@
|
||||
"""RX2 irradiance layout checks without owned-game fixtures."""
|
||||
import struct
|
||||
import unittest
|
||||
|
||||
from .irradiance import decode
|
||||
|
||||
|
||||
def resource():
|
||||
data = bytearray(272)
|
||||
data[:7] = b'\x89RW4xb2'
|
||||
struct.pack_into('>I', data, 32, 1)
|
||||
struct.pack_into('>I', data, 48, 64)
|
||||
struct.pack_into('>6I', data, 64, 96, 0, 176, 0, 0, 0xEB0024)
|
||||
struct.pack_into('>2I', data, 96, 1, 16)
|
||||
values = [i / 4 for i in range(39)]
|
||||
struct.pack_into('>39fI', data, 112, *values, 0xDEDEDEDE)
|
||||
return data, values
|
||||
|
||||
|
||||
class IrradianceTests(unittest.TestCase):
|
||||
def test_preserves_coefficients_position_and_link_word(self):
|
||||
data, values = resource()
|
||||
groups = decode(data)
|
||||
self.assertEqual(len(groups), 1)
|
||||
self.assertEqual(struct.unpack('<39fI', groups[0][0]), (*values, 0xDEDEDEDE))
|
||||
|
||||
def test_rejects_records_outside_section(self):
|
||||
data, _ = resource()
|
||||
struct.pack_into('>I', data, 96, 2)
|
||||
with self.assertRaises(ValueError):
|
||||
decode(data)
|
||||
|
||||
def test_rejects_nonfinite_sh(self):
|
||||
data, _ = resource()
|
||||
struct.pack_into('>f', data, 112, float('nan'))
|
||||
with self.assertRaises(ValueError):
|
||||
decode(data)
|
||||
@@ -0,0 +1,35 @@
|
||||
import json
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from .refresh_textures import refresh
|
||||
|
||||
|
||||
class RefreshTests(unittest.TestCase):
|
||||
def test_payload_refresh_preserves_tail_and_failed_refresh_preserves_output(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
string = lambda x: struct.pack('<I', len(x)) + x
|
||||
prefix = b'SKATE14\0' + struct.pack('<I', 0x12345678) + string(b'fixture') + bytes(49*4)
|
||||
prefix += struct.pack('<9I', 0, 1, 0, 0, 0, 0, 0, 0, 0)
|
||||
old = bytes(16)
|
||||
header = string(b'texture') + struct.pack('<3I', 2, 2, 0)
|
||||
tail = b'geometry/collision/metadata-preservation-fixture'
|
||||
source = root/'input.skate'
|
||||
source.write_bytes(prefix + header + struct.pack('<II', 0, 16) + old + tail)
|
||||
cache = bytes(range(16))
|
||||
(root/'new.rgba').write_bytes(cache)
|
||||
manifest = root/'manifest.json'
|
||||
manifest.write_text(json.dumps({'textures': {'texture': dict(width=2, height=2, rgba='new.rgba')}}))
|
||||
output = root/'out.skate'
|
||||
report = refresh(manifest, source, output)
|
||||
self.assertEqual(report['count'], 1)
|
||||
self.assertEqual(output.read_bytes()[:len(prefix)], prefix)
|
||||
self.assertEqual(output.read_bytes()[report['new_tail_offset']:], tail)
|
||||
saved = output.read_bytes()
|
||||
(root/'new.rgba').write_bytes(b'bad')
|
||||
with self.assertRaises(ValueError): refresh(manifest, source, output)
|
||||
self.assertEqual(output.read_bytes(), saved)
|
||||
with self.assertRaises(ValueError): refresh(manifest, source, source)
|
||||
self.assertFalse(list(root.glob('*.tmp')))
|
||||
@@ -0,0 +1,36 @@
|
||||
import struct
|
||||
import unittest
|
||||
import numpy as np
|
||||
from .teleports import language_table, location_records
|
||||
from .test_dynamic_props import resource
|
||||
|
||||
|
||||
class TeleportTests(unittest.TestCase):
|
||||
def test_language_join_uses_keys_not_storage_order(self):
|
||||
# Offsets are relative to the chunk payload; strings need not follow
|
||||
# the same physical order as the sorted hash index.
|
||||
raw = bytearray(36 + 16 + 11)
|
||||
struct.pack_into('<5I', raw, 0, 0x39000, len(raw)-8, 2, 28, 44)
|
||||
struct.pack_into('<4I', raw, 36, 123, 6, 456, 0)
|
||||
raw[52:] = b'First\0Last\0'
|
||||
self.assertEqual(language_table(raw), {123: b'Last', 456: b'First'})
|
||||
struct.pack_into('<I', raw, 40, 999)
|
||||
with self.assertRaises(ValueError): language_table(raw)
|
||||
|
||||
def test_locator_preserves_heading_and_world_space_position(self):
|
||||
payload = bytearray(165)
|
||||
struct.pack_into('>5I', payload, 0, 1, 0, 0, 32, 160)
|
||||
m = np.array([[0, 0, -1, 0], [0, 1, 0, 0], [1, 0, 0, 0], [350, 140, -725, 1]], dtype=float)
|
||||
struct.pack_into('>16f', payload, 32, *m.ravel())
|
||||
struct.pack_into('>I', payload, 136, 160)
|
||||
payload[160:] = b'spot\0'
|
||||
rows = location_records(resource(0xEB0009, payload))
|
||||
self.assertEqual(rows[0]['locator'], 'spot')
|
||||
np.testing.assert_array_equal(rows[0]['matrix'], m)
|
||||
struct.pack_into('>I', payload, 136, 32)
|
||||
with self.assertRaisesRegex(ValueError, 'overlaps'): location_records(resource(0xEB0009, payload))
|
||||
|
||||
def test_bad_record_count_is_rejected(self):
|
||||
payload = bytearray(32)
|
||||
struct.pack_into('>5I', payload, 0, 1, 0, 0, 32, 160)
|
||||
with self.assertRaises(ValueError): location_records(resource(0xEB0009, payload))
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Regression fixtures for padded Xbox tiles and BC colour palettes."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
UTT = Path(__file__).resolve().parents[1] / 'vendor/utt'
|
||||
sys.path.insert(0, str(UTT))
|
||||
import rx2_fast
|
||||
|
||||
# Exercise the standalone fallback as well as the NumPy implementation.
|
||||
spec = importlib.util.spec_from_file_location('rx2_scalar_test', UTT / 'rx2_parser.py')
|
||||
scalar = importlib.util.module_from_spec(spec)
|
||||
with patch.dict(sys.modules, {'rx2_fast': None}):
|
||||
spec.loader.exec_module(scalar)
|
||||
|
||||
|
||||
class TextureDecodeTests(unittest.TestCase):
|
||||
def test_padding_cannot_overwrite_visible_rows(self):
|
||||
for width, pitch in ((w, p) for w in (4, 8, 16, 32) for p in (2, 4, 8, 16)):
|
||||
with self.subTest(width=width, pitch=pitch):
|
||||
units = max(32*32, 4096//pitch)
|
||||
raw = bytearray(units*pitch)
|
||||
for offset in range(units):
|
||||
x = scalar._x360_tiled_x(offset, width, pitch)
|
||||
y = scalar._x360_tiled_y(offset, width, pitch)
|
||||
if x < width and y < 32:
|
||||
raw[offset*pitch:offset*pitch+pitch] = bytes([1+y])*pitch
|
||||
expected = b''.join(bytes([1+y])*(width*pitch) for y in range(32))
|
||||
for decoder in (scalar, rx2_fast):
|
||||
self.assertEqual(decoder._untile360(raw, width, pitch)[:len(expected)], expected)
|
||||
|
||||
def test_bc1_keeps_transparent_palette(self):
|
||||
colour = struct.pack('>HH', 0x001f, 0xf800) + b'\xff'*4
|
||||
self.assertEqual(scalar._dxt1_block(colour, True), bytes(64))
|
||||
self.assertEqual(rx2_fast._decode(colour, 4, 4, 1, False), bytes(64))
|
||||
|
||||
def test_bc2_bc3_use_four_colours_with_reversed_endpoints(self):
|
||||
colour = struct.pack('>HH', 0x001f, 0xf800) + b'\xff'*4
|
||||
for kind, alpha, block_decoder in ((3, b'\xaa'*8, scalar._dxt3_block),
|
||||
(5, b'\x00\xaa'+bytes(6), scalar._dxt5_block)):
|
||||
expected = bytes((170, 0, 85, 170))*16
|
||||
self.assertEqual(block_decoder(alpha+colour), expected)
|
||||
self.assertEqual(rx2_fast._decode(alpha+colour, 4, 4, kind, False), expected)
|
||||
@@ -315,6 +315,8 @@ def load_district_stream(
|
||||
root = Path(stream_directory)
|
||||
records = read_atoc(root / f"{district_name}_{stream_name}.xst")
|
||||
stream_files = sorted(root.glob(f"c{stream_name}_*.xsf"))
|
||||
if not records and not stream_files:
|
||||
return []
|
||||
if not stream_files:
|
||||
raise StreamFormatError(
|
||||
f"{root} contains no c{stream_name}_*.xsf stream files"
|
||||
|
||||
Vendored
+6
-2
@@ -17,7 +17,9 @@ def _layout(units,width,pitch):
|
||||
y=(((m//(aligned>>5))<<2)+((t>>(6+log))&1)+((b&2048)>>10))<<3
|
||||
y+=(((t&(((pitch<<6)-1)&~31))+((t&15)<<1))>>(3+log))&~1
|
||||
y+=(t&16)>>4
|
||||
dest=y*width+x;valid=dest<units
|
||||
# Padded tile columns are outside the logical image. Flattening them
|
||||
# first aliases them onto later rows and overwrites valid blocks.
|
||||
dest=y*width+x;valid=(x<width)&(dest<units)
|
||||
# Match scalar last-write ordering even for narrow padded surfaces.
|
||||
source=np.full(units,-1,dtype=np.int64)
|
||||
np.maximum.at(source,dest[valid],offset[valid])
|
||||
@@ -39,7 +41,9 @@ def _blocks(data,w,h,size):
|
||||
def _colour(b,transparent):
|
||||
c=b[:,:4].astype(np.uint32);c0=c[:,0]*256+c[:,1];c1=c[:,2]*256+c[:,3]
|
||||
def rgb(v):return np.stack((((v>>11)&31)*255//31,((v>>5)&63)*255//63,(v&31)*255//31),axis=1)
|
||||
a=rgb(c0);z=rgb(c1);four=c0>c1
|
||||
# BC2/BC3 always use four colours. Only BC1 uses endpoint ordering
|
||||
# to select the three-colour + transparent-black palette.
|
||||
a=rgb(c0);z=rgb(c1);four=(c0>c1) | (not transparent)
|
||||
pal=np.zeros((len(b),4,4),dtype=np.uint8);pal[:,:,3]=255
|
||||
pal[:,0,:3]=a;pal[:,1,:3]=z
|
||||
pal[:,2,:3]=np.where(four[:,None],(2*a+z+1)//3,(a+z)//2)
|
||||
|
||||
Vendored
+3
-2
@@ -426,7 +426,7 @@ def _untile360(src, width_units, texel_pitch):
|
||||
y = _x360_tiled_y(j * width_units + i, width_units, texel_pitch)
|
||||
dst_idx = (y * width_units + x) * texel_pitch
|
||||
src_idx = (j * width_units + i) * texel_pitch
|
||||
if dst_idx + texel_pitch <= len(dst) and src_idx + texel_pitch <= len(src):
|
||||
if x < width_units and dst_idx + texel_pitch <= len(dst) and src_idx + texel_pitch <= len(src):
|
||||
dst[dst_idx:dst_idx + texel_pitch] = src[src_idx:src_idx + texel_pitch]
|
||||
return bytes(dst)
|
||||
|
||||
@@ -473,7 +473,8 @@ def _dxt1_block(blk, transparent):
|
||||
c0, c1 = struct.unpack_from(">HH", blk, 0)
|
||||
r0, g0, b0 = _rgb565(c0)
|
||||
r1, g1, b1 = _rgb565(c1)
|
||||
if c0 > c1:
|
||||
# DXT3/DXT5 have independent alpha and always use four colour entries.
|
||||
if c0 > c1 or not transparent:
|
||||
c2, c3 = _rgb565_interp((c0 >> 11) & 0x1F, (c1 >> 11) & 0x1F,
|
||||
(c0 >> 5) & 0x3F, (c1 >> 5) & 0x3F,
|
||||
c0 & 0x1F, c1 & 0x1F)
|
||||
|
||||
Reference in new issue
Block a user