The movie cutscene subtitle + voice pipeline, driven by the ADVERTISE_MOVIE manifest (the authoritative movie -> subtitle -> voice index). Also flushes several sessions of local WIP (async viewer loading, grouped-pool XBG7/hero-ship decode, drawlog tooling). See docs/HANDOFF-movie-voice-subtitles-2026-07-19.md. Subtitles (movie_subtitle.rs): - Full movie->track->text chain; join multi-line captions sharing one timing (fixes S13A dropped "Look at it father" line); overlap-safe active_cues(); Latin-1 accents preserved. Voice (slb.rs): XACT .slb -> XMA1 RIFF; take the FIRST sub-wave bounded by its declared data size (fixes S10-S16 alternate-take garble); list_voice_clips. Manifest (movie_manifest.rs): parse ADVERTISE_MOVIE (0x5B983A08) for the real movie->voice binding (not always VOICE_<movie>; e.g. hokyu -> VOICE_D_* in etc\). Resolve the token's sound.pak path via sounds.tbl. DIRECT bindings only — the demo-id shared-clip fallback for unbound hokyu movies was verified WRONG in-game and reverted (unbound hokyu stay unvoiced; correct join key is an OPEN problem). Viewer: manifest-driven voice (movie player toggle + solo button), standalone "Voice Lines" browser, stacked caption overlay. Tests: 46 formats-lib + 11 viewer-lib + movie_manifest/movie_subtitle/slb disc tests; full workspace green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
218 lines
7.6 KiB
Rust
218 lines
7.6 KiB
Rust
//! Orbit camera for inspecting 3D assets.
|
|
//!
|
|
//! Controls:
|
|
//! - Left-click + drag → orbit
|
|
//! - Right-click + drag → pan
|
|
//! - Scroll wheel → zoom
|
|
//! - R → reset to default view
|
|
|
|
use bevy::prelude::*;
|
|
use bevy::input::mouse::{MouseMotion, MouseWheel};
|
|
use bevy::render::render_asset::RenderAssetUsages;
|
|
use bevy::render::render_resource::{
|
|
Extent3d, TextureDimension, TextureFormat, TextureViewDescriptor, TextureViewDimension,
|
|
};
|
|
|
|
pub struct OrbitCameraPlugin;
|
|
|
|
impl Plugin for OrbitCameraPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.add_systems(Startup, spawn_camera)
|
|
.add_systems(Update, orbit_camera);
|
|
}
|
|
}
|
|
|
|
/// State for the orbit camera controller.
|
|
#[derive(Component)]
|
|
pub struct OrbitCamera {
|
|
/// Distance from the focus point
|
|
pub radius: f32,
|
|
/// Rotation around the vertical axis (azimuth) in radians
|
|
pub yaw: f32,
|
|
/// Rotation around the horizontal axis (elevation) in radians
|
|
pub pitch: f32,
|
|
/// The point the camera orbits around
|
|
pub focus: Vec3,
|
|
pub orbit_sensitivity: f32,
|
|
pub zoom_sensitivity: f32,
|
|
pub pan_sensitivity: f32,
|
|
/// Zoom limits — set when framing a model so large scenes can be pulled back
|
|
/// far enough (the old fixed 0.5..50 cap trapped the camera inside big stages).
|
|
pub min_radius: f32,
|
|
pub max_radius: f32,
|
|
}
|
|
|
|
impl Default for OrbitCamera {
|
|
fn default() -> Self {
|
|
Self {
|
|
radius: 5.0,
|
|
yaw: std::f32::consts::FRAC_PI_4,
|
|
pitch: std::f32::consts::FRAC_PI_6,
|
|
focus: Vec3::ZERO,
|
|
orbit_sensitivity: 0.005,
|
|
zoom_sensitivity: 0.3,
|
|
pan_sensitivity: 0.003,
|
|
min_radius: 0.05,
|
|
max_radius: 500.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn spawn_camera(mut commands: Commands, mut images: ResMut<Assets<Image>>) {
|
|
let orbit = OrbitCamera::default();
|
|
let transform = orbit_transform(&orbit);
|
|
|
|
// The game's ship shader reflects a shared HDR environment cubemap off the
|
|
// hull (three cube samples — see the shader RE). Reproduce that "look" with a
|
|
// procedural sky cube feeding Bevy's image-based lighting, so metallic
|
|
// surfaces reflect an environment instead of reading flat. Procedural (not a
|
|
// bundled KTX2) so it also works on WASM with no extra assets.
|
|
let env = make_env_cubemap(&mut images);
|
|
|
|
commands.spawn((
|
|
Camera3d::default(),
|
|
// Wide near/far so both tiny weapons and multi-thousand-unit stages fit;
|
|
// the planes are re-scaled to the zoom distance each frame (below).
|
|
Projection::Perspective(PerspectiveProjection {
|
|
near: 0.05,
|
|
far: 100_000.0,
|
|
..default()
|
|
}),
|
|
transform,
|
|
orbit,
|
|
EnvironmentMapLight {
|
|
diffuse_map: env.clone(),
|
|
specular_map: env,
|
|
intensity: 900.0,
|
|
..default()
|
|
},
|
|
));
|
|
}
|
|
|
|
/// Build a small procedural sky cubemap (6 faces) for image-based lighting.
|
|
///
|
|
/// A vertical gradient (bright zenith → warm horizon → dark ground) plus a warm
|
|
/// "sun" highlight roughly where the game's key light sits — enough for a
|
|
/// metallic hull to read as reflective rather than flat. Stored as an sRGB cube
|
|
/// (filterable, no half-float encoding needed); a single mip means reflections
|
|
/// stay sharp (fine for a shiny ship).
|
|
fn make_env_cubemap(images: &mut Assets<Image>) -> Handle<Image> {
|
|
let size: i32 = 64;
|
|
// Approx key-light direction from the RE (PS c32 ≈ (0,-0.76,-0.65), i.e. light
|
|
// arriving from top-front); place the bright spot there.
|
|
let sun = Vec3::new(0.0, 0.85, 0.5).normalize();
|
|
let zenith = Vec3::new(0.70, 0.80, 0.95);
|
|
let horizon = Vec3::new(0.42, 0.45, 0.50);
|
|
let ground = Vec3::new(0.09, 0.09, 0.11);
|
|
let srgb = |c: f32| (c.clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0).round() as u8;
|
|
|
|
let mut data: Vec<u8> = Vec::with_capacity((size * size * 6 * 4) as usize);
|
|
for face in 0..6 {
|
|
for y in 0..size {
|
|
for x in 0..size {
|
|
let u = (x as f32 + 0.5) / size as f32 * 2.0 - 1.0;
|
|
let v = (y as f32 + 0.5) / size as f32 * 2.0 - 1.0;
|
|
// Standard cube-face → direction mapping.
|
|
let d = match face {
|
|
0 => Vec3::new(1.0, -v, -u),
|
|
1 => Vec3::new(-1.0, -v, u),
|
|
2 => Vec3::new(u, 1.0, v),
|
|
3 => Vec3::new(u, -1.0, -v),
|
|
4 => Vec3::new(u, -v, 1.0),
|
|
_ => Vec3::new(-u, -v, -1.0),
|
|
}
|
|
.normalize();
|
|
let mut col = if d.y >= 0.0 {
|
|
horizon.lerp(zenith, (d.y).powf(0.6))
|
|
} else {
|
|
horizon.lerp(ground, (-d.y).powf(0.5))
|
|
};
|
|
let s = d.dot(sun).max(0.0).powf(60.0);
|
|
col += Vec3::new(1.0, 0.85, 0.6) * s * 1.5;
|
|
data.extend_from_slice(&[srgb(col.x), srgb(col.y), srgb(col.z), 255]);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut image = Image::new(
|
|
Extent3d {
|
|
width: size as u32,
|
|
height: size as u32,
|
|
depth_or_array_layers: 6,
|
|
},
|
|
TextureDimension::D2,
|
|
data,
|
|
TextureFormat::Rgba8UnormSrgb,
|
|
RenderAssetUsages::RENDER_WORLD,
|
|
);
|
|
image.texture_view_descriptor = Some(TextureViewDescriptor {
|
|
dimension: Some(TextureViewDimension::Cube),
|
|
..default()
|
|
});
|
|
images.add(image)
|
|
}
|
|
|
|
fn orbit_camera(
|
|
mut query: Query<(&mut OrbitCamera, &mut Transform, &mut Projection)>,
|
|
mouse_buttons: Res<ButtonInput<MouseButton>>,
|
|
keys: Res<ButtonInput<KeyCode>>,
|
|
mut mouse_motion: EventReader<MouseMotion>,
|
|
mut scroll: EventReader<MouseWheel>,
|
|
) {
|
|
let Ok((mut cam, mut transform, mut projection)) = query.get_single_mut() else { return };
|
|
|
|
let mut delta_motion = Vec2::ZERO;
|
|
for ev in mouse_motion.read() {
|
|
delta_motion += ev.delta;
|
|
}
|
|
|
|
let mut scroll_delta = 0.0f32;
|
|
for ev in scroll.read() {
|
|
scroll_delta += ev.y;
|
|
}
|
|
|
|
// Orbit (left mouse drag)
|
|
if mouse_buttons.pressed(MouseButton::Left) {
|
|
cam.yaw -= delta_motion.x * cam.orbit_sensitivity;
|
|
cam.pitch -= delta_motion.y * cam.orbit_sensitivity;
|
|
// Clamp pitch to avoid gimbal lock
|
|
cam.pitch = cam.pitch.clamp(-1.5, 1.5);
|
|
}
|
|
|
|
// Pan (right mouse drag)
|
|
if mouse_buttons.pressed(MouseButton::Right) {
|
|
let right = transform.rotation * Vec3::X;
|
|
let up = transform.rotation * Vec3::Y;
|
|
// Copy fields before mutably borrowing `cam.focus`
|
|
let pan_sens = cam.pan_sensitivity;
|
|
let radius = cam.radius;
|
|
cam.focus -= right * delta_motion.x * pan_sens * radius;
|
|
cam.focus += up * delta_motion.y * pan_sens * radius;
|
|
}
|
|
|
|
// Zoom (scroll)
|
|
cam.radius -= scroll_delta * cam.zoom_sensitivity * cam.radius;
|
|
cam.radius = cam.radius.clamp(cam.min_radius, cam.max_radius);
|
|
|
|
// Reset (R key)
|
|
if keys.just_pressed(KeyCode::KeyR) {
|
|
*cam = OrbitCamera::default();
|
|
}
|
|
|
|
// Keep the clip planes proportional to the zoom distance so depth precision
|
|
// stays usable across scales (tiny prop → whole stage grid).
|
|
if let Projection::Perspective(p) = projection.as_mut() {
|
|
p.near = (cam.radius * 0.02).clamp(0.02, 50.0);
|
|
p.far = (cam.radius * 50.0).max(2000.0);
|
|
}
|
|
|
|
*transform = orbit_transform(&cam);
|
|
}
|
|
|
|
fn orbit_transform(cam: &OrbitCamera) -> Transform {
|
|
let rotation = Quat::from_euler(EulerRot::YXZ, cam.yaw, cam.pitch, 0.0);
|
|
let offset = rotation * Vec3::new(0.0, 0.0, cam.radius);
|
|
Transform::from_translation(cam.focus + offset)
|
|
.looking_at(cam.focus, Vec3::Y)
|
|
}
|