feat(fx): animate native point light color and radius jitter
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s

This commit is contained in:
Valentin Popov committed 2026-11-01 13:55:35 +04:00
1 parent e8942caa23
commit bbd31fc253
6 files changed
+222 -22

No files matched your search

+33 -7
View File
@@ -29,8 +29,9 @@ use fparkan_fx::ambient_op3::{
use fparkan_fx::ambient_sound::{Op2SoundCommand, Op2SoundState}; use fparkan_fx::ambient_sound::{Op2SoundCommand, Op2SoundState};
use fparkan_fx::atmosphere::{AtmosphereFrame, TypedAtmosphere}; use fparkan_fx::atmosphere::{AtmosphereFrame, TypedAtmosphere};
use fparkan_fx::environment::{ use fparkan_fx::environment::{
decode_env_lightning_fxid, evaluate_op1_point_light_with_basis, Camera as FxCamera, decode_env_lightning_fxid, evaluate_op1_point_light_with_basis_and_jitter, Camera as FxCamera,
EnvironmentFrame, EnvironmentPrimitive, EnvironmentSystem, PrecipitationKind, ScreenBillboard, EnvironmentFrame, EnvironmentPrimitive, EnvironmentSystem, Op1JitterState, PrecipitationKind,
ScreenBillboard,
}; };
use fparkan_fx::fx_visual::{evaluate_f_gener_ball, evaluate_f_gener_ball_mesh}; use fparkan_fx::fx_visual::{evaluate_f_gener_ball, evaluate_f_gener_ball_mesh};
use fparkan_fx::op7::{ use fparkan_fx::op7::{
@@ -5314,6 +5315,7 @@ fn control_effect_world_from_component_pose_with_random_shift(
fn update_control_point_lights( fn update_control_point_lights(
slots: &mut [ControlEffectSlot], slots: &mut [ControlEffectSlot],
jitter: &mut Op1JitterState,
component_pose_buffers: &[Vec<Option<NodePoseBuffer>>], component_pose_buffers: &[Vec<Option<NodePoseBuffer>>],
component_mount_poses: &[Vec<Option<VulkanNodePose>>], component_mount_poses: &[Vec<Option<VulkanNodePose>>],
random_samples: &HashMap<ControlEffectInstanceKey, ControlEffectFrameSample>, 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][1], matrix[1][1], matrix[2][1]],
[matrix[0][2], matrix[1][2], matrix[2][2]], [matrix[0][2], matrix[1][2], matrix[2][2]],
]; ];
let Some(frame) = evaluate_op1_point_light_with_basis(&slot.command, phase, origin, basis) let Some(frame) = evaluate_op1_point_light_with_basis_and_jitter(
else { &slot.command,
phase,
origin,
basis,
jitter,
) else {
continue; continue;
}; };
if !frame.active || frame.light_selector != 1 { if !frame.active || frame.light_selector != 1 {
@@ -5942,7 +5949,12 @@ fn ambient_effect_op1_supported(command: &FxCommand) -> bool {
else { else {
return false; 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> { fn ambient_effect_pool_capacity(command: &FxCommand) -> Option<usize> {
@@ -9413,6 +9425,7 @@ fn run_static_vulkan_mode(
} }
struct StaticVulkanApp { struct StaticVulkanApp {
op1_jitter: Op1JitterState,
fixed_water_clock_ms: Option<u32>, fixed_water_clock_ms: Option<u32>,
mesh: VulkanStaticMesh, mesh: VulkanStaticMesh,
dynamic_vertex_start: usize, dynamic_vertex_start: usize,
@@ -9561,6 +9574,8 @@ impl StaticVulkanApp {
shadow_last_evidence: ShadowFrameEvidence::default(), shadow_last_evidence: ShadowFrameEvidence::default(),
last_environment_control_clock_ms: 0, last_environment_control_clock_ms: 0,
control_point_lights_diagnostic_reported: false, 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(), simulation_last_tick: Instant::now(),
live_clock_paused_for_zero_extent: false, live_clock_paused_for_zero_extent: false,
simulation_elapsed: Duration::ZERO, simulation_elapsed: Duration::ZERO,
@@ -9616,7 +9631,7 @@ impl StaticVulkanApp {
}) })
}) })
.count(); .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 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()); 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 { for &range_index in &self.world_range_indices {
@@ -10031,6 +10046,7 @@ impl StaticVulkanApp {
); );
let control_point_lights = update_control_point_lights( let control_point_lights = update_control_point_lights(
&mut self.control_point_light_slots, &mut self.control_point_light_slots,
&mut self.op1_jitter,
&self.component_pose_buffers, &self.component_pose_buffers,
&self.component_mount_poses, &self.component_mount_poses,
&control_effect_random_samples, &control_effect_random_samples,
@@ -13875,6 +13891,7 @@ mod tests {
#[test] #[test]
fn live_cpu_updates_sample_materials_effect_rng_and_environment_without_renderer() { fn live_cpu_updates_sample_materials_effect_rng_and_environment_without_renderer() {
let (mut app, key) = clock_test_app(None); let (mut app, key) = clock_test_app(None);
let initial_op1_jitter = app.op1_jitter;
app.render_scene_update() app.render_scene_update()
.expect("an early redraw without a packet is a no-op"); .expect("an early redraw without a packet is a no-op");
assert!(app.pending_scene_update.is_none()); assert!(app.pending_scene_update.is_none());
@@ -13901,6 +13918,8 @@ mod tests {
.vertices .vertices
.is_empty()); .is_empty());
let first_random = control_random_snapshot(&app.control_effect_instance_states[&key]); 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_eq!(first_random.3, Some(SceneSampleId::Live(0)));
assert_ne!(first_random.0, 0); assert_ne!(first_random.0, 0);
assert_ne!(first_random.1, 0); assert_ne!(first_random.1, 0);
@@ -13921,6 +13940,10 @@ mod tests {
first_environment_time first_environment_time
); );
assert_eq!(app.mesh.vertices, first_vertices); 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); assert_eq!(app.simulation_update_generation, 1);
let mut next = app.sample_frame_clock(); let mut next = app.sample_frame_clock();
@@ -13939,6 +13962,7 @@ mod tests {
first_random first_random
); );
assert!(app.environment.as_ref().unwrap().time_seconds > first_environment_time); assert!(app.environment.as_ref().unwrap().time_seconds > first_environment_time);
assert_ne!(app.op1_jitter, first_op1_jitter);
assert_eq!( assert_eq!(
app.pending_scene_update app.pending_scene_update
.as_ref() .as_ref()
@@ -16757,6 +16781,7 @@ mod tests {
); );
assert!(update_control_point_lights( assert!(update_control_point_lights(
&mut [inactive], &mut [inactive],
&mut Op1JitterState::new(1, 1),
&poses, &poses,
&mounts, &mounts,
&random_samples, &random_samples,
@@ -16773,6 +16798,7 @@ mod tests {
); );
let lights = update_control_point_lights( let lights = update_control_point_lights(
std::slice::from_mut(&mut slot), std::slice::from_mut(&mut slot),
&mut Op1JitterState::new(1, 1),
&poses, &poses,
&mounts, &mounts,
&random_samples, &random_samples,
@@ -16782,7 +16808,7 @@ mod tests {
let (light, flags) = lights[0]; let (light, flags) = lights[0];
assert_eq!(light.position, [13.0, 22.0, 33.0]); assert_eq!(light.position, [13.0, 22.0, 33.0]);
assert_eq!(light.rgb, [0.25, 0.5, 0.75]); 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.coefficients, [1.0, 0.5, 0.25]);
assert_eq!( assert_eq!(
light.owner_scope, 1, light.owner_scope, 1,
+150 -4
View File
@@ -615,7 +615,7 @@ pub struct PointLightFrame {
pub range: f32, pub range: f32,
/// Native attenuation coefficients `(a0, a1, a2)`. /// Native attenuation coefficients `(a0, a1, a2)`.
pub attenuation: [f32; 3], 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, pub effect_factor: f32,
/// Whether the opcode 1 command is active at `command_time`. /// Whether the opcode 1 command is active at `command_time`.
pub active: bool, pub active: bool,
@@ -627,12 +627,59 @@ pub struct PointLightFrame {
/// `command_time` is resolved by the caller; FX header duration and lifecycle /// `command_time` is resolved by the caller; FX header duration and lifecycle
/// remain with that caller. Native Op1 scales the radius by the transformed /// remain with that caller. Native Op1 scales the radius by the transformed
/// direction length and replaces a non-positive result with 0.01. /// 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] #[must_use]
pub fn evaluate_op1_point_light_with_basis( pub fn evaluate_op1_point_light_with_basis(
command: &FxCommand, command: &FxCommand,
command_time: f32, command_time: f32,
origin: [f32; 3], origin: [f32; 3],
basis: [[f32; 3]; 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> { ) -> Option<PointLightFrame> {
if command.opcode != FxOpcode::Op1 if command.opcode != FxOpcode::Op1
|| !command_time.is_finite() || !command_time.is_finite()
@@ -653,14 +700,27 @@ pub fn evaluate_op1_point_light_with_basis(
|| FLOAT_OFFSETS || FLOAT_OFFSETS
.iter() .iter()
.any(|&offset| read_f32(body, offset).is_none_or(|value| !value.is_finite())) .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; return None;
} }
let jitter = jitter.map(Op1JitterState::sample);
let normalized_time = native_opcode1_interval(body, command_time)?; let normalized_time = native_opcode1_interval(body, command_time)?;
if !normalized_time.is_finite() { if !normalized_time.is_finite() {
return None; 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)?; let (light_selector, native_flags) = opcode1_light_manager_mapping(sample.effect_type)?;
if light_selector != 1 { if light_selector != 1 {
return None; return None;
@@ -681,8 +741,6 @@ pub fn evaluate_op1_point_light_with_basis(
.map(|component| component * component) .map(|component| component * component)
.sum::<f32>() .sum::<f32>()
.sqrt(); .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; let scaled_range = sample.range * direction_length;
if !direction_length.is_finite() || !scaled_range.is_finite() { if !direction_length.is_finite() || !scaled_range.is_finite() {
return None; return None;
@@ -2981,6 +3039,94 @@ mod tests {
assert_eq!(sample.range, 0.01); assert_eq!(sample.range, 0.01);
} }
#[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] #[test]
fn generic_opcode1_keeps_zero_direction_zero() { fn generic_opcode1_keeps_zero_direction_zero() {
let mut command = op1_test_command(5, 0.0, 1.0); let mut command = op1_test_command(5, 0.0, 1.0);
+5 -4
View File
@@ -84,12 +84,13 @@ Raw queue clock задаёт фазу волн отдельно от време
## FXID field-level semantics ## FXID field-level semantics
Control Op1 пока использует цвет и радиус без случайных добавок команды; Control Op1 воспроизводит добавки к RGBA и радиусу двумя сохраняемыми RNG-потоками.
это явное приближение считается в `render_coverage control_op1_jitter_unsupported`. Seeds просмотра `1/1` и общий порядок других FX ещё не совпадают с оригиналом;
это явное приближение считается в `render_coverage control_op1_rng_schedule_approx`.
Масштаб радиуса длиной преобразованного направления и нижняя замена 0,01 восстановлены. Масштаб радиуса длиной преобразованного направления и нижняя замена 0,01 восстановлены.
Локальный флаг `0x80000000` сохраняет свет на MSH владельца, исключая его Локальный флаг `0x80000000` сохраняет свет на MSH владельца, исключая его
из общего света рельефа. Мерцание жёлтого источника над заводом ещё использует из общего света рельефа. Мерцание жёлтого источника над заводом подключено;
авторские цвет и радиус; порядок общего native RNG пока не воспроизводится. конкретная последовательность пока не является эталоном совпадения во времени.
Control Op2 воспроизводит modes 0/2; modes 1/3, body+76 и неподдержанные Control Op2 воспроизводит modes 0/2; modes 1/3, body+76 и неподдержанные
time modes/привязки считаются как `render_coverage control_op2_unsupported`. time modes/привязки считаются как `render_coverage control_op2_unsupported`.
+21 -2
View File
@@ -165,13 +165,32 @@ opcode 1; обе границы включены, а коэффициент вы
Позиция, направление, цвет и range интерполируются по концам команды. Позиция, направление, цвет и range интерполируются по концам команды.
Позиция и направление переводятся через основу родителя; направление после Позиция и направление переводятся через основу родителя; направление после
этого нормализуется, а нулевой вектор остаётся нулевым. Range сохраняет этого нормализуется, а нулевой вектор остаётся нулевым. Радиус умножается на
авторское значение без ограничения снизу. Типы Op1 `1`, `2`, `5`, `6` и `7` длину преобразованного направления; неположительный результат заменяется на
`0,01`. Типы Op1 `1`, `2`, `5`, `6` и `7`
выбирают point-light selector `1`; их native flags сохраняются. Типы `3` и выбирают point-light selector `1`; их native flags сохраняются. Типы `3` и
`4` выбирают другие классы записей LightManager и не попадают в generic список `4` выбирают другие классы записей LightManager и не попадают в generic список
точечного света. У `LightningEffect` выборка тех же полей использует отдельные точечного света. У `LightningEffect` выборка тех же полей использует отдельные
ограничение по длительности FXID и правила безопасной обработки. ограничение по длительности 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 и `decode_env_lightning_fxid` извлекает из FXID длительность, ссылки opcode 3 и
+5 -3
View File
@@ -122,9 +122,11 @@ RVA `0xF6E0`, преобразует направление текущей ма
завода локальный радиус 7 и длина `0x409B1F91` дали радиус `0x4207BB9F`. завода локальный радиус 7 и длина `0x409B1F91` дали радиус `0x4207BB9F`.
Это же значение проверяется Rust-тестом общего Op1 evaluator. Это же значение проверяется Rust-тестом общего Op1 evaluator.
Случайные добавки цвета и радиуса ещё не воспроизводятся; приложение сообщает Случайные добавки к RGBA и радиусу воспроизводятся двумя сохраняемыми потоками;
число таких Control-команд как `control_op1_jitter_unsupported` и использует формула и расход слов внутри Op1 сверены исполнением DLL. Инициализация
их авторские значения. У синего источника в проверенном захвате добавки нулевые. и общий порядок других FX ещё приближённые: такие команды считаются в
`control_op1_rng_schedule_approx`. У синего источника в проверенном захвате
амплитуды нулевые; у жёлтого света над трубой они меняют освещение крыши.
Флаг источника `0x80000000` отделяет локальный свет объекта от общего света Флаг источника `0x80000000` отделяет локальный свет объекта от общего света
мира. В эталонной `Terrain.dll` (SHA-256 мира. В эталонной `Terrain.dll` (SHA-256
+8 -2
View File
@@ -864,8 +864,14 @@ shadows. Радиус источника масштабируется длино
направления до нормализации. Например, локальный радиус 7 при масштабе узла направления до нормализации. Например, локальный радиус 7 при масштабе узла
около 4,85 освещает область радиусом около 34. Потеря этого множителя оставляла около 4,85 освещает область радиусом около 34. Потеря этого множителя оставляла
синий свет завода почти незаметным. Неположительный итог заменяется на 0,01. синий свет завода почти незаметным. Неположительный итог заменяется на 0,01.
Случайные добавки к цвету и радиусу пока явно считаются в К плавным кривым добавляется мерцание: каждый канал RGBA и радиус получают
`render_coverage control_op1_jitter_unsupported`. случайную поправку от минус половины авторской амплитуды до плюс половины.
Она не накапливается, а заново прибавляется к текущей кривой. Два потока
случайных чисел сохраняются между обновлениями сцены; даже погасший на время
свет расходует их. Повторная отправка кадра не меняет уже выбранный свет.
Формула и порядок выборок внутри команды сверены с исполнением оригинала.
Начальные значения просмотра и общий порядок вызовов других FX пока
приблизительные и считаются в `render_coverage control_op1_rng_schedule_approx`.
Эффект может одновременно рисовать поверхность и издавать звук. Для Op2 Эффект может одновременно рисовать поверхность и издавать звук. Для Op2
приложение создаёт отдельный звуковой источник у каждого экземпляра команды: приложение создаёт отдельный звуковой источник у каждого экземпляра команды: