aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-11-01 12:40:12 +0300
committerValentin Popov <valentin@popov.link>2026-11-01 12:40:12 +0300
commite8942caa233a663155ffec14c1070ad5dbbb9c7b (patch)
tree3cd95541668969db5139c528e50e4a32aea83ab1
parent315afff2202f3d7cfd7299a29c0ca67a757b9e2e (diff)
downloadfparkan-e8942caa233a663155ffec14c1070ad5dbbb9c7b.tar.xz
fparkan-e8942caa233a663155ffec14c1070ad5dbbb9c7b.zip
fix(render): preserve object-local point lights on models
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert12
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert.spvbin20384 -> 20860 bytes
-rw-r--r--adapters/fparkan-render-vulkan/src/asset_mesh.rs6
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/reflection.rs1
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/resources.rs3
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs12
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs12
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/tests.rs11
-rw-r--r--apps/fparkan-game/src/main.rs69
-rw-r--r--apps/fparkan-game/src/terrain_lod.rs1
-rw-r--r--docs/appendices/knowledge-boundaries.md3
-rw-r--r--docs/reference/environment-effects.md9
-rw-r--r--docs/reference/render-frame.md17
-rw-r--r--docs/tomes/05-render.md10
14 files changed, 136 insertions, 30 deletions
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert
index 8e04536..3accd2e 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert
@@ -45,7 +45,8 @@ layout(std430, set = 0, binding = 5) readonly buffer MaterialSpecular {
struct PointLightRecord {
vec4 position_range;
vec4 rgb_active;
- vec4 coefficients_active;
+ vec3 coefficients;
+ uint owner_scope;
};
layout(std430, set = 0, binding = 6) readonly buffer PointLightBuffer {
@@ -197,6 +198,9 @@ LightingTerms native_material_lighting(
}
for (uint index = 0u; index < point_light_buffer.header.x; ++index) {
PointLightRecord point_light = point_light_buffer.records[index];
+ if (point_light.owner_scope != 0u && point_light.owner_scope != uint(gl_InstanceIndex)) {
+ continue;
+ }
vec3 to_light = point_light.position_range.xyz - world_position;
float distance_squared = dot(to_light, to_light);
float range = point_light.position_range.w;
@@ -209,9 +213,9 @@ LightingTerms native_material_lighting(
float ndotl = dot(normal, light_direction);
if (ndotl > 0.0) {
float t = (range - distance_to_light) / range;
- float attenuation = point_light.coefficients_active.x
- + point_light.coefficients_active.y * t
- + point_light.coefficients_active.z * t * t;
+ float attenuation = point_light.coefficients.x
+ + point_light.coefficients.y * t
+ + point_light.coefficients.z * t * t;
attenuation = max(attenuation, 0.0);
accumulated += ndotl * directional_rgb * point_light.rgb_active.rgb * attenuation;
specular += specular_from_light(
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
index 439e2a0..74adcc7 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
Binary files differ
diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
index d813b91..f25ab89 100644
--- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs
+++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
@@ -859,6 +859,7 @@ fn append_node_fallback_batch(
indices.push(vertex_index);
}
draw_ranges.push(VulkanStaticDrawRange {
+ owner_scope: 0,
first_index,
index_count: u32::from(batch.index_count),
material_index: u16::from(batch.material_index),
@@ -1246,6 +1247,7 @@ fn static_terrain_draw_ranges(
end_face += 1;
}
ranges.push(VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: u32::try_from(start_face)
.map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?
.checked_mul(3)
@@ -1299,6 +1301,7 @@ fn static_model_indices_and_ranges(
indices.push(index);
}
draw_ranges.push(VulkanStaticDrawRange {
+ owner_scope: 0,
first_index,
index_count: u32::from(batch.index_count),
material_index: u16::from(batch.material_index),
@@ -1507,6 +1510,7 @@ mod tests {
assert_eq!(
mesh.draw_ranges,
vec![VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 3,
material_index: 0,
@@ -2038,6 +2042,7 @@ mod tests {
mesh.draw_ranges,
vec![
VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 3,
material_index: 0,
@@ -2050,6 +2055,7 @@ mod tests {
VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
},
VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 3,
index_count: 3,
material_index: 7,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/reflection.rs b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
index 1a9c9ae..6eb6c67 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/reflection.rs
@@ -406,6 +406,7 @@ mod tests {
],
indices: vec![0, 1, 2],
draw_ranges: vec![VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 3,
material_index: 0,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/resources.rs b/adapters/fparkan-render-vulkan/src/ffi/resources.rs
index 498a472..449750f 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/resources.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/resources.rs
@@ -687,12 +687,13 @@ pub(super) fn encode_point_lights(
.chain(light.rgb)
.chain([if light.active { 1.0 } else { 0.0 }])
.chain(light.coefficients)
- .chain([if light.active { 1.0 } else { 0.0 }])
{
bytes[offset..offset + std::mem::size_of::<f32>()]
.copy_from_slice(&value.to_ne_bytes());
offset += std::mem::size_of::<f32>();
}
+ bytes[offset..offset + 4].copy_from_slice(&light.owner_scope.to_ne_bytes());
+ offset += 4;
}
debug_assert_eq!(offset, total);
Ok(bytes)
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
index a016645..c4f0264 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
@@ -2105,7 +2105,7 @@ impl VulkanSmokeRenderer {
1,
range.first_index,
0,
- 0,
+ range.owner_scope,
);
}
device.device().cmd_end_render_pass(command_buffer);
@@ -2898,6 +2898,7 @@ mod tests {
};
let mut ranges = vec![
VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 3,
material_index: 7,
@@ -2909,6 +2910,7 @@ mod tests {
alpha_test_reference: 17,
},
VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 3,
index_count: 3,
material_index: 9,
@@ -3066,6 +3068,7 @@ mod tests {
#[test]
fn geometry_updates_dirty_world_order_on_activation_and_deactivation() {
let mut ranges = [VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 0,
material_index: 0,
@@ -3253,6 +3256,7 @@ mod tests {
fn native_transparent_order_is_descending_and_keeps_fixed_pass_slots() {
let ranges = (0..5)
.map(|_| super::super::VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 3,
material_index: 0,
@@ -3278,6 +3282,7 @@ mod tests {
fn inactive_world_range_is_not_reused_as_a_sort_slot() {
let ranges = vec![
super::super::VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 3,
material_index: 0,
@@ -3289,6 +3294,7 @@ mod tests {
alpha_test_reference: 0,
},
super::super::VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 3,
index_count: 0,
material_index: 0,
@@ -3300,6 +3306,7 @@ mod tests {
alpha_test_reference: 0,
},
super::super::VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 3,
index_count: 3,
material_index: 0,
@@ -3325,6 +3332,7 @@ mod tests {
fn native_equal_key_insertion_uses_inclusive_midpoint() {
let ranges = (0..4)
.map(|index| super::super::VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: index * 3,
index_count: 3,
material_index: 0,
@@ -3419,6 +3427,7 @@ mod tests {
const WEATHER: usize = 10;
let ranges = (0..11)
.map(|index| VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: index * 3,
index_count: 3,
material_index: 0,
@@ -3478,6 +3487,7 @@ mod tests {
const WEATHER: usize = 10;
let mut ranges = (0..11)
.map(|index| VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: index * 3,
index_count: 3,
material_index: 0,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
index 90fd7b4..9fd0582 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
@@ -158,6 +158,8 @@ impl Default for VulkanDirectionalLight {
/// One point light in the frame lighting contract.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VulkanPointLight {
+ /// Zero lights the world; a nonzero scope lights only matching object ranges.
+ pub owner_scope: u32,
/// World-space position.
pub position: [f32; 3],
/// Light RGB multiplier.
@@ -173,6 +175,7 @@ pub struct VulkanPointLight {
impl Default for VulkanPointLight {
fn default() -> Self {
Self {
+ owner_scope: 0,
position: [0.0; 3],
rgb: [0.0; 3],
range: 0.0,
@@ -201,7 +204,7 @@ pub const VULKAN_FRAME_SLOT_COUNT: usize = 2;
/// `uvec4` header bytes for the dynamic point-light storage buffer.
pub const VULKAN_POINT_LIGHT_HEADER_BYTES: usize = 16;
-/// Three `vec4` records per dynamically uploaded point light.
+/// Two vec4 records, three attenuation floats and one u32 owner scope.
pub const VULKAN_POINT_LIGHT_RECORD_BYTES: usize = 3 * 4 * std::mem::size_of::<f32>();
/// Bytes occupied by one material's base and overlay specular records.
@@ -444,6 +447,8 @@ pub struct VulkanDynamicDrawRange {
/// One indexed triangle-list draw retained from an original mesh batch.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VulkanStaticDrawRange {
+ /// Root-object lighting scope; zero denotes terrain or unowned geometry.
+ pub owner_scope: u32,
/// First index in the shared index buffer.
pub first_index: u32,
/// Number of indices in this draw.
@@ -787,6 +792,7 @@ impl VulkanStaticMesh {
],
indices: vec![0, 1, 2],
draw_ranges: vec![VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 3,
material_index: 0,
@@ -872,6 +878,7 @@ mod static_mesh_tests {
VulkanDirectionalLight::default(),
],
point_light: VulkanPointLight {
+ owner_scope: 0,
position: [28.0, 29.0, 30.0],
rgb: [31.0, 32.0, 33.0],
range: 34.0,
@@ -1073,6 +1080,7 @@ mod static_mesh_tests {
vertices: VulkanStaticMesh::smoke_triangle().vertices,
indices: vec![0, 1],
draw_ranges: vec![VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 2,
material_index: 0,
@@ -1105,6 +1113,7 @@ mod static_mesh_tests {
fn material_descriptors_follow_source_batch_selectors() {
let ranges = [
VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 3,
material_index: 7,
@@ -1116,6 +1125,7 @@ mod static_mesh_tests {
alpha_test_reference: 0,
},
VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 3,
index_count: 3,
material_index: 2,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/tests.rs b/adapters/fparkan-render-vulkan/src/ffi/tests.rs
index 4b2f7d2..8dda737 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/tests.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/tests.rs
@@ -518,8 +518,9 @@ fn fragment_shader_dispatches_all_four_material_modes() {
}
#[test]
-fn point_light_storage_payload_is_counted_and_packed_as_three_vec4s() {
+fn point_light_storage_payload_preserves_integer_owner_scope_and_stride() {
let lights = [VulkanPointLight {
+ owner_scope: 0x1234_5678,
position: [1.0, 2.0, 3.0],
rgb: [4.0, 5.0, 6.0],
range: 7.0,
@@ -532,7 +533,7 @@ fn point_light_storage_payload_is_counted_and_packed_as_three_vec4s() {
VULKAN_POINT_LIGHT_HEADER_BYTES + VULKAN_POINT_LIGHT_RECORD_BYTES
);
assert_eq!(u32::from_ne_bytes(bytes[0..4].try_into().unwrap()), 1);
- let values = (0..12)
+ let values = (0..11)
.map(|index| {
let offset = VULKAN_POINT_LIGHT_HEADER_BYTES + index * std::mem::size_of::<f32>();
f32::from_ne_bytes(bytes[offset..offset + 4].try_into().unwrap())
@@ -540,7 +541,11 @@ fn point_light_storage_payload_is_counted_and_packed_as_three_vec4s() {
.collect::<Vec<_>>();
assert_eq!(
values,
- vec![1.0, 2.0, 3.0, 7.0, 4.0, 5.0, 6.0, 1.0, 8.0, 9.0, 10.0, 1.0]
+ vec![1.0, 2.0, 3.0, 7.0, 4.0, 5.0, 6.0, 1.0, 8.0, 9.0, 10.0]
+ );
+ assert_eq!(
+ u32::from_ne_bytes(bytes[60..64].try_into().unwrap()),
+ 0x1234_5678
);
let many = vec![VulkanPointLight::default(); 33];
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index 0d4fd0c..80585e1 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -723,6 +723,7 @@ struct AmbientEffectMeshPrimitive {
#[derive(Clone, Debug)]
struct ControlEffectSlot {
+ light_owner_scope: u32,
time_mode: u32,
header_flags: u32,
random_instance_key: ControlEffectInstanceKey,
@@ -2757,6 +2758,7 @@ fn append_environment_range(
let range_index = mesh.draw_ranges.len();
mesh.draw_ranges
.push(fparkan_render_vulkan::VulkanStaticDrawRange {
+ owner_scope: 0,
first_index,
index_count,
material_index,
@@ -5384,6 +5386,11 @@ fn update_control_point_lights(
}
lights.push((
VulkanPointLight {
+ owner_scope: if frame.native_flags & 0x8000_0000 != 0 {
+ slot.light_owner_scope
+ } else {
+ 0
+ },
position: frame.position,
rgb: [frame.color[0], frame.color[1], frame.color[2]],
range: frame.range,
@@ -5429,12 +5436,12 @@ fn report_control_point_lights_once(
*reported = true;
}
-fn ordinary_control_point_lights(
+fn active_control_point_lights(
raw_control_lights: &[(VulkanPointLight, u32)],
) -> Vec<VulkanPointLight> {
raw_control_lights
.iter()
- .filter(|(light, flags)| light.active && flags & 0x8000_0000 == 0)
+ .filter(|(light, _)| light.active)
.map(|(light, _)| *light)
.collect()
}
@@ -5494,6 +5501,11 @@ fn append_initial_control_effect_slots(
let Some(point_list) = control.point_list.as_ref() else {
return Ok(());
};
+ let light_owner_scope = u32::try_from(object_index)
+ .ok()
+ .and_then(|index| index.checked_add(1))
+ .ok_or_else(|| "Control light owner exceeds GPU scope range".to_string())?;
+ let initial_range_count = mesh.draw_ranges.len();
let mut unsupported_commands = 0;
let mut state_dependent_commands = 0;
for row in
@@ -5620,6 +5632,7 @@ fn append_initial_control_effect_slots(
for (command_index, command) in document.commands().iter().enumerate() {
if ambient_effect_op1_supported(command) || command.opcode == FxOpcode::Op2 {
let slot = ControlEffectSlot {
+ light_owner_scope,
time_mode: header.time_mode,
header_flags: header.flags,
random_instance_key,
@@ -5847,6 +5860,9 @@ fn append_initial_control_effect_slots(
});
}
}
+ for range in &mut mesh.draw_ranges[initial_range_count..] {
+ range.owner_scope = light_owner_scope;
+ }
eprintln!("render_coverage control_fx_unsupported={unsupported_commands} control_fx_requires_game_state={state_dependent_commands}");
Ok(())
}
@@ -7710,6 +7726,7 @@ fn project_basement_foundation(
.ok_or_else(|| "basement terrain draw range exceeds u32".to_string())?;
} else {
mesh.draw_ranges.push(VulkanStaticDrawRange {
+ owner_scope: 0,
first_index,
index_count: 3,
material_index,
@@ -8403,6 +8420,10 @@ fn static_preview_mesh_and_materials(
.map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
let component_handedness = native_msh_handedness(transform, mission_root.scale);
for range in &mut component.draw_ranges {
+ range.owner_scope = u32::try_from(object_index)
+ .ok()
+ .and_then(|index| index.checked_add(1))
+ .ok_or_else(|| "MSH light owner exceeds GPU scope range".to_string())?;
range.pipeline_state.cull =
native_msh_cull_mode(range.batch_flags, component_handedness);
}
@@ -10019,7 +10040,7 @@ impl StaticVulkanApp {
.control_clock_target_ms
.is_none_or(|target_ms| control_clock_ms >= target_ms),
);
- let control_vulkan_lights = ordinary_control_point_lights(&control_point_lights);
+ let control_vulkan_lights = active_control_point_lights(&control_point_lights);
let effect_updates = update_ambient_effect_geometry(
&mut self.ambient_effect_slots,
&self.component_pose_buffers,
@@ -10433,6 +10454,7 @@ impl StaticVulkanApp {
.iter()
.find(|light| light.active)
.map(|light| VulkanPointLight {
+ owner_scope: 0,
position: light.position,
rgb: [light.color[0], light.color[1], light.color[2]],
range: light.range,
@@ -10448,6 +10470,7 @@ impl StaticVulkanApp {
.iter()
.filter(|light| light.active)
.map(|light| VulkanPointLight {
+ owner_scope: 0,
position: light.position,
rgb: [light.color[0], light.color[1], light.color[2]],
range: light.range,
@@ -11227,6 +11250,7 @@ mod tests {
positions: &[[f32; 3]],
) -> (VulkanStaticMesh, VulkanStaticDrawRange) {
let range = VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: u32::try_from(positions.len()).expect("fixture length fits u32"),
material_index: 0,
@@ -12484,6 +12508,7 @@ mod tests {
.map(|index| index + environment_vertex_start),
);
let draw_range = |first_index, index_count| VulkanStaticDrawRange {
+ owner_scope: 0,
first_index,
index_count,
material_index: 0,
@@ -13196,6 +13221,7 @@ mod tests {
#[test]
fn draw_range_reorder_remaps_all_material_animation_bindings() {
let range = |first_index, material_index| VulkanStaticDrawRange {
+ owner_scope: 0,
first_index,
index_count: 3,
material_index,
@@ -13261,6 +13287,7 @@ mod tests {
#[test]
fn native_world_transparency_uses_sampled_alpha_and_native_batch_flags() {
let range = |blend, batch_flags| VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 3,
material_index: 0,
@@ -13624,6 +13651,7 @@ mod tests {
row_index: 0,
};
let control_slot = ControlEffectSlot {
+ light_owner_scope: 1,
time_mode: 1,
header_flags: 9,
random_instance_key: key,
@@ -13728,6 +13756,7 @@ mod tests {
.collect(),
indices: vec![0, 1, 2],
draw_ranges: vec![VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 3,
material_index: 0,
@@ -16680,6 +16709,7 @@ mod tests {
#[test]
fn op1_control_lights_use_native_phase_owner_transform_and_flags() {
let mut slot = ControlEffectSlot {
+ light_owner_scope: 1,
time_mode: 1,
header_flags: 0,
random_instance_key: ControlEffectInstanceKey {
@@ -16754,6 +16784,10 @@ mod tests {
assert_eq!(light.rgb, [0.25, 0.5, 0.75]);
assert_eq!(light.range, 15.0);
assert_eq!(light.coefficients, [1.0, 0.5, 0.25]);
+ assert_eq!(
+ light.owner_scope, 1,
+ "native local light reaches its owning root"
+ );
assert_eq!(flags, 0xA000_0000);
assert_eq!(
[
@@ -16874,8 +16908,9 @@ mod tests {
}
#[test]
- fn op1_generic_diffuse_list_excludes_bit31_and_clears_inactive_lights() {
- let light = |position, active| VulkanPointLight {
+ fn op1_diffuse_list_retains_owner_lights_and_clears_inactive_lights() {
+ let light = |position, owner_scope, active| VulkanPointLight {
+ owner_scope,
position,
rgb: [0.25, 0.5, 0.75],
range: 15.0,
@@ -16883,15 +16918,21 @@ mod tests {
active,
};
let raw_records = vec![
- (light([1.0, 2.0, 3.0], true), 0xA000_0000),
- (light([4.0, 5.0, 6.0], true), 0x2000_0000),
- (light([7.0, 8.0, 9.0], true), 0x8000_0000),
- (light([10.0, 11.0, 12.0], false), 0x0000_0000),
+ (light([1.0, 2.0, 3.0], 5, true), 0xA000_0000),
+ (light([4.0, 5.0, 6.0], 0, true), 0x2000_0000),
+ (light([7.0, 8.0, 9.0], 2, true), 0x8000_0000),
+ (light([10.0, 11.0, 12.0], 0, false), 0x0000_0000),
];
- let ordinary = ordinary_control_point_lights(&raw_records);
- assert_eq!(ordinary.len(), 1);
- assert_eq!(ordinary[0].position, [4.0, 5.0, 6.0]);
+ let active = active_control_point_lights(&raw_records);
+ assert_eq!(active.len(), 3);
+ assert_eq!(
+ active
+ .iter()
+ .map(|light| light.owner_scope)
+ .collect::<Vec<_>>(),
+ [5, 0, 2]
+ );
assert_eq!(raw_records.len(), 4);
assert_eq!(
raw_records
@@ -16901,9 +16942,9 @@ mod tests {
vec![0xA000_0000, 0x2000_0000, 0x8000_0000, 0]
);
- assert!(ordinary_control_point_lights(&[]).is_empty());
+ assert!(active_control_point_lights(&[]).is_empty());
assert!(
- ordinary_control_point_lights(&[(light([13.0, 14.0, 15.0], false), 0x2000_0000)])
+ active_control_point_lights(&[(light([13.0, 14.0, 15.0], 0, false), 0x2000_0000)])
.is_empty()
);
}
diff --git a/apps/fparkan-game/src/terrain_lod.rs b/apps/fparkan-game/src/terrain_lod.rs
index 190f660..1dd707d 100644
--- a/apps/fparkan-game/src/terrain_lod.rs
+++ b/apps/fparkan-game/src/terrain_lod.rs
@@ -233,6 +233,7 @@ mod tests {
#[test]
fn cell_ranges_keep_material_state_and_separate_generated_bands() {
let range = VulkanStaticDrawRange {
+ owner_scope: 0,
first_index: 0,
index_count: 12,
material_index: 7,
diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md
index baf5ed5..103769c 100644
--- a/docs/appendices/knowledge-boundaries.md
+++ b/docs/appendices/knowledge-boundaries.md
@@ -87,6 +87,9 @@ Raw queue clock задаёт фазу волн отдельно от времеÐ
Control Op1 пока использует цвет и радиус без случайных добавок команды;
это явное приближение считается в `render_coverage control_op1_jitter_unsupported`.
Масштаб радиуса длиной преобразованного направления и нижняя замена 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 0e709d9..2a17838 100644
--- a/docs/reference/environment-effects.md
+++ b/docs/reference/environment-effects.md
@@ -350,9 +350,12 @@ Renderer preview поддерживает FXID-команды Op3 modes 0–2, O
Op3 mode0 строит camera-facing поверхность из анимируемых локальных позиции и
масштаба; Op7/Op8 обновляют отдельные элементы particle pool; Op9 создаёт
индексированную полусферическую сетку. Op1 вычисляется отдельно как список
-point lights: selector 1 и записи без `flags & 0x80000000` входят в общий
-diffuse light set. Записи с этим битом сохраняют native flags для специальных
-путей и projected shadows, но не добавляются в общий diffuse список.
+point lights: selector 1 без `flags & 0x80000000` освещает мир, а с этим битом —
+собственное строение или юнит. Локальный источник нельзя удалять из освещения
+MSH вместе с исключением из общего списка рельефа. Preview сохраняет исходные
+flags для projected shadows и передаёт в renderer область действия корневого
+объекта. Область ноль означает общий свет; у локального источника и диапазонов
+его модели одинаковый ненулевой идентификатор.
Body discriminator Op3 читается из первого DWORD `FxCommand.raw_body`; offsets
в evaluator-е относительны к данным после четырёхбайтового command word. Mode 1
diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md
index ebe3971..8bd548f 100644
--- a/docs/reference/render-frame.md
+++ b/docs/reference/render-frame.md
@@ -126,6 +126,23 @@ RVA `0xF6E0`, преобразует направление текущей маÑ
число таких Control-команд как `control_op1_jitter_unsupported` и использует
их авторские значения. У синего источника в проверенном захвате добавки нулевые.
+Флаг источника `0x80000000` отделяет локальный свет объекта от общего света
+мира. В эталонной `Terrain.dll` (SHA-256
+`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead`)
+`CLightManager` RVA `0x4C1D0` ведёт отдельные числа всех активных и общих
+источников; второй счётчик исключает этот бит. Живой вход `CShade` RVA
+`0x2E4A0` подтверждает различие: завод получает жёлтый источник с flags
+`0xA0000000`, а рельеф — только общие источники. Для выбранной вершины крыши
+native diffuse `1,205386/1,095748/1,893271` и specular после переноса переполнения
+`0,160597/0,147096/0,358346` воспроизводятся формулой shader с теми же входами
+с отклонением менее `0,0002`. Менять формулу блика для этого дефекта не нужно.
+
+В Vulkan каждый свет хранит `owner_scope` в последнем DWORD 48-байтовой
+SSBO-записи; область диапазона передаётся через `firstInstance`. Нулевая
+область света доступна всем диапазонам, ненулевая — только совпадающему
+владельцу. Terrain и геометрия без владельца имеют область ноль. Один общий
+буфер сохраняется; отдельные наборы света для каждого строения не создаются.
+
## Элемент отрисовки
Подготовленный элемент отрисовки содержит:
diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md
index 7cbe7d7..1329268 100644
--- a/docs/tomes/05-render.md
+++ b/docs/tomes/05-render.md
@@ -854,9 +854,13 @@ emit не должен повторно менять игровое состоя
gate. Type 4 point list задаёт локальную матрицу; текущие MSH owner и mounted
node poses входят в transform до локальной FX basis. Реализованные visuals
охватывают Op3 modes 0–2, Op4, Op7/8 particle pools и Op9 generated mesh. Op1
-selector 1 добавляет point lights без native flag `0x80000000` в общий diffuse
-список; остальные active records сохраняют flags для специальных путей и
-projected shadows. Радиус источника масштабируется длиной его преобразованного
+selector 1 добавляет point lights в освещение. Native flag `0x80000000`
+ограничивает источник собственным объектом: такая лампа освещает завод, но
+не рельеф вокруг него. Это не запрет на обычный расчёт света. Потеря локального
+жёлтого источника над трубой делала крышу завода заметно холоднее оригинала.
+Renderer сопоставляет владельца лампы и диапазона MSH; общие источники
+доступны всем поверхностям. Исходные flags сохраняются также для projected
+shadows. Радиус источника масштабируется длиной его преобразованного
направления до нормализации. Например, локальный радиус 7 при масштабе узла
около 4,85 освещает область радиусом около 34. Потеря этого множителя оставляла
синий свет завода почти незаметным. Неположительный итог заменяется на 0,01.