diff options
| author | Valentin Popov <valentin@popov.link> | 2026-11-01 12:55:35 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-11-01 12:55:35 +0300 |
| commit | bbd31fc25336d6a2e6c5927ad4d7101696f8bd78 (patch) | |
| tree | 27ab5689a6bc414a8bdb3052ea743566cb248faa | |
| parent | e8942caa233a663155ffec14c1070ad5dbbb9c7b (diff) | |
| download | fparkan-bbd31fc25336d6a2e6c5927ad4d7101696f8bd78.tar.xz fparkan-bbd31fc25336d6a2e6c5927ad4d7101696f8bd78.zip | |
feat(fx): animate native point light color and radius jitter
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 40 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/environment.rs | 154 | ||||
| -rw-r--r-- | docs/appendices/knowledge-boundaries.md | 9 | ||||
| -rw-r--r-- | docs/reference/environment-effects.md | 23 | ||||
| -rw-r--r-- | docs/reference/render-frame.md | 8 | ||||
| -rw-r--r-- | docs/tomes/05-render.md | 10 |
6 files changed, 222 insertions, 22 deletions
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 80585e1..aed8dd4 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -29,8 +29,9 @@ use fparkan_fx::ambient_op3::{ use fparkan_fx::ambient_sound::{Op2SoundCommand, Op2SoundState}; use fparkan_fx::atmosphere::{AtmosphereFrame, TypedAtmosphere}; use fparkan_fx::environment::{ - decode_env_lightning_fxid, evaluate_op1_point_light_with_basis, Camera as FxCamera, - EnvironmentFrame, EnvironmentPrimitive, EnvironmentSystem, PrecipitationKind, ScreenBillboard, + decode_env_lightning_fxid, evaluate_op1_point_light_with_basis_and_jitter, Camera as FxCamera, + EnvironmentFrame, EnvironmentPrimitive, EnvironmentSystem, Op1JitterState, PrecipitationKind, + ScreenBillboard, }; use fparkan_fx::fx_visual::{evaluate_f_gener_ball, evaluate_f_gener_ball_mesh}; use fparkan_fx::op7::{ @@ -5314,6 +5315,7 @@ fn control_effect_world_from_component_pose_with_random_shift( fn update_control_point_lights( slots: &mut [ControlEffectSlot], + jitter: &mut Op1JitterState, component_pose_buffers: &[Vec<Option<NodePoseBuffer>>], component_mount_poses: &[Vec<Option<VulkanNodePose>>], random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectFrameSample>, @@ -5352,8 +5354,13 @@ fn update_control_point_lights( [matrix[0][1], matrix[1][1], matrix[2][1]], [matrix[0][2], matrix[1][2], matrix[2][2]], ]; - let Some(frame) = evaluate_op1_point_light_with_basis(&slot.command, phase, origin, basis) - else { + let Some(frame) = evaluate_op1_point_light_with_basis_and_jitter( + &slot.command, + phase, + origin, + basis, + jitter, + ) else { continue; }; if !frame.active || frame.light_selector != 1 { @@ -5942,7 +5949,12 @@ fn ambient_effect_op1_supported(command: &FxCommand) -> bool { else { return false; }; - command.raw_body.len() >= 132 && matches!(effect_type, 1 | 2 | 5 | 6 | 7) + matches!(effect_type, 1 | 2 | 5 | 6 | 7) + && command.raw_body.get(4..132).is_some_and(|bytes| { + bytes.chunks_exact(4).all(|word| { + f32::from_le_bytes(word.try_into().expect("four-byte Op1 field")).is_finite() + }) + }) } fn ambient_effect_pool_capacity(command: &FxCommand) -> Option<usize> { @@ -9413,6 +9425,7 @@ fn run_static_vulkan_mode( } struct StaticVulkanApp { + op1_jitter: Op1JitterState, fixed_water_clock_ms: Option<u32>, mesh: VulkanStaticMesh, dynamic_vertex_start: usize, @@ -9561,6 +9574,8 @@ impl StaticVulkanApp { shadow_last_evidence: ShadowFrameEvidence::default(), last_environment_control_clock_ms: 0, control_point_lights_diagnostic_reported: false, + // shortcut: native initialization and cross-effect RNG ordering remain for G. + op1_jitter: Op1JitterState::new(1, 1), simulation_last_tick: Instant::now(), live_clock_paused_for_zero_extent: false, simulation_elapsed: Duration::ZERO, @@ -9616,7 +9631,7 @@ impl StaticVulkanApp { }) }) .count(); - eprintln!("render_coverage control_op1_jitter_unsupported={randomized_lights} (using authored color and radius; native RNG sampling pending)"); + eprintln!("render_coverage control_op1_rng_schedule_approx={randomized_lights} (native color/radius jitter; fixed preview seeds and cross-effect RNG ordering unverified)"); eprintln!("render_coverage control_op2_supported={} control_op2_unsupported={} (unsupported timing, backend mode or parameters are skipped)", self.control_sound_slots.len(), self.unsupported_control_sounds); eprintln!("render_coverage fog_pass_eligibility_approx={} (global world fog and horizon RGB; native shade-state/color verification pending)", self.world_range_indices.len()); for &range_index in &self.world_range_indices { @@ -10031,6 +10046,7 @@ impl StaticVulkanApp { ); let control_point_lights = update_control_point_lights( &mut self.control_point_light_slots, + &mut self.op1_jitter, &self.component_pose_buffers, &self.component_mount_poses, &control_effect_random_samples, @@ -13875,6 +13891,7 @@ mod tests { #[test] fn live_cpu_updates_sample_materials_effect_rng_and_environment_without_renderer() { let (mut app, key) = clock_test_app(None); + let initial_op1_jitter = app.op1_jitter; app.render_scene_update() .expect("an early redraw without a packet is a no-op"); assert!(app.pending_scene_update.is_none()); @@ -13901,6 +13918,8 @@ mod tests { .vertices .is_empty()); let first_random = control_random_snapshot(&app.control_effect_instance_states[&key]); + let first_op1_jitter = app.op1_jitter; + assert_ne!(first_op1_jitter, initial_op1_jitter); assert_eq!(first_random.3, Some(SceneSampleId::Live(0))); assert_ne!(first_random.0, 0); assert_ne!(first_random.1, 0); @@ -13921,6 +13940,10 @@ mod tests { first_environment_time ); assert_eq!(app.mesh.vertices, first_vertices); + assert_eq!( + app.op1_jitter, first_op1_jitter, + "redraw must not resample light jitter" + ); assert_eq!(app.simulation_update_generation, 1); let mut next = app.sample_frame_clock(); @@ -13939,6 +13962,7 @@ mod tests { first_random ); assert!(app.environment.as_ref().unwrap().time_seconds > first_environment_time); + assert_ne!(app.op1_jitter, first_op1_jitter); assert_eq!( app.pending_scene_update .as_ref() @@ -16757,6 +16781,7 @@ mod tests { ); assert!(update_control_point_lights( &mut [inactive], + &mut Op1JitterState::new(1, 1), &poses, &mounts, &random_samples, @@ -16773,6 +16798,7 @@ mod tests { ); let lights = update_control_point_lights( std::slice::from_mut(&mut slot), + &mut Op1JitterState::new(1, 1), &poses, &mounts, &random_samples, @@ -16782,7 +16808,7 @@ mod tests { let (light, flags) = lights[0]; assert_eq!(light.position, [13.0, 22.0, 33.0]); assert_eq!(light.rgb, [0.25, 0.5, 0.75]); - assert_eq!(light.range, 15.0); + assert_eq!(light.range, 14.500397); assert_eq!(light.coefficients, [1.0, 0.5, 0.25]); assert_eq!( light.owner_scope, 1, diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs index 713a3f9..b0f9d83 100644 --- a/crates/fparkan-fx/src/environment.rs +++ b/crates/fparkan-fx/src/environment.rs @@ -615,7 +615,7 @@ pub struct PointLightFrame { pub range: f32, /// Native attenuation coefficients `(a0, a1, a2)`. pub attenuation: [f32; 3], - /// Radius jitter amplitude at body offset 116; retained but not sampled yet. + /// Radius jitter amplitude at body offset 116 (legacy field name). pub effect_factor: f32, /// Whether the opcode 1 command is active at `command_time`. pub active: bool, @@ -627,6 +627,8 @@ pub struct PointLightFrame { /// `command_time` is resolved by the caller; FX header duration and lifecycle /// remain with that caller. Native Op1 scales the radius by the transformed /// direction length and replaces a non-positive result with 0.01. +/// This samples the authored curves; Control FX use +/// [`evaluate_op1_point_light_with_basis_and_jitter`] for stochastic additions. #[must_use] pub fn evaluate_op1_point_light_with_basis( command: &FxCommand, @@ -634,6 +636,51 @@ pub fn evaluate_op1_point_light_with_basis( origin: [f32; 3], basis: [[f32; 3]; 3], ) -> Option<PointLightFrame> { + evaluate_op1_point_light(command, command_time, origin, basis, None) +} + +/// The two native streams used by Op1 color and radius jitter. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Op1JitterState { + general: u32, + light: u32, +} + +impl Op1JitterState { + #[must_use] + pub const fn new(general: u32, light: u32) -> Self { + Self { general, light } + } + + fn sample(&mut self) -> ([u16; 4], u16) { + let alpha = crate::advance_native_fx_random_state(&mut self.light); + let blue = crate::advance_native_fx_random_state(&mut self.light); + let green = crate::advance_native_fx_random_state(&mut self.light); + let red = crate::advance_native_fx_random_state(&mut self.general); + let radius = crate::advance_native_fx_random_state(&mut self.light); + ([red, green, blue, alpha], radius) + } +} + +/// Adds the native signed uniform jitter before transforming the light radius. +/// Streams advance even outside the command interval and with zero amplitudes. +pub fn evaluate_op1_point_light_with_basis_and_jitter( + command: &FxCommand, + command_time: f32, + origin: [f32; 3], + basis: [[f32; 3]; 3], + jitter: &mut Op1JitterState, +) -> Option<PointLightFrame> { + evaluate_op1_point_light(command, command_time, origin, basis, Some(jitter)) +} + +fn evaluate_op1_point_light( + command: &FxCommand, + command_time: f32, + origin: [f32; 3], + basis: [[f32; 3]; 3], + jitter: Option<&mut Op1JitterState>, +) -> Option<PointLightFrame> { if command.opcode != FxOpcode::Op1 || !command_time.is_finite() || !origin @@ -653,14 +700,27 @@ pub fn evaluate_op1_point_light_with_basis( || FLOAT_OFFSETS .iter() .any(|&offset| read_f32(body, offset).is_none_or(|value| !value.is_finite())) + || (jitter.is_some() + && (92..108) + .step_by(4) + .any(|offset| read_f32(body, offset).is_none_or(|value| !value.is_finite()))) { return None; } + let jitter = jitter.map(Op1JitterState::sample); let normalized_time = native_opcode1_interval(body, command_time)?; if !normalized_time.is_finite() { return None; } - let sample = sample_op1_point_light(body, normalized_time)?; + let mut sample = sample_op1_point_light(body, normalized_time)?; + if let Some((color, radius)) = jitter { + let centered = + |word, amplitude| f32::from(word) * (amplitude * (1.0 / 65_536.0)) - amplitude * 0.5; + for (axis, word) in color.into_iter().enumerate() { + sample.color[axis] += centered(word, read_f32(body, 92 + axis * 4)?); + } + sample.range += centered(radius, read_f32(body, 116)?); + } let (light_selector, native_flags) = opcode1_light_manager_mapping(sample.effect_type)?; if light_selector != 1 { return None; @@ -681,8 +741,6 @@ pub fn evaluate_op1_point_light_with_basis( .map(|component| component * component) .sum::<f32>() .sqrt(); - // shortcut: Op1 color/radius jitter is omitted; add native RNG sampling - // when the reported randomized Control lights are handled in phase R4. let scaled_range = sample.range * direction_length; if !direction_length.is_finite() || !scaled_range.is_finite() { return None; @@ -2982,6 +3040,94 @@ mod tests { } #[test] + fn opcode1_jitter_matches_native_color_words_and_advances_outside_the_interval() { + let mut command = op1_test_command(7, 0.0, 1.0); + let mut body = command.raw_body.to_vec(); + for (start, values) in [ + (60, [0.25_f32, 0.5, 0.75, 1.0]), + (76, [0.75, 1.0, 1.25, 1.5]), + (92, [0.5, 1.0, 2.0, 4.0]), + ] { + body[start..start + 16].copy_from_slice(&values.map(f32::to_le_bytes).concat()); + } + body[48..60].copy_from_slice(&[1.0_f32, 0.0, 0.0].map(f32::to_le_bytes).concat()); + body[116..120].copy_from_slice(&4.0_f32.to_le_bytes()); + command.raw_body = Arc::from(body); + let mut state = super::Op1JitterState::new(0x12345678, 0x89abcdef); + // Effect.dll+F6E0, CW007F, identity owner; native FastProc length is approximate. + let native = [ + ( + [0x3ef7de00, 0x3f310300, 0x3f123400, 0x3ef50000], + 0x4150a0fb, + 0xb7debec4, + 0xa29eea1f, + ), + ( + [0x3f08dc80, 0x3ec15400, 0x3fd95800, 0xbe011000], + 0x417a34ee, + 0x91b9ca56, + 0xa8f3b8a6, + ), + ( + [0x3f06e480, 0x3f190600, 0x3f885b00, 0x3fcb8c00], + 0x4181f650, + 0x4dc90515, + 0x2fd33350, + ), + ( + [0x3f508380, 0x3f2a5100, 0x40099880, 0x3f8d3400], + 0x41c31747, + 0x610747e3, + 0xf8ebfdc3, + ), + ( + [0x3f2f2100, 0x3f56ed00, 0x3f0c9c00, 0x3f9e3000], + 0x417d7bd4, + 0xde42eec1, + 0xb60ffd79, + ), + ]; + for (phase, (color, radius, general, light)) in + [0.25, 0.5, 0.75, 1.25, 0.5].into_iter().zip(native) + { + let frame = super::evaluate_op1_point_light_with_basis_and_jitter( + &command, + phase, + [0.0; 3], + identity_basis(), + &mut state, + ); + if phase > 1.0 { + assert!(frame.is_none()); + } else { + let frame = frame.unwrap(); + assert_eq!(frame.color.map(f32::to_bits), color); + let native_radius = f32::from_bits(radius); + assert!((frame.range - native_radius).abs() < native_radius * 0.00013); + } + assert_eq!((state.general, state.light), (general, light)); + } + } + + #[test] + fn opcode1_jitter_rejects_nonfinite_amplitudes_without_consuming_random_state() { + let mut command = op1_test_command(7, 0.0, 1.0); + let mut body = command.raw_body.to_vec(); + body[92..96].copy_from_slice(&f32::NAN.to_le_bytes()); + command.raw_body = Arc::from(body); + let mut state = super::Op1JitterState::new(123, 456); + assert!(super::evaluate_op1_point_light_with_basis_and_jitter( + &command, + 0.5, + [0.0; 3], + identity_basis(), + &mut state + ) + .is_none()); + assert_eq!((state.general, state.light), (123, 456)); + } + + #[test] fn generic_opcode1_keeps_zero_direction_zero() { let mut command = op1_test_command(5, 0.0, 1.0); let mut body = command.raw_body.to_vec(); diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index 103769c..ed821e4 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -84,12 +84,13 @@ Raw queue clock задаёт фазу волн отдельно от времеÐ ## FXID field-level semantics -Control Op1 пока использует цвет и радиус без случайных добавок команды; -это явное приближение считается в `render_coverage control_op1_jitter_unsupported`. +Control Op1 воспроизводит добавки к RGBA и радиусу двумя сохраняемыми RNG-потоками. +Seeds просмотра `1/1` и общий порядок других FX ещё не совпадают с оригиналом; +это явное приближение считается в `render_coverage control_op1_rng_schedule_approx`. Масштаб радиуса длиной преобразованного направления и нижняя замена 0,01 восстановлены. Локальный флаг `0x80000000` сохраняет свет на MSH владельца, исключая его -из общего света рельефа. Мерцание жёлтого источника над заводом ещё использует -авторские цвет и радиус; порядок общего native RNG пока не воспроизводится. +из общего света рельефа. Мерцание жёлтого источника над заводом подключено; +конкретная последовательность пока не является эталоном совпадения во времени. Control Op2 воспроизводит modes 0/2; modes 1/3, body+76 и неподдержанные time modes/привязки считаются как `render_coverage control_op2_unsupported`. diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md index 2a17838..23115af 100644 --- a/docs/reference/environment-effects.md +++ b/docs/reference/environment-effects.md @@ -165,13 +165,32 @@ opcode 1; обе границы включены, а коэффициент вы Позиция, направление, цвет и range интерполируются по концам команды. Позиция и направление переводятся через основу родителя; направление после -этого нормализуется, а нулевой вектор остаётся нулевым. Range сохраняет -авторское значение без ограничения снизу. Типы Op1 `1`, `2`, `5`, `6` и `7` +этого нормализуется, а нулевой вектор остаётся нулевым. Радиус умножается на +длину преобразованного направления; неположительный результат заменяется на +`0,01`. Типы Op1 `1`, `2`, `5`, `6` и `7` выбирают point-light selector `1`; их native flags сохраняются. Типы `3` и `4` выбирают другие классы записей LightManager и не попадают в generic список точечного света. У `LightningEffect` выборка тех же полей использует отдельные ограничение по длительности FXID и правила безопасной обработки. +Generic Control Op1 добавляет к интерполированным RGBA и радиусу равномерный +шум `U × (amplitude / 65536) − amplitude / 2`, где `U` — очередное 16-битное +слово native FX-генератора. Цветовые амплитуды лежат в body `92..108`, +амплитуда радиуса — в `116..120`. Радиус с этой добавкой затем умножается на +длину преобразованного направления и ограничивается снизу `0,01`. +В [эталонной Effect.dll](original-binaries.md) RVA `0xF6E0` расходует слова +в порядке A, B, G, R, радиус: R использует общий поток DLL, остальные четыре +выборки — отдельный поток Op1. Нулевая амплитуда и выход за интервал команды +не отменяют расход этих слов. Четыре активные выборки RGBA и состояния обоих +генераторов после пяти вызовов совпали побитно с DLL при CW `0x007F`; +сравнение радиуса учитывает известное приближение native FastProc длины. + +Приложение сохраняет состояния между обновлениями сцены, не пересчитывая их +при повторной отправке кадра. Начальные seeds просмотра равны `1/1`; +инициализация и общий порядок выборок остальных FX ещё не воспроизведены. +Эта граница учитывается в `control_op1_rng_schedule_approx`: мерцание +подключено, но его конкретная последовательность ещё не служит эталоном parity. + ## Молния и звуковые события `decode_env_lightning_fxid` извлекает из FXID длительность, ссылки opcode 3 и diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md index 8bd548f..b3c0be5 100644 --- a/docs/reference/render-frame.md +++ b/docs/reference/render-frame.md @@ -122,9 +122,11 @@ RVA `0xF6E0`, преобразует направление текущей маÑ завода локальный радиус 7 и длина `0x409B1F91` дали радиус `0x4207BB9F`. Это же значение проверяется Rust-тестом общего Op1 evaluator. -Случайные добавки цвета и радиуса ещё не воспроизводятся; приложение сообщает -число таких Control-команд как `control_op1_jitter_unsupported` и использует -их авторские значения. У синего источника в проверенном захвате добавки нулевые. +Случайные добавки к RGBA и радиусу воспроизводятся двумя сохраняемыми потоками; +формула и расход слов внутри Op1 сверены исполнением DLL. Инициализация +и общий порядок других FX ещё приближённые: такие команды считаются в +`control_op1_rng_schedule_approx`. У синего источника в проверенном захвате +амплитуды нулевые; у жёлтого света над трубой они меняют освещение крыши. Флаг источника `0x80000000` отделяет локальный свет объекта от общего света мира. В эталонной `Terrain.dll` (SHA-256 diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 1329268..8c059f2 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -864,8 +864,14 @@ shadows. Радиус источника масштабируется длино направления до нормализации. Например, локальный радиус 7 при масштабе узла около 4,85 освещает область радиусом около 34. Потеря этого множителя оставляла синий свет завода почти незаметным. Неположительный итог заменяется на 0,01. -Случайные добавки к цвету и радиусу пока явно считаются в -`render_coverage control_op1_jitter_unsupported`. +К плавным кривым добавляется мерцание: каждый канал RGBA и радиус получают +случайную поправку от минус половины авторской амплитуды до плюс половины. +Она не накапливается, а заново прибавляется к текущей кривой. Два потока +случайных чисел сохраняются между обновлениями сцены; даже погасший на время +свет расходует их. Повторная отправка кадра не меняет уже выбранный свет. +Формула и порядок выборок внутри команды сверены с исполнением оригинала. +Начальные значения просмотра и общий порядок вызовов других FX пока +приблизительные и считаются в `render_coverage control_op1_rng_schedule_approx`. Эффект может одновременно рисовать поверхность и издавать звук. Для Op2 приложение создаёт отдельный звуковой источник у каждого экземпляра команды: |
