diff --git a/docs/BEAM_RENDERING_MODES.md b/docs/BEAM_RENDERING_MODES.md index decb97e..219a954 100644 --- a/docs/BEAM_RENDERING_MODES.md +++ b/docs/BEAM_RENDERING_MODES.md @@ -1,36 +1,78 @@ -# Beam rendering modes +# Beam presentation experiment -Peraviz exposes two presentation modes in Visual Settings. Renderer choice does not change native GDTF or DMX interpretation. +Peraviz resolves GDTF/MVR and live DMX in the native runtime. The modes below consume the same resolved texture, color, dimmer, beam angle, range, index, and rotation state. They are Peraviz presentation choices and do not add or reinterpret GDTF semantics. -## Modes +## Independent surface projection -- **Volumetric (default):** a full cone with shader-projected shaping for haze shafts, distance attenuation, soft end fade, and selectable Low, Medium, or High quality. Low removes turbulence and reduces raymarch work. -- **Lightweight:** a lower-cost cone/prism path intended for dense scenes or limited GPUs. The name is a renderer mode, not a legacy semantic authority. +An active native gobo selection assigns the cached composed texture to `SpotLight3D.light_projector` in every mode. Godot 4.7 requires shadows for reliable projector output, so Peraviz enables them while a projector is present and restores the prior policy when it is cleared. Projector roll is composed into the saved local optical basis around local `-Z`; it does not rewrite Euler angles, the emitter origin, or the beam direction. The Vector Prism child receives an explicit inverse presentation roll around its documented longitudinal axis, preserving its recovered topology and centerline. -Both modes are children of the rotated `SpotLight3D` renderer anchor and extend along that anchor's local `-Z`. This renderer-child direction corresponds to mapped emitter-local `-Y`, which in turn represents official GDTF Beam source-local `-Z`. These axes are related but are not interchangeable local spaces; see [Coordinate system and transform validation](COORDINATE_SYSTEM.md). +The shadows-only aperture mask is a separate resource. It exists only for an active gobo in Shared Haze + Gobo Shadow and shapes volumetric light through real shadows. It is not the crisp surface-projector path. -The cone/prism mesh is authored on its own local Y axis. A +90-degree X rotation places the mesh's near `+Y` endpoint at the light origin and its length along renderer-child-local `-Z`; mesh translation then extends it from the lens by the configured visual range. Optical rotation and gobo presentation are applied in renderer-child space, without changing the official GDTF source-axis semantics. +## Presentation modes -## Shared renderer parameters +### Vector Prism -The renderer-facing optics contract includes beam angle, visual range, selected near aperture, lens/near offsets, lens shift, softness, radial and longitudinal falloff, intensity, and haze density. Setup-time native Beam profiles preserve official GDTF Beam geometry fields; live native Zoom rows update the physical full angle and normalized position for the exact Beam target. +This is the recovered reference renderer. Its cached vectorized prism represents the gobo silhouette as geometry, with gobo-dependent primitive counts. Indexed and continuous rotation remain parametric and reuse topology. The shared haze is hidden and real fixture SpotLights are used only by active surface projectors. -Beam radius and angle are optical inputs. Visual beam length is a Peraviz presentation choice and is not a GDTF physical property. See [Beam geometry and visual length](BEAM_GEOMETRY_AND_VISUAL_LENGTH.md). +### Shared Haze -Lightweight Prism reuses a normalized mesh and mutates per-instance near/far parameters. Circular Beam types use circular topology, Rectangle uses `RectangleRatio`, and None/Glow do not create a projected custom beam. Official BeamRadius, measured model aperture, and the selected visual near radius remain distinct diagnostic values. +One scene-owned box `FogVolume` represents atmospheric haze. It is auto-sized from the loaded scene bounds with a 5 m margin and reused across mode switches. A standard neutral `FogMaterial` starts at density `0.015`, near-white albedo, and zero emission. Active real SpotLights illuminate this common medium; there are no emitter-owned FogVolumes and no physical gobo masks. The independent surface projector remains active for gobo footprints. -## Gobo presentation boundary +### Shared Haze + Gobo Shadow -The authoritative supported gobo semantics are defined in [GDTF gobo control](GDTF_GOBO_CONTROL.md). For the bounded static seated case, registered masks can provide a vectorized Lightweight prism topology or a shader mask. Renderer orientation and masking are presentation compatibility details, not semantic support for rotation, shake, wheel spin, or moving multi-wheel composition. +This reuses the same shared haze and active real SpotLights. Each active gobo output additionally owns one reusable alpha-scissored, shadows-only aperture mask. Open beams have no mask and do not enable shadows unless another independent requirement exists. The mask and crisp projector use the same authoritative texture and physical rotation. -Volumetric mode keeps a full cone to avoid discontinuous shaft geometry. Shader masks sample outside their UV domain as closed rather than stretching edge pixels. Any visibility floor or contribution blend is a renderer tuning safeguard and must not be described as official gobo transmission. +Godot 4.7 does not apply `light_projector` textures directly to volumetric fog. Shadow maps and the fixed froxel grid can make the volumetric gobo softer than the surface footprint, particularly at distance. -## Performance and tuning +### Shader Beam Proxy -- Prefer Volumetric Low for integrated GPUs and increase quality only after measuring frame time. -- Prefer Lightweight for maximum throughput or large fixture counts. -- Reuse meshes, materials, masks, and registered targets; do not rebuild them per DMX frame. -- Keep spotlight footprint projection optional. It is not an authoritative gobo-motion path. -- Treat beam intensity, haze, softness, and falloff controls as Peraviz visual tuning, not GDTF semantic values. +This experimental Peraviz presentation is inspired by established real-time DMX-previsualization architecture, but is not an Unreal Engine dependency and is not part of GDTF or MVR. It uses one shared normalized low-poly stack of frustum slices and a per-instance additive shader. Length, aperture, angle, color, intensity, composed gobo texture, scale, and rotation are uniforms or instance transforms; gobo motion never vectorizes or rebuilds topology. -Current limitations include advanced photometry, Focus, Iris, Frost, prisms, shutters, gobo motion, and high-quality volumetric rectangular rendering. Consult the [GDTF support matrix](gdtf-support-matrix.md) before expanding that list. +The proxy selects a bounded 4, 8, or 12 slice tier: wide beams use fewer visible slices and narrow beams use more. A Forward+ scene-depth texture softly clips fragments behind the closest opaque surface. Transparent-depth ordering and unusual camera projections can still expose clipping artifacts, so this remains an experimental approximation rather than physical volumetric scattering. + +## Shared haze and range + +The presets keep global Environment fog density at zero; the local shared FogVolume supplies density. Both haze modes enable Environment volumetric fog and use a 110 m fog length. The longer range helps overview cameras see fixtures whose beams commonly reach about 75 m, but distributes the fixed froxel depth over more distance and reduces detail. Froxel resolution remains unchanged. + +The shared volume is updated when loaded scene bounds or haze settings change, not for emitter DMX updates. The historical per-emitter FogVolume implementation remains internal for reference but is not selected by any user-facing mode or preset. + +## Ownership and diagnostics + +Presentation ownership is explicit: + +- Vector Prism owns only the vector shaft. +- Shared haze owns one scene-level FogVolume. +- A main realtime SpotLight contributes to haze only when its authoritative physical output is active and the selected haze mode requires it. +- Surface projection owns the crisp projector and its Godot 4.7 shadow dependency. +- The physical mask belongs only to Shared Haze + Gobo Shadow. +- Shader Beam Proxy owns one cached proxy instance per relevant emitter and requires neither shared haze nor a realtime SpotLight for its shaft. + +`[peraviz-presentation]` reports native target/output/emitter ownership, authoritative active outputs and emitters, cached fixture lights, vector resources, proxy visibility/creation/parameter/texture counters, the shared haze, emitter FogVolumes, surface projectors, masks, realtime SpotLights, and shadowed SpotLights. Counts use stable target/emitter identities rather than per-frame SceneTree scans. + +| Mode | Shaft resource scaling | Gobo motion | Native SpotLight for shaft | +| --- | --- | --- | --- | +| Vector Prism | One cached gobo-dependent mesh per emitter | Parametric rotation; texture selection can change topology | No | +| Shared Haze | One scene FogVolume plus active native lights | Surface projector only | Yes | +| Shared Haze + Gobo Shadow | Same scene FogVolume plus one mask per active gobo | Parametric mask/projector roll | Yes | +| Shader Beam Proxy | One instance of shared normalized topology per active/reused emitter | Texture/uniform only | No | + +## Current limitations + +- Surface projector correctness depends on real-time shadows in stable Godot 4.7. +- Projector textures do not shape stable Godot volumetric fog. +- Native gobo shadows are limited by froxel and shadow-map resolution. +- Shader proxy depth clipping is screen-space and does not provide physical shadowing or robust transparent-object occlusion. +- Lens texture/mask presentation remains independent future work. +- A scene with many genuinely active physical emitters can still make native volumetric lighting expensive; future aggregation or culling is outside this experiment. +- The broader live-DMX output/apply CPU bottleneck is separate work and is not solved by changing beam presentation. +- Focus, Frost, Iris, prism, and shutter semantics remain limited exactly as documented in the [GDTF support matrix](gdtf-support-matrix.md). + +## Windows validation checklist + +1. Load the canonical scene without Art-Net and confirm no fixture output resource is active and no missing-metadata errors appear. +2. Start Art-Net and select Vector Prism. Confirm open beams and vector gobo shafts are unchanged, and an asymmetric surface footprint rotates coherently with the prism. +3. Select Shared Haze without changing DMX. Confirm exactly one shared haze, zero emitter FogVolumes, active open shafts in haze, and a crisp gobo surface footprint. +4. Select Shared Haze + Gobo Shadow. Confirm the same haze is reused, active gobo outputs have masks, open outputs do not, and volumetric light reacts to the mask as Godot's resolution permits. +5. Select Shader Beam Proxy and confirm open/gobo shafts use the same surface centerline at two Pan/Tilt orientations and four indexed angles. +6. Switch all four modes repeatedly without a new DMX event; confirm no accumulated drift, stale white footprint, or missing gobo. +7. Capture diagnostics and at least ten settled `--peraviz-perf-trace` lines per experimental mode. diff --git a/docs/release-notes-draft.md b/docs/release-notes-draft.md index 898d6cc..be40b1d 100644 --- a/docs/release-notes-draft.md +++ b/docs/release-notes-draft.md @@ -11,6 +11,8 @@ Changes since the previous Peraviz release. ## New features - Added continuous selected-gobo rotation for exact GDTF `Gobo(n)PosRotate` controls, preserving signed physical speed, indexed-angle handoff, and smooth renderer-local motion without rebuilding gobo resources. +- Added live comparison of Vector Prism, one auto-sized Shared Haze volume, and Shared Haze with physical gobo shadows without changing resolved GDTF or DMX behavior. +- Added an experimental shader beam proxy with shared low-poly topology, texture-driven gobos, bounded adaptive slice tiers, and screen-depth clipping. ## Improvements @@ -29,6 +31,14 @@ Changes since the previous Peraviz release. ## Fixes +- Restored crisp surface gobo projection on Godot 4.7 by managing the projector's required shadow state, and prevented Visual Settings refreshes from replacing held beam intensity with zero when optional metadata is absent. +- Corrected fog shader compilation and kept projector and native-shadow spotlight instances active during DMX playback. +- Restored Vector Gobo Prism as the startup reference, rebound native renderer targets after gobo topology replacement, and changed Fog Volume to an analytic local-beam frustum without forcing global haze. +- Replaced the user-facing per-emitter fog experiment with one auto-sized shared haze volume, synchronized native gobo texture and rotation across surface projectors and shadow masks, and removed stale presentation resources during live mode switching. +- Prevented missing optional gobo metadata from flooding the Godot debugger and limited native haze lights to authoritative active physical outputs. +- Kept rotating gobo footprints on the imported optical centerline by composing roll through the emitter basis instead of Euler angles, and made Visual Settings presets scalable through a dropdown. +- Fixture-generated lights now start fail-closed at zero energy before authoritative output arrives, while normal environment daylight and shadows remain independent. + - Corrected vector gobo silhouettes for complex concave artwork, preserved raster cut-outs and nested islands in prism caps and walls, and aligned asymmetric artwork and indexed rotation with the documented source-image convention. Adaptive cached-beam simplification now reduces large curves more aggressively, removes redundant faces and reverse-turn artifacts, and retains small star-field details. - Prevented beam-optics debug axes from appearing as red shafts during normal playback, stopped Dimmer changes from requesting expensive beam-topology work, and restored beams that first receive a zero Dimmer value before becoming visible. diff --git a/scripts/beam_optics_controller.gd b/scripts/beam_optics_controller.gd index 9981979..e47e425 100644 --- a/scripts/beam_optics_controller.gd +++ b/scripts/beam_optics_controller.gd @@ -61,7 +61,8 @@ static func BuildBeamParams(light: SpotLight3D, beam_angle_deg: float, beam_colo static func BuildGoboControls(controls: Dictionary, visual_settings: Dictionary, defaults: Dictionary) -> Dictionary: var merged_defaults: Dictionary = _build_merged_defaults(defaults) var gobo_controls: Dictionary = controls.duplicate(false) - gobo_controls["prefer_native_fog_projector"] = bool(visual_settings.get("use_native_fog_projector_gobos", true)) + # Only the native-shadow experiment needs the physical alpha-scissored mask plane. + gobo_controls["use_native_shadow_gobo_mask"] = int(visual_settings.get("beam_presentation", 1)) == 2 gobo_controls["gobo_scale"] = float(visual_settings.get("gobo_scale", merged_defaults.get("gobo_scale", 1.0))) gobo_controls["gobo_rotation_deg"] = float(visual_settings.get("gobo_rotation_deg", merged_defaults.get("gobo_rotation_deg", 0.0))) if OS.is_debug_build(): diff --git a/scripts/beam_renderers/gobo_compound_topology.gd b/scripts/beam_renderers/gobo_compound_topology.gd index 524a2b7..3172b26 100644 --- a/scripts/beam_renderers/gobo_compound_topology.gd +++ b/scripts/beam_renderers/gobo_compound_topology.gd @@ -150,7 +150,7 @@ static func _preserves_neighbor_turns(ring: PackedVector2Array, removed_index: i var new_next_turn: float = (next - previous).cross(next_next - next) return _same_nonzero_sign(old_previous_turn, new_previous_turn) and _same_nonzero_sign(old_next_turn, new_next_turn) -static func _same_nonzero_sign(reference: float, candidate: float) -> bool: - if absf(reference) <= 0.0000001: +static func _same_nonzero_sign(reference_turn: float, candidate: float) -> bool: + if absf(reference_turn) <= 0.0000001: return true - return candidate * reference > 0.0 + return candidate * reference_turn > 0.0 diff --git a/scripts/beam_renderers/shader_beam_proxy_controller.gd b/scripts/beam_renderers/shader_beam_proxy_controller.gd new file mode 100644 index 0000000..073d0a2 --- /dev/null +++ b/scripts/beam_renderers/shader_beam_proxy_controller.gd @@ -0,0 +1,167 @@ +extends RefCounted +class_name ShaderBeamProxyController + +const META_KEY := "peraviz_shader_beam_proxy" +const STATE_META := "peraviz_shader_beam_proxy_state" +const SLICE_COUNT := 12 +const RADIAL_SEGMENTS := 12 + +static var _shared_mesh: ArrayMesh +static var _shared_shader: Shader +static var _shared_material: ShaderMaterial +static var _topology_creations := 0 +var _creations := 0 +var _parameter_writes := 0 +var _texture_changes := 0 +var _allocated_ids: Dictionary = {} +var _visible_ids: Dictionary = {} +var _tier_ids: Dictionary = {4: {}, 8: {}, 12: {}} + +func update_for_light(light: SpotLight3D, params: Dictionary, texture: Texture2D) -> Dictionary: + var beam_type: String = str(params.get("beam_type", "Wash")).to_lower() + var intensity_max: float = max(float(params.get("intensity_max", 100.0)), 0.01) + var intensity: float = clamp(float(params.get("scaled_intensity", 0.0)), 0.0, intensity_max) + var visible: bool = beam_type != "none" and beam_type != "glow" and intensity > float(params.get("intensity_visibility_threshold", 0.015)) + var proxy: MeshInstance3D = _get_proxy(light) + if proxy == null and not visible: + return {"changed": false, "parameter_write_count": 0} + var created := false + if proxy == null: + proxy = _create_proxy(light) + created = true + var writes := 0 + if proxy.visible != visible: + proxy.visible = visible + writes += 1 + if not visible: + _set_visibility_diagnostics(light, false, 0) + return {"changed": writes > 0, "resource_created": created, "parameter_write_count": writes} + var beam_range: float = max(float(params.get("beam_range", 0.1)), 0.01) + var beam_angle: float = max(float(params.get("beam_angle", 1.0)), 0.1) + var aperture: float = max(float(params.get("lens_radius", params.get("aperture_radius", 0.0085))), 0.001) + var radius_end: float = aperture + tan(deg_to_rad(beam_angle * 0.5)) * beam_range + var transform_signature: Vector3 = Vector3(radius_end, beam_range, aperture) + var state: Dictionary = proxy.get_meta(STATE_META, {}) + if state.get("transform") != transform_signature: + proxy.position = Vector3(0.0, 0.0, -beam_range * 0.5) + proxy.scale = Vector3(radius_end, radius_end, beam_range) + state["transform"] = transform_signature + writes += 1 + var beam_color: Color = params.get("beam_color", Color.WHITE) + writes += _set_param(proxy, state, "beam_dynamic_state", Color(beam_color.r, beam_color.g, beam_color.b, intensity)) + writes += _set_param(proxy, state, "intensity_max", intensity_max) + writes += _set_param(proxy, state, "near_radius_ratio", clamp(aperture / radius_end, 0.0, 1.0)) + writes += _set_param(proxy, state, "gobo_rotation_deg", float(params.get("gobo_rotation_deg", 0.0))) + writes += _set_param(proxy, state, "gobo_scale", max(float(params.get("gobo_scale", 1.0)), 0.05)) + writes += _set_param(proxy, state, "use_gobo", texture != null) + if state.get("gobo_texture") != texture: + proxy.set_instance_shader_parameter("gobo_texture", texture) + state["gobo_texture"] = texture + _texture_changes += 1 + writes += 1 + var tier: int = 12 if beam_angle <= 10.0 else (8 if beam_angle <= 25.0 else 4) + writes += _set_param(proxy, state, "sample_tier", tier) + state["quality_tier"] = tier + _set_visibility_diagnostics(light, true, tier) + proxy.set_meta(STATE_META, state) + _parameter_writes += writes + return {"changed": writes > 0, "resource_created": created, "parameter_write_count": writes} + +func clear_for_light(light: SpotLight3D) -> void: + var proxy := _get_proxy(light) + if proxy != null: + proxy.queue_free() + light.remove_meta(META_KEY) + var light_id := light.get_instance_id() + _allocated_ids.erase(light_id) + _set_visibility_diagnostics(light, false, 0) + +func hide_for_light(light: SpotLight3D) -> void: + var proxy := _get_proxy(light) + if proxy != null: + proxy.visible = false + _set_visibility_diagnostics(light, false, 0) + +func get_proxy(light: SpotLight3D) -> MeshInstance3D: + return _get_proxy(light) + +func counters() -> Dictionary: + return {"allocated_shader_proxies": _allocated_ids.size(), "active_shader_proxies": _visible_ids.size(), "proxy_resource_creations": _creations, "proxy_topology_creations": _topology_creations, "proxy_topology_rebuilds": 0, "proxy_parameter_writes": _parameter_writes, "proxy_gobo_texture_changes": _texture_changes, "proxy_tier_4": (_tier_ids[4] as Dictionary).size(), "proxy_tier_8": (_tier_ids[8] as Dictionary).size(), "proxy_tier_12": (_tier_ids[12] as Dictionary).size()} + +func _create_proxy(light: SpotLight3D) -> MeshInstance3D: + var proxy := MeshInstance3D.new() + proxy.name = "PeravizShaderBeamProxy" + proxy.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + proxy.mesh = _mesh() + proxy.set_meta("peraviz_shader_beam_proxy_instance", true) + proxy.material_override = _material() + proxy.visible = false + light.add_child(proxy) + light.set_meta(META_KEY, proxy) + _allocated_ids[light.get_instance_id()] = true + _creations += 1 + return proxy + +func _get_proxy(light: SpotLight3D) -> MeshInstance3D: + if light == null or not light.has_meta(META_KEY): + return null + var proxy := light.get_meta(META_KEY) as MeshInstance3D + return proxy if proxy != null and is_instance_valid(proxy) else null + +func _set_param(proxy: MeshInstance3D, state: Dictionary, key: String, value: Variant) -> int: + if state.get(key) == value: + return 0 + proxy.set_instance_shader_parameter(key, value) + state[key] = value + return 1 + +func _set_visibility_diagnostics(light: SpotLight3D, visible: bool, tier: int) -> void: + var light_id := light.get_instance_id() + _visible_ids.erase(light_id) + for tier_ids_item in _tier_ids.values(): + (tier_ids_item as Dictionary).erase(light_id) + if visible: + _visible_ids[light_id] = true + (_tier_ids[tier] as Dictionary)[light_id] = true + +static func _shader() -> Shader: + if _shared_shader == null: + _shared_shader = load("res://scripts/shaders/shader_beam_proxy.gdshader") + return _shared_shader + +static func _material() -> ShaderMaterial: + if _shared_material == null: + _shared_material = ShaderMaterial.new() + _shared_material.shader = _shader() + return _shared_material + +static func _mesh() -> ArrayMesh: + if _shared_mesh != null: + return _shared_mesh + var vertices := PackedVector3Array() + var uvs := PackedVector2Array() + var uv2s := PackedVector2Array() + var indices := PackedInt32Array() + for slice_index in range(SLICE_COUNT): + var depth := (float(slice_index) + 0.5) / float(SLICE_COUNT) + var base := vertices.size() + vertices.append(Vector3(0.0, 0.0, 0.5 - depth)) + uvs.append(Vector2(0.5, 0.5)) + uv2s.append(Vector2(depth, float(slice_index))) + for radial_index in range(RADIAL_SEGMENTS + 1): + var angle := TAU * float(radial_index) / float(RADIAL_SEGMENTS) + vertices.append(Vector3(cos(angle) * 0.5, sin(angle) * 0.5, 0.5 - depth)) + uvs.append(Vector2(cos(angle), sin(angle)) * 0.5 + Vector2(0.5, 0.5)) + uv2s.append(Vector2(depth, float(slice_index))) + for radial_index in range(RADIAL_SEGMENTS): + indices.append_array(PackedInt32Array([base, base + radial_index + 1, base + radial_index + 2])) + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = vertices + arrays[Mesh.ARRAY_TEX_UV] = uvs + arrays[Mesh.ARRAY_TEX_UV2] = uv2s + arrays[Mesh.ARRAY_INDEX] = indices + _shared_mesh = ArrayMesh.new() + _shared_mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + _topology_creations += 1 + return _shared_mesh diff --git a/scripts/beam_renderers/volumetric_beam_renderer.gd b/scripts/beam_renderers/volumetric_beam_renderer.gd index 94629c9..7b31ea2 100644 --- a/scripts/beam_renderers/volumetric_beam_renderer.gd +++ b/scripts/beam_renderers/volumetric_beam_renderer.gd @@ -11,6 +11,12 @@ const DYNAMIC_STATE_META_KEY: String = "peraviz_beam_dynamic_state" const DYNAMIC_STATE_APPLIED_META_KEY: String = "peraviz_beam_dynamic_state_applied" const INTENSITY_MAX_DESIRED_META_KEY: String = "peraviz_beam_intensity_max_desired" const INTENSITY_MAX_META_KEY: String = "peraviz_beam_intensity_max" +const PRESENTATION_FOG_VOLUME: int = 0 +const PRESENTATION_VECTOR_PRISM: int = 1 +const PRESENTATION_NATIVE_SHADOW: int = 2 +const PRESENTATION_SHADER_PROXY: int = 3 +const FogVolumeControllerScript = preload("res://scripts/fog_volume_gobo_beam_controller.gd") +const ShaderBeamProxyControllerScript = preload("res://scripts/beam_renderers/shader_beam_proxy_controller.gd") var _beam_material_template: ShaderMaterial var _camera: Camera3D @@ -19,6 +25,9 @@ var _beam_settings_hash: int = 0 var _shape_providers: Dictionary = {} var _active_shape_provider: VolumetricBeamShapeProvider var _last_parameter_write_count: int = 0 +var _presentation_mode: int = PRESENTATION_VECTOR_PRISM +var _fog_controller: FogVolumeGoboBeamController = FogVolumeControllerScript.new() +var _proxy_controller: ShaderBeamProxyController = ShaderBeamProxyControllerScript.new() func _init() -> void: _beam_material_template = ShaderMaterial.new() @@ -30,10 +39,13 @@ func _init() -> void: func configure(view_camera: Camera3D, settings: Dictionary) -> void: _camera = view_camera _settings = settings.duplicate(true) + _presentation_mode = int(settings.get("beam_presentation", PRESENTATION_VECTOR_PRISM)) _beam_settings_hash = _compute_beam_settings_hash() _active_shape_provider = _select_shape_provider() func ensure_beam(light: SpotLight3D) -> void: + if _presentation_mode != PRESENTATION_VECTOR_PRISM: + return if light.has_meta(BEAM_META_KEY): return var beam := MeshInstance3D.new() @@ -47,6 +59,9 @@ func ensure_beam(light: SpotLight3D) -> void: _apply_static_beam_params(beam, {}) func update_beam(light: SpotLight3D, params: Dictionary) -> void: + if _presentation_mode != PRESENTATION_VECTOR_PRISM: + _update_experimental_beam(light, params) + return ensure_beam(light) var beam: MeshInstance3D = light.get_meta(BEAM_META_KEY) as MeshInstance3D if beam == null: @@ -94,7 +109,7 @@ func update_beam(light: SpotLight3D, params: Dictionary) -> void: func _compute_beam_settings_hash() -> int: var hash_value: int = 2166136261 - for key in ["beam_noise_amount", "beam_noise_scale", "beam_haze_density", "beam_anisotropy", "beam_quality"]: + for key in ["beam_softness", "beam_radial_falloff", "beam_longitudinal_falloff"]: hash_value = int((hash_value ^ hash(_settings.get(key, null))) * 16777619) return hash_value @@ -108,12 +123,7 @@ func _apply_static_beam_params(beam: MeshInstance3D, params: Dictionary) -> void if int(beam.get_meta("peraviz_static_beam_hash", 0)) == static_hash: return beam.set_meta("peraviz_static_beam_hash", static_hash) - beam.set_instance_shader_parameter("beam_noise_amount", float(_settings.get("beam_noise_amount", 0.06))) - beam.set_instance_shader_parameter("beam_noise_scale", float(_settings.get("beam_noise_scale", 1.4))) - beam.set_instance_shader_parameter("beam_haze_density", float(_settings.get("beam_haze_density", 0.17)) * haze_density) beam.set_instance_shader_parameter("haze_density", max(haze_density, 0.2)) - beam.set_instance_shader_parameter("beam_anisotropy", float(_settings.get("beam_anisotropy", 0.62))) - beam.set_instance_shader_parameter("beam_quality", int(_settings.get("beam_quality", 1))) beam.set_instance_shader_parameter("radial_falloff", max(float(params.get("beam_radial_falloff", 1.1)), 0.05)) beam.set_instance_shader_parameter("longitudinal_falloff", max(float(params.get("beam_longitudinal_falloff", 1.0)), 0.05)) beam.set_instance_shader_parameter("beam_softness", clamp(float(params.get("beam_softness", 0.35)), 0.02, 1.0)) @@ -138,6 +148,10 @@ func _apply_beam_material_params(beam: MeshInstance3D, beam_range: float, shape_ func update_beam_intensity(light: SpotLight3D, params: Dictionary) -> int: _last_parameter_write_count = 0 + if _presentation_mode != PRESENTATION_VECTOR_PRISM: + var result: Dictionary = _update_experimental_beam(light, params) + _last_parameter_write_count = int(result.get("parameter_write_count", 0)) + return INTENSITY_CHANGED if bool(result.get("changed", false)) else INTENSITY_UNCHANGED if not light.has_meta(BEAM_META_KEY): return INTENSITY_UNRESOLVED var beam: MeshInstance3D = light.get_meta(BEAM_META_KEY) as MeshInstance3D @@ -207,12 +221,16 @@ func get_beam_optics_state(light: SpotLight3D) -> Dictionary: return {} func get_beam_resource(light: SpotLight3D) -> MeshInstance3D: + if _presentation_mode == PRESENTATION_SHADER_PROXY: + return _proxy_controller.get_proxy(light) if not light.has_meta(BEAM_META_KEY): return null var beam: MeshInstance3D = light.get_meta(BEAM_META_KEY) as MeshInstance3D return beam if beam != null and is_instance_valid(beam) else null func cleanup_beam(light: SpotLight3D) -> void: + _fog_controller.clear_for_light(light) + _proxy_controller.hide_for_light(light) if light.has_meta(BEAM_META_KEY): var beam: MeshInstance3D = light.get_meta(BEAM_META_KEY) as MeshInstance3D if beam != null and is_instance_valid(beam): @@ -220,6 +238,26 @@ func cleanup_beam(light: SpotLight3D) -> void: light.remove_meta(BEAM_META_KEY) _cleanup_debug_axis(light) +func _update_experimental_beam(light: SpotLight3D, params: Dictionary) -> Dictionary: + var result := {"changed": false, "resource_created": false, "visibility_changed": false, "parameter_write_count": 0} + var existing: MeshInstance3D = get_beam_resource(light) + if existing != null and existing.visible: + existing.visible = false + result["changed"] = true + result["visibility_changed"] = true + _fog_controller.clear_for_light(light) + if _presentation_mode == PRESENTATION_SHADER_PROXY: + var texture: Texture2D = light.get_meta("peraviz_gobo_texture") as Texture2D if light.has_meta("peraviz_gobo_texture") else null + result = _proxy_controller.update_for_light(light, params, texture) + else: + _proxy_controller.hide_for_light(light) + light.set_meta("peraviz_beam_last_params", params) + _last_parameter_write_count = int(result.get("parameter_write_count", 0)) + return result + +func get_proxy_diagnostics() -> Dictionary: + return _proxy_controller.counters() + func _select_shape_provider() -> VolumetricBeamShapeProvider: var requested_mode: String = str(_settings.get("volumetric_shape_mode", SHAPE_MODE_GOBO_PRISM)).to_lower() if _shape_providers.has(requested_mode): diff --git a/scripts/controllers/dmx_controller.gd b/scripts/controllers/dmx_controller.gd index b5eb4b1..b1e8d53 100644 --- a/scripts/controllers/dmx_controller.gd +++ b/scripts/controllers/dmx_controller.gd @@ -298,10 +298,13 @@ func _report_performance_trace_if_needed() -> void: return var stats: Dictionary = _dmx_receiver.get_stats() var counters: Dictionary = _perf_last_apply_stats.get("visual_apply_counters", {}) + if _owner != null and _owner.has_method("_get_presentation_diagnostics"): + counters = _owner._get_presentation_diagnostics(counters) var counter_delta: Dictionary = _counter_delta(counters, _perf_previous_renderer_counters) var accepted_delta: int = int(stats.get("valid_artdmx_accepted", 0)) - int(_perf_previous_stats.get("valid_artdmx_accepted", 0)) var relevant_delta: int = int(stats.get("relevant_packets", 0)) - int(_perf_previous_stats.get("relevant_packets", 0)) print("[peraviz-perf] mode=%s fps=%.1f process_ms=%.2f draws=%d objects=%d primitives=%d nodes=%d resources=%d packets=%d relevant=%d irrelevant=%d rejects=%d overwrites=%d pump_hz=%d states=%d rx_native_us=%d/%d/%d native_apply_us=%d/%d/%d rx_apply_us=%d/%d/%d frames=%d rows=%d applied=%d noop=%d failed=%d domains=%s domain_us=%s outputs=%d beams_considered=%d light_props=%d light_visibility=%d beam_params=%d beam_visibility=%d beam_topology=%d beam_full=%d beam_full_init=%d beam_full_mask=%d beam_dynamic=%d/%d/%d material_params=%d gobo_topology=%d gobo_params=%d visible_beams=%d visible_lights=%d emitter_commits=%d/%d/%d emitter_commit_us=%d apply_max_us=%d" % [_render_diagnostic_mode, Performance.get_monitor(Performance.TIME_FPS), Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0, int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)), int(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)), int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME)), int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT)), int(Performance.get_monitor(Performance.OBJECT_RESOURCE_COUNT)), accepted_delta, relevant_delta, _stat_delta(stats, "irrelevant_packets"), _stat_delta(stats, "out_of_order_dropped"), _stat_delta(stats, "mailbox_overwrites"), _production_pump_calls, int(_perf_interval.get("states", 0)), int(stats.get("rx_to_native_p50_us", 0)), int(stats.get("rx_to_native_p95_us", 0)), int(stats.get("rx_to_native_max_us", 0)), int(stats.get("native_to_apply_p50_us", 0)), int(stats.get("native_to_apply_p95_us", 0)), int(stats.get("native_to_apply_max_us", 0)), int(stats.get("rx_to_apply_p50_us", 0)), int(stats.get("rx_to_apply_p95_us", 0)), int(stats.get("rx_to_apply_max_us", 0)), int(_perf_interval.get("frames", 0)), int(_perf_interval.get("rows", 0)), int(_perf_interval.get("applied", 0)), int(_perf_interval.get("noop", 0)), int(_perf_interval.get("failed", 0)), str(_perf_interval.get("domains", {})), str(_perf_interval.get("domain_us", {})), int(counter_delta.get("physical_outputs_considered", 0)), int(counter_delta.get("beams_considered", 0)), int(counter_delta.get("light_properties_written", 0)), int(counter_delta.get("light_visibility_calls", 0)), int(counter_delta.get("beam_shader_parameters_written", 0)), int(counter_delta.get("beam_visibility_transitions", 0)), int(counter_delta.get("beam_topology_rebuilds", 0)), int(counter_delta.get("beam_full_state_applies", 0)), int(counter_delta.get("beam_full_state_reason_initialization", 0)), int(counter_delta.get("beam_full_state_reason_topology_mask", 0)), int(counter_delta.get("beam_dynamic_changed", 0)), int(counter_delta.get("beam_dynamic_unchanged", 0)), int(counter_delta.get("beam_dynamic_unresolved", 0)), int(counter_delta.get("material_parameters_written", 0)), int(counter_delta.get("gobo_topology_updates", 0)), int(counter_delta.get("gobo_parametric_updates", 0)), int(counters.get("beam_visible_count", 0)), int(counters.get("spotlight_visible_count", 0)), int(counter_delta.get("emitter_output_commit_candidates", 0)), int(counter_delta.get("emitter_output_commits", 0)), int(counter_delta.get("emitter_output_commits_coalesced", 0)), int(counter_delta.get("emitter_output_commit_usec", 0)), _worst_dmx_tick_usec]) + print("[peraviz-presentation] mode=%d shared_haze=%d per_emitter_fog=%d proxies=%d/%d proxy_create=%d topology=%d/%d proxy_writes=%d texture_changes=%d tiers=%d/%d/%d native_targets=%d outputs=%d emitters=%d active=%d/%d cached_lights=%d vectors=%d projectors=%d masks=%d realtime=%d shadows=%d gobo_open=%d/%d" % [int(counters.get("beam_presentation", 1)), 1 if bool(counters.get("shared_haze", false)) else 0, int(counters.get("allocated_fog_volumes", 0)), int(counters.get("active_shader_proxies", 0)), int(counters.get("allocated_shader_proxies", 0)), int(counters.get("proxy_resource_creations", 0)), int(counters.get("proxy_topology_creations", 0)), int(counters.get("proxy_topology_rebuilds", 0)), int(counters.get("proxy_parameter_writes", 0)), int(counters.get("proxy_gobo_texture_changes", 0)), int(counters.get("proxy_tier_4", 0)), int(counters.get("proxy_tier_8", 0)), int(counters.get("proxy_tier_12", 0)), int(counters.get("native_dimmer_targets", 0)), int(counters.get("native_output_records", 0)), int(counters.get("unique_native_emitters", 0)), int(counters.get("active_output_records", 0)), int(counters.get("active_native_emitters", 0)), int(counters.get("cached_fixture_lights", 0)), int(counters.get("active_vector_prisms", 0)), int(counters.get("active_light_projectors", 0)), int(counters.get("active_shadow_masks", 0)), int(counters.get("spotlight_visible_count", 0)), int(counters.get("shadow_enabled_spotlights", 0)), int(counters.get("gobo_visible_beams", 0)), int(counters.get("open_visible_beams", 0))]) _perf_previous_stats = stats _perf_previous_renderer_counters = counters _perf_interval.clear() diff --git a/scripts/fixture_gobo_projector.gd b/scripts/fixture_gobo_projector.gd index bd48b26..4db974c 100644 --- a/scripts/fixture_gobo_projector.gd +++ b/scripts/fixture_gobo_projector.gd @@ -5,6 +5,7 @@ const FAKE_GOBO_TEXTURE_SIZE: int = 1024 const GOBO_TEXTURE_META_KEY: String = "peraviz_gobo_texture" const FALLBACK_GOBO_META_KEY: String = "peraviz_is_vector_fallback_gobo" const GOBO_PLANE_META_KEY: String = "peraviz_gobo_plane" +const PRE_PROJECTOR_SHADOW_META_KEY: String = "peraviz_pre_projector_shadow_enabled" const GOBO_SHADER_PATH: String = "res://scripts/shaders/gobo_alpha_projector.gdshader" const GOBO_PLANE_LOCAL_Z: float = -0.043 const GOBO_PLANE_MESH_SIZE: Vector2 = Vector2(0.017, 0.017) @@ -24,6 +25,7 @@ const GOBO_WHEEL_SHAKE_PHASE_META_KEY: String = "peraviz_gobo_wheel_shake_phase" const GOBO_WHEEL_SHAKE_RANGE_META_KEY: String = "peraviz_gobo_wheel_shake_range" const GOBO_LAST_UPDATE_MSEC_META_KEY: String = "peraviz_gobo_last_update_msec" const GOBO_APPLIED_ROTATION_DEG_META_KEY: String = "peraviz_gobo_applied_rotation_deg" +const PROJECTOR_BASE_BASIS_META_KEY: String = "peraviz_projector_base_basis" const GOBO_APPLIED_SHAKE_TILT_DEG_META_KEY: String = "peraviz_gobo_applied_shake_tilt_deg" const GOBO_WHEEL_MODE_META_KEY: String = "peraviz_gobo_wheel_mode" const GOBO_APPLIED_STATE_META_KEY: String = "peraviz_gobo_applied_state" @@ -51,6 +53,7 @@ const GOBO_BEHAVIOR_SHAKE: int = 3 const DmxGoboRangeResolverScript = preload("res://scripts/dmx_gobo_range_resolver.gd") const DmxGoboControlsResolverScript = preload("res://scripts/dmx_gobo_controls_resolver.gd") +const GoboRotationPresentationScript = preload("res://scripts/runtime/gobo_indexed_rotation_presentation.gd") var _texture_cache: Dictionary = {} var _texture_composition_count: int = 0 @@ -65,6 +68,35 @@ func get_debug_counters() -> Dictionary: "parametric_updates": _parametric_update_count, } +func set_shadow_mask_enabled(light: SpotLight3D, enabled: bool) -> void: + if light == null or not is_instance_valid(light): + return + var texture: Texture2D = light.get_meta(GOBO_TEXTURE_META_KEY) as Texture2D if light.has_meta(GOBO_TEXTURE_META_KEY) else light.light_projector + if not enabled or texture == null: + _remove_gobo_plane(light) + return + var plane: MeshInstance3D = _ensure_gobo_plane(light) + if plane != null and plane.material_override is ShaderMaterial: + (plane.material_override as ShaderMaterial).set_shader_parameter("gobo_texture", texture) + _update_gobo_plane_scale(light, plane) + +func set_presentation_rotation(light: SpotLight3D, gobo_rotation_degrees: float) -> void: + if light == null or not is_instance_valid(light): + return + if not light.has_meta(PROJECTOR_BASE_BASIS_META_KEY): + light.set_meta(PROJECTOR_BASE_BASIS_META_KEY, light.transform.basis.orthonormalized()) + var base_basis: Basis = light.get_meta(PROJECTOR_BASE_BASIS_META_KEY) as Basis + var local_roll := Basis(Vector3(0.0, 0.0, -1.0), deg_to_rad(gobo_rotation_degrees)) + var current_transform := light.transform + current_transform.basis = base_basis * local_roll + light.transform = current_transform + if light.has_meta("peraviz_volumetric_beam"): + GoboRotationPresentationScript.apply_parent_roll_compensation(light.get_meta("peraviz_volumetric_beam") as MeshInstance3D, gobo_rotation_degrees) + if light.has_meta(GOBO_PLANE_META_KEY): + var mask: MeshInstance3D = light.get_meta(GOBO_PLANE_META_KEY) as MeshInstance3D + if mask != null: + mask.set_meta("peraviz_gobo_presentation_rotation_deg", gobo_rotation_degrees) + func apply_gobo_projection(light: SpotLight3D, controls: Dictionary) -> bool: if light == null or not is_instance_valid(light): return false @@ -141,14 +173,14 @@ func apply_gobo_projection(light: SpotLight3D, controls: Dictionary) -> bool: "texture_load_success": not source_textures.is_empty(), }) var composed_texture_cache_key: String = _build_composed_gobo_cache_key(source_texture_cache_keys) - var prefer_native_fog_projector: bool = bool(controls.get("prefer_native_fog_projector", true)) + var use_native_shadow_gobo_mask: bool = bool(controls.get("use_native_shadow_gobo_mask", false)) var has_composed_texture: bool = not source_textures.is_empty() var mode: String = "fallback_vector" if has_runtime_gobo and has_composed_texture: mode = "runtime_slots" var topology_state: Dictionary = { "mode": mode, - "prefer_native_fog_projector": prefer_native_fog_projector, + "use_native_shadow_gobo_mask": use_native_shadow_gobo_mask, "has_composed_texture": has_composed_texture, "texture_cache_key": composed_texture_cache_key, "wheel_slot_index_by_wheel": wheel_slot_state, @@ -171,7 +203,11 @@ func apply_gobo_projection(light: SpotLight3D, controls: Dictionary) -> bool: # Expensive path: texture slot/composition changed. if source_textures.is_empty(): - _apply_vector_fallback_gobo(light, previous_meta_texture, gobo_controls) + if has_runtime_gobo: + _apply_vector_fallback_gobo(light, previous_meta_texture, gobo_controls) + else: + _clear_gobo_visuals(light) + light.set_meta(GOBO_TEXTURE_META_KEY, null) else: var projected_gobo: Texture2D = _compose_gobo_textures(source_textures, composed_texture_cache_key) _apply_gobo_visuals(light, projected_gobo, gobo_controls) @@ -495,8 +531,8 @@ func _apply_gobo_visuals(light: SpotLight3D, gobo_texture: Texture2D, controls: if gobo_texture == null: _remove_gobo_plane(light) return - var prefer_native_fog_projector: bool = bool(controls.get("prefer_native_fog_projector", true)) - if prefer_native_fog_projector: + var use_native_shadow_gobo_mask: bool = bool(controls.get("use_native_shadow_gobo_mask", false)) + if not use_native_shadow_gobo_mask: _remove_gobo_plane(light) return var gobo_plane: MeshInstance3D = _ensure_gobo_plane(light) @@ -532,6 +568,13 @@ func _set_light_projector_texture(light: SpotLight3D, texture: Texture2D) -> voi light.set("projector", texture) if _has_property(light, "light_projector"): light.set("light_projector", texture) + if texture != null: + if not light.has_meta(PRE_PROJECTOR_SHADOW_META_KEY): + light.set_meta(PRE_PROJECTOR_SHADOW_META_KEY, light.shadow_enabled) + light.shadow_enabled = true + elif light.has_meta(PRE_PROJECTOR_SHADOW_META_KEY): + light.shadow_enabled = bool(light.get_meta(PRE_PROJECTOR_SHADOW_META_KEY, false)) + light.remove_meta(PRE_PROJECTOR_SHADOW_META_KEY) func _has_property(object: Object, property_name: String) -> bool: if object == null: @@ -575,7 +618,7 @@ func _topology_signature_from_state(applied_state: Dictionary) -> String: return "" return "%s|%s|%s" % [ str(topology_state.get("mode", "")), - str(bool(topology_state.get("prefer_native_fog_projector", true))), + str(bool(topology_state.get("use_native_shadow_gobo_mask", false))), str(bool(topology_state.get("has_composed_texture", false))), ] @@ -585,7 +628,7 @@ func _build_applied_state_key(topology_state: Dictionary) -> String: var wheel_mode_key: String = JSON.stringify(topology_state.get("wheel_mode_by_wheel", {})) return "%s|%s|%s|%s|%s" % [ str(topology_state.get("mode", "")), - str(bool(topology_state.get("prefer_native_fog_projector", true))), + str(bool(topology_state.get("use_native_shadow_gobo_mask", false))), str(bool(topology_state.get("has_composed_texture", false))), texture_key, wheel_slot_key + "|" + wheel_mode_key, @@ -599,7 +642,7 @@ func _ensure_gobo_plane(light: SpotLight3D) -> MeshInstance3D: var gobo_plane := MeshInstance3D.new() gobo_plane.name = "PeravizGoboPlane" - gobo_plane.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_DOUBLE_SIDED + gobo_plane.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY var quad := QuadMesh.new() quad.size = GOBO_PLANE_MESH_SIZE gobo_plane.mesh = quad diff --git a/scripts/fog_volume_gobo_beam_controller.gd b/scripts/fog_volume_gobo_beam_controller.gd index 96f29bf..fe46527 100644 --- a/scripts/fog_volume_gobo_beam_controller.gd +++ b/scripts/fog_volume_gobo_beam_controller.gd @@ -3,70 +3,101 @@ class_name FogVolumeGoboBeamController const FOG_VOLUME_NODE_NAME: String = "PeravizFogVolumeGoboBeam" const FOG_SHADER_PATH: String = "res://scripts/shaders/fog_volume_gobo_beam.gdshader" +const STATE_META_KEY: String = "peraviz_fog_volume_state" -func update_for_light(light: SpotLight3D, beam_params: Dictionary, gobo_texture: Texture2D, visual_settings: Dictionary) -> void: - if light == null or not is_instance_valid(light): - return - if gobo_texture == null: - clear_for_light(light) - return +var _open_texture: Texture2D - var fog_volume: FogVolume = _ensure_volume(light) +func update_for_light(light: SpotLight3D, beam_params: Dictionary, gobo_texture: Texture2D, visual_settings: Dictionary) -> Dictionary: + var result := {"changed": false, "resource_created": false, "visibility_changed": false, "parameter_write_count": 0} + if light == null or not is_instance_valid(light): + return result + var beam_type: String = str(beam_params.get("beam_type", "Wash")).to_lower() + var intensity_max: float = max(float(beam_params.get("intensity_max", 100.0)), 0.01) + var scaled_intensity: float = clamp(float(beam_params.get("scaled_intensity", beam_params.get("beam_intensity", 0.0))), 0.0, intensity_max) + var threshold: float = float(beam_params.get("intensity_visibility_threshold", 0.015)) + var active: bool = beam_type != "none" and beam_type != "glow" and scaled_intensity > threshold + var fog_volume: FogVolume = light.get_node_or_null(FOG_VOLUME_NODE_NAME) as FogVolume + if not active: + if fog_volume != null and fog_volume.visible: + fog_volume.visible = false + result["changed"] = true + result["visibility_changed"] = true + _record_counters(light, result) + return result if fog_volume == null: - return + fog_volume = _create_volume(light) + result["changed"] = true + result["resource_created"] = true + if not fog_volume.visible: + fog_volume.visible = true + result["changed"] = true + result["visibility_changed"] = true var beam_range: float = max(float(beam_params.get("beam_range", light.spot_range)), 0.1) var beam_angle: float = max(float(beam_params.get("beam_angle", light.spot_angle * 2.0)), 0.1) - var cone_radius: float = tan(deg_to_rad(beam_angle * 0.5)) * beam_range - fog_volume.size = Vector3(max(cone_radius * 2.0, 0.1), max(cone_radius * 2.0, 0.1), beam_range) - fog_volume.position = Vector3(0.0, 0.0, -beam_range * 0.5) - var scaled_intensity: float = clamp(float(beam_params.get("scaled_intensity", beam_params.get("beam_intensity", 0.0))), 0.0, max(float(beam_params.get("intensity_max", 100.0)), 0.01)) - var threshold: float = float(beam_params.get("intensity_visibility_threshold", 0.015)) - fog_volume.visible = scaled_intensity > threshold + var lens_radius: float = max(float(beam_params.get("lens_radius", 0.03)), 0.001) + var cone_radius: float = lens_radius + tan(deg_to_rad(beam_angle * 0.5)) * beam_range + var values := { + "size": Vector3(max(cone_radius * 2.0, 0.1), max(cone_radius * 2.0, 0.1), beam_range), + "position": Vector3(0.0, 0.0, -beam_range * 0.5), + "gobo_texture": gobo_texture if gobo_texture != null else _get_open_texture(), + "use_gobo": gobo_texture != null, + "light_color": Color(beam_params.get("beam_color", Color.WHITE)), + "intensity": clamp(scaled_intensity / min(intensity_max, 20.0), 0.0, 1.0), + "density_scale": float(visual_settings.get("fog_volume_density_scale", 1.1)) * max(float(beam_params.get("haze_density_multiplier", 0.22)), 0.01), + "emission_strength": float(visual_settings.get("fog_volume_emission_strength", 1.5)), + "edge_softness": float(visual_settings.get("fog_volume_edge_softness", 0.72)), + "invert_gobo": bool(visual_settings.get("fog_volume_invert_gobo", false)), + "gobo_scale": max(float(beam_params.get("gobo_scale", 1.0)), 0.05), + "gobo_rotation_deg": float(beam_params.get("gobo_rotation_deg", 0.0)), + "near_radius_ratio": clamp(lens_radius / max(cone_radius, 0.001), 0.0, 1.0), + "radial_falloff": max(float(beam_params.get("beam_radial_falloff", 1.25)), 0.05), + "longitudinal_falloff": max(float(beam_params.get("beam_longitudinal_falloff", 1.1)), 0.05), + } + var previous: Dictionary = fog_volume.get_meta(STATE_META_KEY, {}) + if previous.get("size") != values["size"]: + fog_volume.size = values["size"] + result["changed"] = true + if previous.get("position") != values["position"]: + fog_volume.position = values["position"] + result["changed"] = true + var material: ShaderMaterial = fog_volume.material as ShaderMaterial + for key in values.keys(): + if key == "size" or key == "position" or previous.get(key) == values[key]: + continue + material.set_shader_parameter(key, values[key]) + result["parameter_write_count"] = int(result["parameter_write_count"]) + 1 + result["changed"] = true + fog_volume.set_meta(STATE_META_KEY, values) + _record_counters(light, result) + return result - var fog_material: ShaderMaterial = fog_volume.material as ShaderMaterial - if fog_material == null: - return - fog_material.set_shader_parameter("gobo_texture", gobo_texture) - fog_material.set_shader_parameter("light_color", Color(beam_params.get("beam_color", Color.WHITE))) - var haze_density: float = max(float(beam_params.get("haze_density_multiplier", 0.22)), 0.01) - fog_material.set_shader_parameter("density_scale", float(visual_settings.get("fog_volume_density_scale", 0.9)) * haze_density) - fog_material.set_shader_parameter("emission_strength", float(visual_settings.get("fog_volume_emission_strength", 0.55))) - fog_material.set_shader_parameter("edge_softness", float(visual_settings.get("fog_volume_edge_softness", 0.72))) - fog_material.set_shader_parameter("invert_gobo", bool(visual_settings.get("fog_volume_invert_gobo", false))) - fog_material.set_shader_parameter("gobo_scale", max(float(beam_params.get("gobo_scale", 1.0)), 0.05)) - fog_material.set_shader_parameter("gobo_rotation_deg", float(beam_params.get("gobo_rotation_deg", 0.0))) - fog_material.set_shader_parameter("radial_falloff", max(float(beam_params.get("beam_radial_falloff", 1.25)), 0.05)) - fog_material.set_shader_parameter("longitudinal_falloff", max(float(beam_params.get("beam_longitudinal_falloff", 1.1)), 0.05)) +func _record_counters(light: SpotLight3D, result: Dictionary) -> void: + light.set_meta("peraviz_fog_volume_creations", int(light.get_meta("peraviz_fog_volume_creations", 0)) + (1 if bool(result.get("resource_created", false)) else 0)) + light.set_meta("peraviz_fog_parameter_writes", int(light.get_meta("peraviz_fog_parameter_writes", 0)) + int(result.get("parameter_write_count", 0))) func clear_for_light(light: SpotLight3D) -> void: if light == null or not is_instance_valid(light): return var existing: Node = light.get_node_or_null(FOG_VOLUME_NODE_NAME) if existing != null: + light.remove_child(existing) existing.queue_free() -func _ensure_volume(light: SpotLight3D) -> FogVolume: - var existing: Node = light.get_node_or_null(FOG_VOLUME_NODE_NAME) - if existing is FogVolume and is_instance_valid(existing): - return existing as FogVolume - +func _create_volume(light: SpotLight3D) -> FogVolume: var fog_volume := FogVolume.new() fog_volume.name = FOG_VOLUME_NODE_NAME - _assign_fog_volume_shape(fog_volume) + fog_volume.shape = RenderingServer.FOG_VOLUME_SHAPE_BOX fog_volume.material = ShaderMaterial.new() - var fog_material: ShaderMaterial = fog_volume.material as ShaderMaterial - fog_material.shader = load(FOG_SHADER_PATH) + (fog_volume.material as ShaderMaterial).shader = load(FOG_SHADER_PATH) + fog_volume.visible = false light.add_child(fog_volume) return fog_volume -func _assign_fog_volume_shape(fog_volume: FogVolume) -> void: - if fog_volume == null: - return - var cone_shape: Variant = ClassDB.instantiate("ConeFogVolumeShape3D") - if cone_shape != null: - fog_volume.shape = cone_shape - return - var cylinder_shape: Variant = ClassDB.instantiate("CylinderFogVolumeShape3D") - if cylinder_shape != null: - fog_volume.shape = cylinder_shape +func _get_open_texture() -> Texture2D: + if _open_texture != null: + return _open_texture + var image := Image.create(1, 1, false, Image.FORMAT_RGBA8) + image.fill(Color.WHITE) + _open_texture = ImageTexture.create_from_image(image) + return _open_texture diff --git a/scripts/load_scene.gd b/scripts/load_scene.gd index df25191..5a60d6a 100644 --- a/scripts/load_scene.gd +++ b/scripts/load_scene.gd @@ -85,6 +85,7 @@ var _visual_settings := { "beam_multiplier": 20.0, "bloom_multiplier": 0.0, "beam_render_mode": 0, + "beam_presentation": 1, "beam_quality": 2, "use_fog_volume_gobo_beam": false, "fog_volume_density_scale": 1.1, @@ -112,6 +113,9 @@ var _visual_settings := { "beam_visual_length_m": 75.0, "ambient_fog_density": 0.0, "volumetric_fog_density": 0.0, + "shared_haze_density": 0.015, + "shared_haze_margin": 5.0, + "volumetric_fog_length": 110.0, "volumetric_fog_fade": 0.02, "light_volumetric_fog_energy": 12.0, "use_native_fog_projector_gobos": true, @@ -145,11 +149,14 @@ var _fixture_light_apply_service: FixtureLightApplyService var _live_gobo_diagnostic_keys: Dictionary = {} var _active_beam_renderer: BeamRendererBase var _active_beam_mode: int = -1 +var _active_beam_presentation: int = 1 +var _shared_haze_controller: SharedHazeController const BeamRendererBaseScript = preload("res://scripts/beam_renderers/beam_renderer_base.gd") const LegacyConeBeamRendererScript = preload("res://scripts/beam_renderers/legacy_cone_beam_renderer.gd") const VolumetricBeamRendererScript = preload("res://scripts/beam_renderers/volumetric_beam_renderer.gd") const FixtureGoboProjectorScript = preload("res://scripts/fixture_gobo_projector.gd") +const SharedHazeControllerScript = preload("res://scripts/shared_haze_controller.gd") const BeamOpticsControllerScript = preload("res://scripts/beam_optics_controller.gd") const BeamApertureMeasurementServiceScript = preload("res://scripts/beam_aperture_measurement_service.gd") const BeamGeometryCalculatorScript = preload("res://scripts/beam_geometry_calculator.gd") @@ -442,10 +449,16 @@ func _apply_visual_settings(settings: Dictionary) -> void: if world_environment.environment.fog_enabled: world_environment.environment.fog_density = ambient_fog_density var volumetric_fog_density: float = max(float(_visual_settings.get("volumetric_fog_density", 0.0)), 0.0) - world_environment.environment.volumetric_fog_enabled = volumetric_fog_density > 0.0001 + var presentation_uses_fog: bool = int(_visual_settings.get("beam_presentation", 1)) in [0, 2] + world_environment.environment.volumetric_fog_enabled = volumetric_fog_density > 0.0001 or presentation_uses_fog world_environment.environment.volumetric_fog_density = volumetric_fog_density + if _environment_has_property(world_environment.environment, "volumetric_fog_length"): + world_environment.environment.volumetric_fog_length = clamp(float(_visual_settings.get("volumetric_fog_length", 110.0)), 1.0, 500.0) if _environment_has_property(world_environment.environment, "volumetric_fog_fade"): world_environment.environment.volumetric_fog_fade = max(float(_visual_settings.get("volumetric_fog_fade", 0.02)), 0.005) + if _shared_haze_controller == null: + _shared_haze_controller = SharedHazeControllerScript.new() + _shared_haze_controller.update(self, _loaded_bounds, _has_loaded_bounds and int(_visual_settings.get("beam_presentation", 1)) in [0, 2], _visual_settings) # Renderer-level volumetric fog froxel settings are kept static in project.godot. # Do not mutate froxel sizing/filtering at runtime, as it can cause renderer signal churn. @@ -457,7 +470,7 @@ func _apply_visual_settings(settings: Dictionary) -> void: var beam_scalar_changed: bool = ( not is_equal_approx(float(previous_settings.get("beam_multiplier", 20.0)), float(_visual_settings.get("beam_multiplier", 20.0))) - or int(previous_settings.get("beam_quality", 1)) != int(_visual_settings.get("beam_quality", 1)) + or int(previous_settings.get("beam_presentation", 1)) != int(_visual_settings.get("beam_presentation", 1)) ) if beam_scalar_changed: _refresh_existing_beam_material_scalars() @@ -505,14 +518,23 @@ func _update_beam_renderer_mode(force_refresh: bool) -> void: if requested_renderer == null: return - if force_refresh or requested_mode != _active_beam_mode: + var requested_presentation: int = int(_visual_settings.get("beam_presentation", VolumetricBeamRendererScript.PRESENTATION_VECTOR_PRISM)) + var presentation_changed: bool = requested_presentation != _active_beam_presentation + if force_refresh or requested_mode != _active_beam_mode or presentation_changed: + var cleaned_lights: Dictionary = {} for fixture_uuid in _fixture_emitter_light_cache.keys(): var lights: Array = _fixture_emitter_light_cache.get(fixture_uuid, []) for light_node in lights: if light_node is SpotLight3D and is_instance_valid(light_node): _cleanup_light_beam_renderers(light_node) + cleaned_lights[light_node.get_instance_id()] = true + for native_light_item in _native_target_registry.get_emitter_anchors(): + var native_light: SpotLight3D = native_light_item as SpotLight3D + if native_light != null and not cleaned_lights.has(native_light.get_instance_id()): + _cleanup_light_beam_renderers(native_light) _active_beam_mode = requested_mode + _active_beam_presentation = requested_presentation _active_beam_renderer = requested_renderer if _status_presenter != null: var render_mode_label: String = "Volumetric" if _active_beam_mode == BEAM_RENDER_MODE_VOLUMETRIC else "Legacy" @@ -538,24 +560,25 @@ func _refresh_existing_beam_material_scalars() -> void: var lights: Array = _fixture_emitter_light_cache.get(fixture_uuid, []) for light in lights: if light is SpotLight3D and is_instance_valid(light): + if _fixture_gobo_projector != null: + _fixture_gobo_projector.set_shadow_mask_enabled(light, int(_visual_settings.get("beam_presentation", 1)) == 2) _update_existing_beam_material_scalars(light) func _apply_light_scalars_to_light(light: SpotLight3D) -> void: var light_rid: RID = _get_light_base_rid(light) if not light_rid.is_valid(): return - var base_energy: float = float(light.get_meta("peraviz_base_light_energy", light.light_energy)) + var base_energy: float = float(light.get_meta("peraviz_base_light_energy", 0.0)) RenderingServer.light_set_param(light_rid, RenderingServer.LIGHT_PARAM_ENERGY, base_energy * float(_visual_settings.get("spot_multiplier", 1.0))) RenderingServer.light_set_param(light_rid, RenderingServer.LIGHT_PARAM_VOLUMETRIC_FOG_ENERGY, float(_visual_settings.get("light_volumetric_fog_energy", 12.0))) func _update_existing_beam_material_scalars(light: SpotLight3D) -> void: - var base_intensity: float = float(light.get_meta("peraviz_beam_base_intensity", 0.0)) var beam_params: Dictionary = light.get_meta("peraviz_beam_last_params", {}) if light.has_meta("peraviz_beam_last_params") else {} if beam_params.is_empty(): return + var base_intensity: float = float(light.get_meta("peraviz_beam_base_intensity", beam_params.get("normalized_dimmer", 0.0))) var beam_multiplier: float = float(_visual_settings.get("beam_multiplier", 20.0)) beam_params["scaled_intensity"] = clamp(base_intensity * beam_multiplier, 0.0, BEAM_INTENSITY_MAX) - beam_params["beam_quality"] = int(_visual_settings.get("beam_quality", 1)) beam_params["intensity_max"] = BEAM_INTENSITY_MAX var beam_phase_start: int = Time.get_ticks_usec() _update_beam_for_light(light, beam_params) @@ -573,6 +596,8 @@ func _update_beam_for_light(light: SpotLight3D, beam_params: Dictionary) -> void light.set_meta("peraviz_beam_last_params", beam_params) _active_beam_renderer.ensure_beam(light) _active_beam_renderer.update_beam(light, beam_params) + _refresh_native_gobo_presentation(light) + _native_target_registry.rebind_beam_resource(light, _active_beam_renderer.get_beam_resource(light)) func _apply_emitter_light_dimmer_fast(light: SpotLight3D, photometric: Dictionary, normalized_dimmer: float, beam_color: Color, controls: Dictionary = {}) -> bool: if light == null or not is_instance_valid(light) or not light.has_meta("peraviz_beam_last_params"): @@ -1200,6 +1225,8 @@ func _rebuild_loaded_bounds() -> void: for child in proxies_root.get_children(): if child is Node3D: _expand_loaded_bounds_from_node(child) + if _shared_haze_controller != null: + _shared_haze_controller.update(self, _loaded_bounds, _has_loaded_bounds and int(_visual_settings.get("beam_presentation", 1)) in [0, 2], _visual_settings) func _create_scene_node(data: Dictionary) -> Node3D: return _node_factory.create_scene_node(data, _loader, _asset_cache) @@ -1687,9 +1714,53 @@ func _configure_native_target_registry() -> void: "get_beam_resource": Callable(self, "_get_beam_resource_for_light"), "apply_beam_optics": Callable(self, "_apply_beam_optics_for_light"), "is_emitter_lens_mesh": Callable(self, "_is_emitter_lens_mesh"), + "present_native_gobo": Callable(self, "_present_native_gobo"), }, }) +func _present_native_gobo(light: SpotLight3D, texture: Texture2D, gobo_rotation_degrees: float) -> void: + if light == null or not is_instance_valid(light): + return + if not is_nan(gobo_rotation_degrees): + var params: Dictionary = light.get_meta("peraviz_beam_last_params", {}) + params["gobo_rotation_deg"] = gobo_rotation_degrees + light.set_meta("peraviz_beam_last_params", params) + _fixture_gobo_projector.set_presentation_rotation(light, gobo_rotation_degrees) + _refresh_native_gobo_presentation(light, texture) + if _active_beam_presentation == VolumetricBeamRendererScript.PRESENTATION_SHADER_PROXY and _active_beam_renderer != null: + _active_beam_renderer.update_beam(light, light.get_meta("peraviz_beam_last_params", {})) + +func _refresh_native_gobo_presentation(light: SpotLight3D, texture: Texture2D = null) -> void: + if _fixture_gobo_projector == null: + return + var params: Dictionary = light.get_meta("peraviz_beam_last_params", {}) + var active: bool = bool(_fixture_light_apply_service._light_desired_for(light).get("visible", false)) if _fixture_light_apply_service != null else false + var resolved_texture: Texture2D = texture + if resolved_texture == null and light.has_meta("peraviz_gobo_texture"): + resolved_texture = light.get_meta("peraviz_gobo_texture") as Texture2D + _fixture_gobo_projector._set_light_projector_texture(light, resolved_texture if active else null) + _fixture_gobo_projector.set_shadow_mask_enabled(light, active and resolved_texture != null and int(_visual_settings.get("beam_presentation", 1)) == 2) + _fixture_gobo_projector.set_presentation_rotation(light, float(params.get("gobo_rotation_deg", 0.0)) if resolved_texture != null else 0.0) + if _fixture_light_apply_service != null: + _fixture_light_apply_service._apply_desired_light_state(self, light) + +func _get_presentation_diagnostics(base_counters: Dictionary) -> Dictionary: + var result: Dictionary = base_counters.duplicate(false) + var active_ids: Dictionary = _fixture_light_apply_service.get_active_physical_emitter_ids() if _fixture_light_apply_service != null else {} + result.merge(_native_target_registry.get_presentation_ownership_diagnostics(active_ids), true) + var cached_ids: Dictionary = {} + for lights_item in _fixture_emitter_light_cache.values(): + for light_item in lights_item: + if light_item is SpotLight3D and is_instance_valid(light_item): + cached_ids[light_item.get_instance_id()] = true + result["cached_fixture_lights"] = cached_ids.size() + var haze: FogVolume = _shared_haze_controller.get_volume() if _shared_haze_controller != null else null + result["shared_haze"] = haze != null and haze.visible + result["shared_haze_density"] = float(_visual_settings.get("shared_haze_density", 0.0)) + if _active_beam_renderer != null and _active_beam_renderer.has_method("get_proxy_diagnostics"): + result.merge(_active_beam_renderer.get_proxy_diagnostics(), true) + return result + func _register_native_runtime_targets(renderer_manifest: Array) -> void: _native_target_registry.install_manifest(renderer_manifest) @@ -1811,7 +1882,7 @@ func _apply_live_visual_gobo_to_light(fixture_uuid: String, light: SpotLight3D, "gobo_debug_shake_frequency_hz": controls.get("gobo_debug_shake_frequency_hz", 0.0), "gobo_debug_shake_waveform": controls.get("gobo_debug_shake_waveform", 0), "frame_delta_sec": visual_gobo_state.get("frame_delta_sec", controls.get("frame_delta_sec", 0.0)), - "prefer_native_fog_projector": controls.get("prefer_native_fog_projector", true), + "use_native_shadow_gobo_mask": int(_visual_settings.get("beam_presentation", 1)) == 2, "gobo_scale": controls.get("gobo_scale", 1.0), "gobo_slots": resolved_gobo_controls.get("gobo_slots", []), "gobo_runtime_bindings": resolved_gobo_controls.get("gobo_runtime_bindings", []), @@ -1973,10 +2044,14 @@ func _find_or_create_emitter_light(emitter_node: Node3D) -> SpotLight3D: var lens_radius: float = _estimate_emitter_lens_radius(emitter_node) for child in emitter_node.get_children(): if child is SpotLight3D and child.name == "PeravizEmitterLight": + if not child.has_meta("peraviz_base_light_energy"): + child.light_energy = 0.0 + child.visible = false if child.rotation_degrees != EMITTER_LIGHT_DIRECTION_FIX: child.rotation_degrees = EMITTER_LIGHT_DIRECTION_FIX - # Fixture beams use dedicated mesh shaders, so positional shadows stay off by default. - child.shadow_enabled = false + # Projector-active lights retain shadows because Godot 4.7 requires them for surface projection. + if child.light_projector == null and not child.has_meta("peraviz_pre_projector_shadow_enabled"): + child.shadow_enabled = false child.shadow_bias = 0.05 child.shadow_normal_bias = 1.2 child.set_meta("peraviz_lens_radius", lens_radius) @@ -1987,6 +2062,9 @@ func _find_or_create_emitter_light(emitter_node: Node3D) -> SpotLight3D: light.position = Vector3.ZERO light.rotation_degrees = EMITTER_LIGHT_DIRECTION_FIX light.light_negative = false + light.light_energy = 0.0 + light.light_projector = null + light.visible = false light.shadow_enabled = false light.shadow_bias = 0.05 light.shadow_normal_bias = 1.2 @@ -2187,7 +2265,7 @@ func _apply_emitter_light_state(light: SpotLight3D, photometric: Dictionary, nor "gobo_debug_shake_frequency_hz": controls.get("gobo_debug_shake_frequency_hz", 0.0), "gobo_debug_shake_waveform": controls.get("gobo_debug_shake_waveform", 0), "frame_delta_sec": controls.get("frame_delta_sec", 0.0), - "prefer_native_fog_projector": controls.get("prefer_native_fog_projector", true), + "use_native_shadow_gobo_mask": int(_visual_settings.get("beam_presentation", 1)) == 2, "gobo_scale": controls.get("gobo_scale", 1.0), "gobo_slots": resolved_gobo_controls.get("gobo_slots", []), "gobo_runtime_bindings": resolved_gobo_controls.get("gobo_runtime_bindings", []), @@ -2208,7 +2286,8 @@ func _apply_emitter_light_state(light: SpotLight3D, photometric: Dictionary, nor if gobo_topology_changed: _emitter_mesh_rebuild_count += 1 _cleanup_light_beam_renderers(light) - _set_light_property_float(light, "light_volumetric_fog_energy", float(_visual_settings.get("light_volumetric_fog_energy", 12.0)) * float(_visual_settings.get("haze_density_multiplier", 0.22)), last_state) + beam_params["gobo_texture"] = light.get_meta("peraviz_gobo_texture") if light.has_meta("peraviz_gobo_texture") else null + _set_light_property_float(light, "light_volumetric_fog_energy", float(_visual_settings.get("light_volumetric_fog_energy", 12.0)), last_state) var beam_phase_start: int = Time.get_ticks_usec() _update_beam_for_light(light, beam_params) if _fixture_light_apply_service != null: diff --git a/scripts/runtime/beam_presentation_diagnostics.gd b/scripts/runtime/beam_presentation_diagnostics.gd new file mode 100644 index 0000000..63917bb --- /dev/null +++ b/scripts/runtime/beam_presentation_diagnostics.gd @@ -0,0 +1,51 @@ +extends RefCounted +class_name BeamPresentationDiagnostics + +var _ids: Dictionary = {"vector": {}, "proxy": {}, "fog_allocated": {}, "fog_visible": {}, "mask": {}, "projector": {}, "shadow": {}, "gobo_visible": {}, "open_visible": {}} +var _fog_totals_by_light: Dictionary = {} +var _fog_creations: int = 0 +var _fog_parameter_writes: int = 0 + +func update(loader: Node, light: SpotLight3D) -> Dictionary: + var light_id: int = light.get_instance_id() + var beam: MeshInstance3D = light.get_meta("peraviz_volumetric_beam") as MeshInstance3D if light.has_meta("peraviz_volumetric_beam") else null + var beam_visible: bool = beam != null and is_instance_valid(beam) and beam.visible + var physical_visible: bool = bool(light.get_meta("peraviz_physical_output_visible")) if light.has_meta("peraviz_physical_output_visible") else false + var proxy: MeshInstance3D = light.get_meta("peraviz_shader_beam_proxy") as MeshInstance3D if light.has_meta("peraviz_shader_beam_proxy") else null + var flags: Dictionary = { + "vector": beam_visible, + "proxy": proxy != null and is_instance_valid(proxy) and proxy.visible, + "fog_allocated": light.get_node_or_null("PeravizFogVolumeGoboBeam") != null, + "fog_visible": light.get_node_or_null("PeravizFogVolumeGoboBeam") is FogVolume and (light.get_node_or_null("PeravizFogVolumeGoboBeam") as FogVolume).visible, + "mask": light.has_meta("peraviz_gobo_plane"), + "projector": light.light_projector != null, + "shadow": light.shadow_enabled, + "gobo_visible": light.has_meta("peraviz_gobo_texture") and physical_visible, + "open_visible": not light.has_meta("peraviz_gobo_texture") and physical_visible, + } + for key in flags.keys(): + var category: Dictionary = _ids[key] + if bool(flags[key]): + category[light_id] = true + else: + category.erase(light_id) + var fog_totals := Vector2i(int(light.get_meta("peraviz_fog_volume_creations", 0)), int(light.get_meta("peraviz_fog_parameter_writes", 0))) + var previous_fog_totals: Vector2i = _fog_totals_by_light.get(light_id, Vector2i.ZERO) + _fog_creations += max(fog_totals.x - previous_fog_totals.x, 0) + _fog_parameter_writes += max(fog_totals.y - previous_fog_totals.y, 0) + _fog_totals_by_light[light_id] = fog_totals + var settings: Variant = loader.get("_visual_settings") if loader != null else null + return { + "beam_presentation": int((settings as Dictionary).get("beam_presentation", 1)) if settings is Dictionary else 1, + "active_vector_prisms": (_ids["vector"] as Dictionary).size(), + "active_shader_proxies": (_ids["proxy"] as Dictionary).size(), + "allocated_fog_volumes": (_ids["fog_allocated"] as Dictionary).size(), + "active_fog_volumes": (_ids["fog_visible"] as Dictionary).size(), + "fog_volume_creations": _fog_creations, + "fog_parameter_writes": _fog_parameter_writes, + "active_shadow_masks": (_ids["mask"] as Dictionary).size(), + "active_light_projectors": (_ids["projector"] as Dictionary).size(), + "shadow_enabled_spotlights": (_ids["shadow"] as Dictionary).size(), + "gobo_visible_beams": (_ids["gobo_visible"] as Dictionary).size(), + "open_visible_beams": (_ids["open_visible"] as Dictionary).size(), + } diff --git a/scripts/runtime/fixture_light_apply_service.gd b/scripts/runtime/fixture_light_apply_service.gd index 4002533..8370eb8 100644 --- a/scripts/runtime/fixture_light_apply_service.gd +++ b/scripts/runtime/fixture_light_apply_service.gd @@ -22,6 +22,7 @@ const VISUAL_FRAME_RENDER_VALUES_OFFSET: int = VISUAL_FRAME_HEADER_COUNT + VISUA const DmxGoboControlsResolverScript = preload("res://scripts/dmx_gobo_controls_resolver.gd") const RenderDiagnosticPolicyScript = preload("res://scripts/runtime/render_diagnostic_policy.gd") +const BeamPresentationDiagnosticsScript = preload("res://scripts/runtime/beam_presentation_diagnostics.gd") var _phase_metrics: Dictionary = { "dmx_decode": {"calls": 0, "total_usec": 0}, @@ -71,6 +72,7 @@ var _light_desired_state: Dictionary = {} var _render_diagnostic_mode: String = RenderDiagnosticPolicyScript.FULL var _visible_beam_ids: Dictionary = {} var _visible_light_ids: Dictionary = {} +var _presentation_diagnostics: RefCounted = BeamPresentationDiagnosticsScript.new() var _beam_optics_signatures: Dictionary = {} var _snapshot_output_commits: Dictionary = {} var _snapshot_commit_active: bool = false @@ -231,7 +233,7 @@ func apply_emitter_intensity(loader: Node, fixture_uuid: String, dimmer_target_i var light_result: Dictionary = _apply_intensity_to_light(loader, fixture_uuid, light, photometric, changed_mask, dimmer_norm, gained_beam_energy, gained_spot_energy, gained_beam_intensity, gained_material_energy, beam_color) lights_mutated += int(light_result.get("lights_mutated", 0)) beams_mutated += int(light_result.get("beams_mutated", 0)) - if _should_enable_realtime_spotlight(loader, dimmer_norm > 0.0001): + if bool(_light_desired_for(light).get("applied_realtime_visible", false)): visible_lights += 1 if _beam_is_visible(light, _clamped_beam_intensity(_scaled_projected_value(gained_beam_intensity, photometric))): visible_beams += 1 @@ -280,7 +282,7 @@ func apply_emitter_color(loader: Node, fixture_uuid: String, color_target_id: in if light == null or not is_instance_valid(light): continue var final_light_energy: float = final_spot_energy if final_spot_energy > 0.0 else final_beam_energy - _update_desired_light_state(light, final_light_energy, float(_light_desired_for(light).get("angle", light.spot_angle)), beam_color, _should_enable_realtime_spotlight(loader, true)) + _update_desired_light_state(light, final_light_energy, float(_light_desired_for(light).get("angle", light.spot_angle)), beam_color, bool(_light_desired_for(light).get("visible", false)) and final_light_energy > 0.0) lights_mutated += _apply_desired_light_state(loader, light) var beam: MeshInstance3D = loader._get_beam_resource_for_light(light) if loader.has_method("_get_beam_resource_for_light") else null if beam != null and is_instance_valid(beam): @@ -309,7 +311,8 @@ func apply_beam_optics(loader: Node, fixture_uuid: String, optics_target_id: int var spot: SpotLight3D = light as SpotLight3D if spot == null or not is_instance_valid(spot): continue - _update_desired_light_state(spot, float(_light_desired_for(spot).get("energy", spot.light_energy)), beam_half_angle, _light_desired_for(spot).get("color", spot.light_color), _should_enable_realtime_spotlight(loader, true)) + var desired: Dictionary = _light_desired_for(spot) + _update_desired_light_state(spot, float(desired.get("energy", spot.light_energy)), beam_half_angle, desired.get("color", spot.light_color), bool(desired.get("visible", false))) _apply_desired_light_state(loader, spot) var last_params: Dictionary = spot.get_meta("peraviz_beam_last_params", {}) if spot.has_meta("peraviz_beam_last_params") else {} var state: Dictionary = _target_state(output_id, fixture_uuid) @@ -476,7 +479,7 @@ func _apply_intensity_to_light(loader: Node, fixture_uuid: String, light: SpotLi var beam_angle: float = _fixture_beam_angle(fixture_uuid) var light_energy: float = scaled_spot_energy if scaled_spot_energy > 0.0 else scaled_beam_energy var previous_beam_updates: int = int(_visual_apply_counters.get("beam_intensity_updates", 0)) - _update_desired_light_state(light, light_energy, beam_half_angle, beam_color, _should_enable_realtime_spotlight(loader, visible)) + _update_desired_light_state(light, light_energy, beam_half_angle, beam_color, visible and light_energy > 0.0) var light_writes: int = _apply_desired_light_state(loader, light) var previous_beam: MeshInstance3D = loader._get_beam_resource_for_light(light) if loader.has_method("_get_beam_resource_for_light") else null var previous_beam_visible: bool = previous_beam != null and is_instance_valid(previous_beam) and previous_beam.visible @@ -497,7 +500,7 @@ func _apply_intensity_to_light(loader: Node, fixture_uuid: String, light: SpotLi _visible_beam_ids[light_id] = true else: _visible_beam_ids.erase(light_id) - if _should_enable_realtime_spotlight(loader, visible): + if bool(_light_desired_for(light).get("applied_realtime_visible", false)): _visible_light_ids[light_id] = true else: _visible_light_ids.erase(light_id) @@ -523,10 +526,12 @@ func _update_desired_light_state(light: SpotLight3D, energy: float, angle: float state["angle"] = angle state["color"] = color state["visible"] = visible + light.set_meta("peraviz_physical_output_visible", visible) func _apply_desired_light_state(loader: Node, light: SpotLight3D) -> int: var state: Dictionary = _light_desired_for(light) - var enabled: bool = _should_enable_realtime_spotlight(loader, bool(state.get("visible", false))) and _render_diagnostic_mode == RenderDiagnosticPolicyScript.FULL + var requested_visible: bool = bool(state.get("visible", false)) + var enabled: bool = (_should_enable_realtime_spotlight(loader, requested_visible) or _requires_realtime_spotlight(loader, light, requested_visible)) and _render_diagnostic_mode == RenderDiagnosticPolicyScript.FULL var writes: int = 0 var property_writes: int = 0 if enabled: @@ -797,9 +802,8 @@ func _apply_visual_frame_materials(loader: Node, fixture_uuid: String, geometry_ func _apply_visual_frame_light(loader: Node, fixture_uuid: String, light: SpotLight3D, photometric: Dictionary, visual_mask: int, dimmer_norm: float, beam_energy: float, spot_energy: float, beam_half_angle: float, beam_angle: float, beam_color: Color, beam_intensity: float, material_energy: float, frame_delta_sec: float, gobo_norm: float, gobo_index_norm: float, gobo_rotation_norm: float, dmx_runtime: Object = null) -> Dictionary: var light_phase_start: int = Time.get_ticks_usec() var visible: bool = dimmer_norm > 0.0001 - var real_spot_visible: bool = _should_enable_realtime_spotlight(loader, visible) var light_energy: float = spot_energy if spot_energy > 0.0 else beam_energy - _update_desired_light_state(light, light_energy, beam_half_angle, beam_color, real_spot_visible) + _update_desired_light_state(light, light_energy, beam_half_angle, beam_color, visible and light_energy > 0.0) _apply_desired_light_state(loader, light) _track_phase("light_apply", light_phase_start) var gobo_topology_changed: bool = _apply_visual_frame_gobo(loader, fixture_uuid, light, visual_mask, frame_delta_sec, gobo_norm, gobo_index_norm, gobo_rotation_norm, dmx_runtime) @@ -815,6 +819,7 @@ func _apply_visual_frame_light(loader: Node, fixture_uuid: String, light: SpotLi var threshold: float = float(beam_params.get("intensity_visibility_threshold", 0.015)) var scaled_intensity: float = clamp(float(beam_params.get("scaled_intensity", beam_intensity)), 0.0, max(float(beam_params.get("intensity_max", 100.0)), 0.01)) var beam_visible: bool = scaled_intensity > threshold + var real_spot_visible: bool = bool(_light_desired_for(light).get("applied_realtime_visible", false)) _log_visual_once("beam_parent_visible_spot_rid_hidden", "[PeravizVisualRuntime] Beam intensity is visible while realtime SpotLight rendering is disabled; the SpotLight node stays visible as the beam parent and its RenderingServer instance remains hidden.", dimmer_norm > 0.0001 and beam_visible and not real_spot_visible and _is_visual_debug_logging_enabled(loader)) _warn_visual_once(str(light.get_instance_id()) + ":beam_params_not_visible", "Light %s has dimmer %.3f but beam params are not visible." % [str(light.get_instance_id()), dimmer_norm], dimmer_norm > 0.0001 and not beam_visible) return {"light_visible": real_spot_visible, "beam_visible": beam_visible} @@ -898,9 +903,11 @@ func _apply_visual_frame_beam_topology(loader: Node, light: SpotLight3D, visible _visual_apply_counters["beam_topology_rebuilds"] = int(_visual_apply_counters.get("beam_topology_rebuilds", 0)) + 1 # Keeps the light node available as a beam parent while independently gating the real SpotLight RID. -func _apply_canonical_light_visibility(_loader: Node, light: SpotLight3D, visible: bool, real_spot_visible: bool) -> int: +func _apply_canonical_light_visibility(loader: Node, light: SpotLight3D, visible: bool, real_spot_visible: bool) -> int: if light == null or not is_instance_valid(light): return 0 + var physical_output_visible: bool = bool(_light_desired_for(light).get("visible", false)) + real_spot_visible = physical_output_visible and (real_spot_visible or _requires_realtime_spotlight(loader, light, physical_output_visible)) var writes: int = 0 if light.visible != visible: light.visible = visible @@ -908,12 +915,19 @@ func _apply_canonical_light_visibility(_loader: Node, light: SpotLight3D, visibl var last_state: Dictionary = _light_desired_for(light) var previous_realtime_visible: bool = bool(last_state.get("applied_realtime_visible", not real_spot_visible)) last_state["applied_realtime_visible"] = real_spot_visible + var light_id: int = light.get_instance_id() + if real_spot_visible: + _visible_light_ids[light_id] = true + else: + _visible_light_ids.erase(light_id) + _visual_apply_counters["spotlight_visible_count"] = _visible_light_ids.size() var instance_rid: RID = light.get_instance() if instance_rid.is_valid() and previous_realtime_visible != real_spot_visible: RenderingServer.instance_set_visible(instance_rid, real_spot_visible) _visual_apply_counters["rendering_server_calls"] = int(_visual_apply_counters.get("rendering_server_calls", 0)) + 1 _visual_apply_counters["light_visibility_calls"] += 1 writes += 1 + _visual_apply_counters.merge(_presentation_diagnostics.update(loader, light), true) return writes func _apply_cached_material_state(material_rid: RID, color: Color, energy: float) -> int: @@ -943,6 +957,14 @@ func _should_enable_realtime_spotlight(loader: Node, visible: bool) -> bool: return bool((settings as Dictionary).get("enable_realtime_spotlights", false)) return false +func _requires_realtime_spotlight(loader: Node, light: SpotLight3D, visible: bool) -> bool: + if not visible or light == null or not is_instance_valid(light): + return false + if light.light_projector != null: + return true + var settings: Variant = loader.get("_visual_settings") if loader != null else null + return settings is Dictionary and int((settings as Dictionary).get("beam_presentation", 1)) in [0, 2] + # Emits a one-time diagnostic message for live visual-frame state transitions. func _is_visual_debug_logging_enabled(loader: Node) -> bool: var settings: Variant = loader.get("_visual_settings") if loader != null else null @@ -965,6 +987,13 @@ func _warn_visual_once(key: String, message: String, condition: bool = true) -> func get_visual_apply_counters() -> Dictionary: return _visual_apply_counters.duplicate(false) +func get_active_physical_emitter_ids() -> Dictionary: + var result: Dictionary = {} + for light_id in _light_desired_state: + if bool((_light_desired_state[light_id] as Dictionary).get("visible", false)): + result[light_id] = true + return result + func resolve_capability_bucket(controls: Dictionary, capability_type: String) -> Array: var capabilities: Dictionary = controls.get("capabilities", {}) if capabilities is Dictionary: diff --git a/scripts/runtime/gobo_indexed_rotation_presentation.gd b/scripts/runtime/gobo_indexed_rotation_presentation.gd index 1b2ff87..954469b 100644 --- a/scripts/runtime/gobo_indexed_rotation_presentation.gd +++ b/scripts/runtime/gobo_indexed_rotation_presentation.gd @@ -6,6 +6,7 @@ const APPLIED_ANGLE_META := "peraviz_gobo_applied_indexed_rotation_deg" const BACKEND_META := "peraviz_gobo_rotation_backend" const BASE_SHADER_ANGLE_META := "peraviz_gobo_base_shader_rotation_deg" const SHADER_BACKEND := "shader_mask" +const PARENT_ROLL_COMPENSATION_META := "peraviz_gobo_parent_roll_compensation_deg" static func apply_physical_angle(beam: MeshInstance3D, physical_angle_degrees: float, backend: String) -> void: if beam == null: @@ -26,6 +27,15 @@ static func reapply_after_base_alignment(beam: MeshInstance3D, base_shader_angle static func physical_angle(beam: MeshInstance3D) -> float: return float(beam.get_meta(ANGLE_META, 0.0)) if beam != null else 0.0 +static func apply_parent_roll_compensation(beam: MeshInstance3D, parent_roll_degrees: float) -> void: + if beam == null: + return + var previous: float = float(beam.get_meta(PARENT_ROLL_COMPENSATION_META, 0.0)) + var desired: float = -parent_roll_degrees + # The beam's local Y axis is the optical axis after its fixed renderer alignment. + beam.rotate_object_local(Vector3.UP, deg_to_rad(desired - previous)) + beam.set_meta(PARENT_ROLL_COMPENSATION_META, desired) + static func _reapply(beam: MeshInstance3D, base_alignment_was_refreshed: bool) -> void: var backend: String = str(beam.get_meta(BACKEND_META, "vector_prism")) var physical_angle_degrees: float = physical_angle(beam) diff --git a/scripts/runtime/native_gobo_resource_registry.gd b/scripts/runtime/native_gobo_resource_registry.gd index a4624d6..bf13e52 100644 --- a/scripts/runtime/native_gobo_resource_registry.gd +++ b/scripts/runtime/native_gobo_resource_registry.gd @@ -18,6 +18,10 @@ var _renderer_generation: int = 0 var _mesh_builder: RefCounted = GoboPrismMeshBuilderScript.new() var _vectorizer: Object = null var _counters: Dictionary = {} +var _presentation_callback: Callable + +func set_presentation_callback(callback: Callable) -> void: + _presentation_callback = callback func _init() -> void: if ClassDB.class_exists("PeravizGoboVectorizer"): @@ -280,16 +284,28 @@ func _apply_resource_to_target(target_record: Dictionary, resource: Dictionary) for light_item in target_record.get("emitter_anchors", []): var light: SpotLight3D = light_item as SpotLight3D if light != null: - light.set_meta("peraviz_gobo_texture", texture) + if texture != null: + light.set_meta("peraviz_gobo_texture", texture) + else: + light.remove_meta("peraviz_gobo_texture") + if _presentation_callback.is_valid(): + _presentation_callback.call(light, texture, NAN) for beam_item in target_record.get("beam_instances", []): var beam: MeshInstance3D = beam_item as MeshInstance3D - if beam != null: + if beam != null and not bool(beam.get_meta("peraviz_shader_beam_proxy_instance", false)): beam.mesh = mesh func _apply_rotation_to_target(target_record: Dictionary, physical_angle_degrees: float) -> void: var backend: String = _presentation_backend(target_record) for beam_item in target_record.get("beam_instances", []): - GoboRotationPresentationScript.apply_physical_angle(beam_item as MeshInstance3D, physical_angle_degrees, backend) + var beam: MeshInstance3D = beam_item as MeshInstance3D + if beam != null and not bool(beam.get_meta("peraviz_shader_beam_proxy_instance", false)): + GoboRotationPresentationScript.apply_physical_angle(beam, physical_angle_degrees, backend) + for light_item in target_record.get("emitter_anchors", []): + var light: SpotLight3D = light_item as SpotLight3D + if light != null and _presentation_callback.is_valid(): + var texture: Texture2D = light.get_meta("peraviz_gobo_texture") as Texture2D if light.has_meta("peraviz_gobo_texture") else null + _presentation_callback.call(light, texture, physical_angle_degrees) func _presentation_backend(target_record: Dictionary) -> String: for light_item in target_record.get("emitter_anchors", []): diff --git a/scripts/runtime/native_renderer_target_registry.gd b/scripts/runtime/native_renderer_target_registry.gd index b287c4f..da43bef 100644 --- a/scripts/runtime/native_renderer_target_registry.gd +++ b/scripts/runtime/native_renderer_target_registry.gd @@ -27,6 +27,7 @@ func configure(dependencies: Dictionary) -> void: _node_index = dependencies.get("node_index", {}) _scene_registry = dependencies.get("scene_registry", null) _callbacks = dependencies.get("callbacks", {}) + _gobo_resources.set_presentation_callback(_callbacks.get("present_native_gobo", Callable())) clear_scene_state() func clear_scene_state() -> void: @@ -151,6 +152,65 @@ func get_beam_output_record(beam_target_id: int) -> Dictionary: return {} return _beam_output_records_by_id.get(beam_target_id, {}) +func get_emitter_anchors() -> Array: + var result: Array = [] + var seen: Dictionary = {} + for record_item in _beam_output_records_by_id.values(): + var record: Dictionary = record_item + for light_item in record.get("emitter_anchors", []): + var light: SpotLight3D = light_item as SpotLight3D + if light == null or not is_instance_valid(light) or seen.has(light.get_instance_id()): + continue + seen[light.get_instance_id()] = true + result.append(light) + return result + +func get_presentation_ownership_diagnostics(active_emitter_ids: Dictionary) -> Dictionary: + var unique_emitters: Dictionary = {} + var active_emitters: Dictionary = {} + var active_records: int = 0 + for record_item in _beam_output_records_by_id.values(): + var record: Dictionary = record_item + var record_active: bool = false + for light_item in record.get("emitter_anchors", []): + var light: SpotLight3D = light_item as SpotLight3D + if light == null or not is_instance_valid(light): + continue + var light_id: int = light.get_instance_id() + unique_emitters[light_id] = true + if active_emitter_ids.has(light_id): + active_emitters[light_id] = true + record_active = true + if record_active: + active_records += 1 + return { + "native_dimmer_targets": _dimmer_targets.size(), + "native_output_records": _beam_output_records_by_id.size(), + "unique_native_emitters": unique_emitters.size(), + "active_output_records": active_records, + "active_native_emitters": active_emitters.size(), + } + +func rebind_beam_resource(light: SpotLight3D, beam: MeshInstance3D) -> void: + if light == null or not is_instance_valid(light): + return + for output_record in _beam_output_records_by_id.values(): + _rebind_record_beam(output_record as Dictionary, light, beam) + for target_map in [_dimmer_targets, _optics_targets, _color_targets]: + for target_record in (target_map as Dictionary).values(): + _rebind_record_beam(target_record as Dictionary, light, beam) + +func _rebind_record_beam(record: Dictionary, light: SpotLight3D, beam: MeshInstance3D) -> void: + var anchors: Array = record.get("emitter_anchors", []) + var index: int = anchors.find(light) + if index < 0: + return + var beams: Array = record.get("beam_instances", []) + while beams.size() <= index: + beams.append(null) + beams[index] = beam + record["beam_instances"] = beams + func get_target_failure(target_id: int) -> Variant: return _target_resolution_failures.get(target_id, null) diff --git a/scripts/shaders/fog_volume_gobo_beam.gdshader b/scripts/shaders/fog_volume_gobo_beam.gdshader index dedf56f..eafd64e 100644 --- a/scripts/shaders/fog_volume_gobo_beam.gdshader +++ b/scripts/shaders/fog_volume_gobo_beam.gdshader @@ -1,6 +1,8 @@ shader_type fog; uniform sampler2D gobo_texture : source_color; +uniform bool use_gobo = false; +uniform float intensity : hint_range(0.0, 1.0) = 0.0; uniform float density_scale : hint_range(0.0, 10.0) = 0.9; uniform float emission_strength : hint_range(0.0, 10.0) = 0.55; uniform vec4 light_color : source_color = vec4(1.0); @@ -10,6 +12,7 @@ uniform float gobo_scale : hint_range(0.05, 8.0) = 1.0; uniform float gobo_rotation_deg : hint_range(-360.0, 360.0) = 0.0; uniform float radial_falloff : hint_range(0.05, 4.0) = 1.25; uniform float longitudinal_falloff : hint_range(0.05, 4.0) = 1.1; +uniform float near_radius_ratio : hint_range(0.0, 1.0) = 0.0; vec2 rotate2d(vec2 point, float angle_rad) { float c = cos(angle_rad); @@ -19,30 +22,28 @@ vec2 rotate2d(vec2 point, float angle_rad) { void fog() { float depth_t = clamp(1.0 - UVW.z, 0.001, 1.0); - vec2 projected = (UVW.xy - vec2(0.5)) / depth_t; + vec2 transverse = (UVW.xy - vec2(0.5)) * 2.0; + float radius_at_depth = mix(near_radius_ratio, 1.0, depth_t); + vec2 projected = transverse / max(radius_at_depth, 0.001); projected = rotate2d(projected / max(gobo_scale, 0.05), radians(gobo_rotation_deg)); vec2 projected_uv = projected * 0.5 + vec2(0.5); - - if (projected_uv.x < 0.0 || projected_uv.x > 1.0 || projected_uv.y < 0.0 || projected_uv.y > 1.0) { - DENSITY = 0.0; - ALBEDO = vec3(0.0); - EMISSION = vec3(0.0); - return; - } - - vec4 gobo_sample = texture(gobo_texture, projected_uv); - float gobo_intensity = clamp(dot(gobo_sample.rgb, vec3(0.299, 0.587, 0.114)) * gobo_sample.a, 0.0, 1.0); + float projected_bounds = float(projected_uv.x >= 0.0 && projected_uv.x <= 1.0 && projected_uv.y >= 0.0 && projected_uv.y <= 1.0); + vec4 gobo_sample = texture(gobo_texture, clamp(projected_uv, vec2(0.0), vec2(1.0))); + float gobo_intensity = use_gobo ? clamp(dot(gobo_sample.rgb, vec3(0.299, 0.587, 0.114)) * gobo_sample.a, 0.0, 1.0) : 1.0; if (invert_gobo) { gobo_intensity = 1.0 - gobo_intensity; } float radial = length(projected); float radial_mask = pow(clamp(1.0 - radial, 0.0, 1.0), max(radial_falloff, 0.05)); - float edge_mask = smoothstep(edge_softness, edge_softness - 0.1, radial); + float edge_start = clamp(edge_softness - 0.1, 0.0, 0.99); + float edge_end = max(edge_softness, edge_start + 0.001); + float edge_mask = 1.0 - smoothstep(edge_start, edge_end, radial); float longitudinal_mask = pow(depth_t, max(longitudinal_falloff, 0.05)); - float final_mask = radial_mask * edge_mask * longitudinal_mask; + float final_mask = radial_mask * edge_mask * longitudinal_mask * projected_bounds; - DENSITY = gobo_intensity * density_scale * final_mask; - ALBEDO = light_color.rgb; - EMISSION = light_color.rgb * emission_strength * gobo_intensity * final_mask; + // A small density keeps Godot's fog integration active while black albedo prevents unrelated lights from coloring this volume. + DENSITY = 0.01 * final_mask; + ALBEDO = vec3(0.0); + EMISSION = light_color.rgb * emission_strength * intensity * gobo_intensity * final_mask * density_scale; } diff --git a/scripts/shaders/shader_beam_proxy.gdshader b/scripts/shaders/shader_beam_proxy.gdshader new file mode 100644 index 0000000..531c592 --- /dev/null +++ b/scripts/shaders/shader_beam_proxy.gdshader @@ -0,0 +1,47 @@ +shader_type spatial; +render_mode unshaded, blend_add, depth_draw_never, cull_disabled; + +uniform sampler2D depth_texture : hint_depth_texture, repeat_disable, filter_nearest; +uniform sampler2D gobo_texture : source_color, repeat_disable; +instance uniform vec4 beam_dynamic_state : source_color = vec4(1.0); +instance uniform float intensity_max = 100.0; +instance uniform float near_radius_ratio = 0.02; +instance uniform float gobo_rotation_deg = 0.0; +instance uniform float gobo_scale = 1.0; +instance uniform bool use_gobo = false; +instance uniform int sample_tier = 8; + +varying float proxy_view_depth; +varying float slice_index; + +void vertex() { + float axial = 0.5 - VERTEX.z; + float radius_ratio = mix(near_radius_ratio, 1.0, axial); + VERTEX.xy *= radius_ratio * 2.0; + proxy_view_depth = -(MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).z; + slice_index = UV2.y; +} + +void fragment() { + int stride = 12 / max(sample_tier, 1); + if ((int(slice_index + 0.5) % max(stride, 1)) != 0) { discard; } + vec2 centered = UV - vec2(0.5); + float radial = length(centered) * 2.0; + float edge = 1.0 - smoothstep(0.72, 1.0, radial); + float longitudinal = pow(clamp(1.0 - UV2.x * 0.65, 0.0, 1.0), 0.8); + float transmission = 1.0; + if (use_gobo) { + float angle = radians(gobo_rotation_deg); + mat2 rotation = mat2(vec2(cos(angle), -sin(angle)), vec2(sin(angle), cos(angle))); + vec2 gobo_uv = rotation * centered / max(gobo_scale, 0.05) + vec2(0.5); + vec4 gobo_sample = texture(gobo_texture, clamp(gobo_uv, vec2(0.0), vec2(1.0))); + transmission = dot(gobo_sample.rgb, vec3(0.299, 0.587, 0.114)) * gobo_sample.a; + } + float raw_depth = textureLod(depth_texture, SCREEN_UV, 0.0).r; + vec4 scene_view = INV_PROJECTION_MATRIX * vec4(SCREEN_UV * 2.0 - 1.0, raw_depth, 1.0); + float scene_depth = -scene_view.z / max(scene_view.w, 0.00001); + float depth_fade = smoothstep(-0.04, 0.08, scene_depth - proxy_view_depth); + float intensity = clamp(beam_dynamic_state.a / max(intensity_max, 0.001), 0.0, 1.0); + ALBEDO = beam_dynamic_state.rgb; + ALPHA = edge * longitudinal * transmission * intensity * 0.08 * depth_fade; +} diff --git a/scripts/shared_haze_controller.gd b/scripts/shared_haze_controller.gd new file mode 100644 index 0000000..c69e2a0 --- /dev/null +++ b/scripts/shared_haze_controller.gd @@ -0,0 +1,37 @@ +extends RefCounted +class_name SharedHazeController + +const NODE_NAME: String = "PeravizSharedHaze" + +var _volume: FogVolume + +func update(owner: Node3D, scene_bounds: AABB, enabled: bool, settings: Dictionary) -> void: + if owner == null or not is_instance_valid(owner): + return + if not enabled or scene_bounds.size == Vector3.ZERO: + if _volume != null and is_instance_valid(_volume): + _volume.visible = false + return + if _volume == null or not is_instance_valid(_volume): + _volume = FogVolume.new() + _volume.name = NODE_NAME + _volume.shape = RenderingServer.FOG_VOLUME_SHAPE_BOX + _volume.material = FogMaterial.new() + owner.add_child(_volume) + var margin: float = clamp(float(settings.get("shared_haze_margin", 5.0)), 0.0, 30.0) + _volume.global_position = scene_bounds.get_center() + _volume.size = scene_bounds.size + Vector3.ONE * margin * 2.0 + var material: FogMaterial = _volume.material as FogMaterial + material.density = clamp(float(settings.get("shared_haze_density", 0.015)), 0.0, 0.08) + material.albedo = Color(0.92, 0.94, 1.0) + material.emission = Color.BLACK + material.height_falloff = 0.0 + _volume.visible = material.density > 0.0 + +func clear() -> void: + if _volume != null and is_instance_valid(_volume): + _volume.queue_free() + _volume = null + +func get_volume() -> FogVolume: + return _volume if _volume != null and is_instance_valid(_volume) else null diff --git a/scripts/ui/visual/beam_presentation_options.gd b/scripts/ui/visual/beam_presentation_options.gd new file mode 100644 index 0000000..32ee2c7 --- /dev/null +++ b/scripts/ui/visual/beam_presentation_options.gd @@ -0,0 +1,16 @@ +extends RefCounted +class_name BeamPresentationOptions + +const LABELS: Array[String] = [ + "Shared Haze (Experimental)", + "Vector Gobo Prism (Reference)", + "Shared Haze + Gobo Shadow (Experimental)", + "Shader Beam Proxy (Experimental)", +] + +const PRESET_NAMES: Array[String] = [ + "Shared Haze", + "Vector Prism", + "Shared Haze + Gobo Shadow", + "Shader Beam Proxy", +] diff --git a/scripts/ui/visual/renderer_settings_panel.gd b/scripts/ui/visual/renderer_settings_panel.gd index 065b9eb..e7b2863 100644 --- a/scripts/ui/visual/renderer_settings_panel.gd +++ b/scripts/ui/visual/renderer_settings_panel.gd @@ -3,6 +3,8 @@ extends VBoxContainer class_name RendererSettingsPanel +const BeamPresentationOptionsScript = preload("res://scripts/ui/visual/beam_presentation_options.gd") + signal setting_changed(key: String, value: Variant) const BASIC_KEYS := { @@ -11,7 +13,7 @@ const BASIC_KEYS := { "bloom_multiplier": true, "volumetric_fog_density": true, "light_volumetric_fog_energy": true, - "beam_quality": true, + "beam_presentation": true, } var _controls: Dictionary = {} @@ -25,14 +27,16 @@ func _ready() -> void: func _build_ui() -> void: add_theme_constant_override("separation", 10) _add_slider_row("Spot intensity", "spot_multiplier", 0.0, 3.0, 0.01) - _add_slider_row("Beam intensity", "beam_multiplier", 0.0, 100.0, 0.01) + _add_slider_row("Beam / volume intensity", "beam_multiplier", 0.0, 100.0, 0.01) _add_slider_row("Bloom", "bloom_multiplier", 0.0, 3.0, 0.01) _add_slider_row("Ambient fog density", "ambient_fog_density", 0.0, 0.05, 0.001) _add_slider_row("Volumetric fog density", "volumetric_fog_density", 0.0, 0.01, 0.0001) + _add_slider_row("Shared haze density", "shared_haze_density", 0.0, 0.05, 0.0005) + _add_slider_row("Shared haze margin", "shared_haze_margin", 0.0, 30.0, 0.5) + _add_slider_row("Volumetric fog length", "volumetric_fog_length", 1.0, 200.0, 1.0) _add_slider_row("Volumetric fog fade", "volumetric_fog_fade", 0.0, 0.2, 0.005) - _add_slider_row("Light fog energy", "light_volumetric_fog_energy", 0.0, 100.0, 0.5) - _add_option_row("Beam rendering", "beam_render_mode", ["Volumetric (default)", "Lightweight (legacy)"]) - _add_option_row("Beam quality", "beam_quality", ["Low", "Medium", "High"]) + _add_slider_row("Native light fog energy", "light_volumetric_fog_energy", 0.0, 1500.0, 10.0) + _add_option_row("Beam presentation", "beam_presentation", BeamPresentationOptionsScript.LABELS) _add_color_row("Background color", "background_color") func set_advanced_mode(enabled: bool) -> void: diff --git a/scripts/visual_settings_window.gd b/scripts/visual_settings_window.gd index 7a7638e..c800ccc 100644 --- a/scripts/visual_settings_window.gd +++ b/scripts/visual_settings_window.gd @@ -10,6 +10,7 @@ const UiVisibilityPolicyScript = preload("res://scripts/ui/ui_visibility_policy. const RendererSettingsPanelScript = preload("res://scripts/ui/visual/renderer_settings_panel.gd") const EnvironmentSettingsPanelScript = preload("res://scripts/ui/visual/environment_settings_panel.gd") const DebugGoboSettingsPanelScript = preload("res://scripts/ui/visual/debug_gobo_settings_panel.gd") +const BeamPresentationOptionsScript = preload("res://scripts/ui/visual/beam_presentation_options.gd") const DEFAULT_SETTINGS := { "ambient_multiplier": 0.08, @@ -17,7 +18,7 @@ const DEFAULT_SETTINGS := { "beam_multiplier": 20.0, "bloom_multiplier": 0.0, "beam_render_mode": 0, - "beam_quality": 2, + "beam_presentation": 1, "beam_haze_density": 0.17, "beam_anisotropy": 0.62, "beam_noise_amount": 0.06, @@ -38,6 +39,9 @@ const DEFAULT_SETTINGS := { "beam_debug_optics": false, "ambient_fog_density": 0.0, "volumetric_fog_density": 0.0, + "shared_haze_density": 0.015, + "shared_haze_margin": 5.0, + "volumetric_fog_length": 110.0, "volumetric_fog_fade": 0.02, "light_volumetric_fog_energy": 12.0, "use_native_fog_projector_gobos": true, @@ -66,31 +70,52 @@ const DEFAULT_SETTINGS := { } const QUICK_PRESETS := { - "Preview": { - "beam_quality": 0, - "beam_multiplier": 12.0, - "spot_multiplier": 0.8, + "Shared Haze": { + "beam_presentation": 0, + "beam_multiplier": 20.0, + "spot_multiplier": 1.0, + "bloom_multiplier": 0.0, + "volumetric_fog_density": 0.0, + "volumetric_fog_length": 110.0, + "light_volumetric_fog_energy": 350.0, + "shared_haze_density": 0.015, + "shared_haze_margin": 5.0, + "environment_current_preset": 1, + }, + "Vector Prism": { + "beam_presentation": 1, + "beam_multiplier": 20.0, + "spot_multiplier": 1.0, "bloom_multiplier": 0.0, - "volumetric_fog_density": 0.0015, - "light_volumetric_fog_energy": 8.0, + "volumetric_fog_density": 0.0, + "volumetric_fog_length": 110.0, + "light_volumetric_fog_energy": 0.0, + "shared_haze_density": 0.015, + "shared_haze_margin": 5.0, "environment_current_preset": 1, }, - "Balanced": { - "beam_quality": 1, + "Shared Haze + Gobo Shadow": { + "beam_presentation": 2, "beam_multiplier": 20.0, "spot_multiplier": 1.0, - "bloom_multiplier": 0.25, - "volumetric_fog_density": 0.003, - "light_volumetric_fog_energy": 12.0, - "environment_current_preset": 2, + "bloom_multiplier": 0.0, + "volumetric_fog_density": 0.0, + "volumetric_fog_length": 110.0, + "light_volumetric_fog_energy": 500.0, + "shared_haze_density": 0.015, + "shared_haze_margin": 5.0, + "environment_current_preset": 1, }, - "Quality": { - "beam_quality": 2, - "beam_multiplier": 26.0, - "spot_multiplier": 1.1, - "bloom_multiplier": 0.4, - "volumetric_fog_density": 0.004, - "light_volumetric_fog_energy": 16.0, + "Shader Beam Proxy": { + "beam_presentation": 3, + "beam_multiplier": 20.0, + "spot_multiplier": 1.0, + "bloom_multiplier": 0.0, + "volumetric_fog_density": 0.0, + "volumetric_fog_length": 110.0, + "light_volumetric_fog_energy": 0.0, + "shared_haze_density": 0.015, + "shared_haze_margin": 5.0, "environment_current_preset": 1, }, } @@ -149,7 +174,6 @@ func _build_ui() -> void: root.add_child(layout) var mode_row: HBoxContainer = HBoxContainer.new() - mode_row.add_theme_constant_override("separation", 8) layout.add_child(mode_row) _advanced_mode_toggle = CheckBox.new() @@ -158,17 +182,22 @@ func _build_ui() -> void: _advanced_mode_toggle.toggled.connect(_on_advanced_mode_toggled) mode_row.add_child(_advanced_mode_toggle) - var presets_label: Label = Label.new() - presets_label.text = "Quick presets" - mode_row.add_child(presets_label) - - for preset_name in ["Preview", "Balanced", "Quality"]: - var preset_button: Button = Button.new() - preset_button.text = preset_name - preset_button.pressed.connect(func() -> void: - _apply_quick_preset(preset_name) - ) - mode_row.add_child(preset_button) + var preset_row := HBoxContainer.new() + preset_row.add_theme_constant_override("separation", 8) + layout.add_child(preset_row) + var presets_label := Label.new() + presets_label.text = "Quick preset" + preset_row.add_child(presets_label) + var preset_option := OptionButton.new() + preset_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL + for preset_name in BeamPresentationOptionsScript.PRESET_NAMES: + preset_option.add_item(preset_name) + preset_option.select(-1) + preset_option.item_selected.connect(func(index: int) -> void: + _apply_quick_preset(BeamPresentationOptionsScript.PRESET_NAMES[index]) + preset_option.select(-1) + ) + preset_row.add_child(preset_option) _tabs = TabContainer.new() _tabs.size_flags_horizontal = Control.SIZE_EXPAND_FILL diff --git a/tests/gdscript/test_gobo_presentation_modes.gd b/tests/gdscript/test_gobo_presentation_modes.gd new file mode 100644 index 0000000..8acb919 --- /dev/null +++ b/tests/gdscript/test_gobo_presentation_modes.gd @@ -0,0 +1,114 @@ +extends SceneTree + +const FixtureGoboProjectorScript = preload("res://scripts/fixture_gobo_projector.gd") +const VolumetricBeamRendererScript = preload("res://scripts/beam_renderers/volumetric_beam_renderer.gd") +const FixtureLightApplyServiceScript = preload("res://scripts/runtime/fixture_light_apply_service.gd") +const HeadlessTestCaseScript = preload("res://tests/gdscript/headless_test_case.gd") + +var test = HeadlessTestCaseScript.new() + +class VisualLoader: + extends Node + var _visual_settings: Dictionary = {"enable_realtime_spotlights": false, "beam_presentation": 1} + +func _init() -> void: + call_deferred("_run") + +func _run() -> void: + var light := SpotLight3D.new() + get_root().add_child(light) + var image := Image.create(2, 2, false, Image.FORMAT_RGBA8) + image.fill(Color.WHITE) + var texture := ImageTexture.create_from_image(image) + var projector = FixtureGoboProjectorScript.new() + projector._set_light_projector_texture(light, texture) + test.check(light.light_projector == texture and light.shadow_enabled, "An active surface projector must enable its Godot 4.7 shadow dependency") + light.set_meta("peraviz_gobo_texture", texture) + projector.set_shadow_mask_enabled(light, true) + var mask: MeshInstance3D = light.get_meta("peraviz_gobo_plane", null) as MeshInstance3D + test.check(mask != null and mask.cast_shadow == GeometryInstance3D.SHADOW_CASTING_SETTING_SHADOWS_ONLY, "Native shadow mode must create a shadows-only physical mask") + projector.set_shadow_mask_enabled(light, false) + projector._set_light_projector_texture(light, null) + test.check(light.light_projector == null and not light.shadow_enabled, "Clearing a projector must restore the previous shadow state") + projector.apply_gobo_projection(light, {"has_gobo": false, "gobo_runtime_bindings": []}) + test.check(light.light_projector == null and not light.has_meta("peraviz_gobo_texture"), "An open slot must leave normal unmasked spot lighting") + + var loader := VisualLoader.new() + get_root().add_child(loader) + var apply_service = FixtureLightApplyServiceScript.new() + apply_service._update_desired_light_state(light, 0.0, 10.0, Color.WHITE, false) + apply_service._apply_canonical_light_visibility(loader, light, true, false) + test.check(not bool(apply_service._light_desired_for(light).get("applied_realtime_visible", true)), "Parent visibility must not make an inactive physical output realtime") + apply_service._update_desired_light_state(light, 10.0, 10.0, Color.WHITE, true) + projector._set_light_projector_texture(light, texture) + apply_service._apply_canonical_light_visibility(loader, light, true, false) + test.check(bool(apply_service._light_desired_for(light).get("applied_realtime_visible", false)), "An active surface projector must keep its realtime SpotLight RID visible") + loader._visual_settings["beam_presentation"] = 0 + apply_service._apply_canonical_light_visibility(loader, light, true, false) + test.check(bool(apply_service._light_desired_for(light).get("applied_realtime_visible", false)), "Shared Haze with a surface projector must keep the realtime SpotLight RID visible") + projector._set_light_projector_texture(light, null) + loader._visual_settings["beam_presentation"] = 2 + apply_service._update_desired_light_state(light, 10.0, 10.0, Color.WHITE, true) + apply_service._apply_canonical_light_visibility(loader, light, true, false) + test.check(bool(apply_service._light_desired_for(light).get("applied_realtime_visible", false)), "Shared Haze + Gobo Shadow must keep the realtime SpotLight RID visible without a gobo") + var presentation_counters: Dictionary = apply_service.get_visual_apply_counters() + test.check(int(presentation_counters.get("beam_presentation", -1)) == 2 and int(presentation_counters.get("spotlight_visible_count", 0)) == 1, "Presentation diagnostics must report the final forced realtime spotlight state") + + var renderer = VolumetricBeamRendererScript.new() + var params := {"beam_type": "Spot", "beam_range": 8.0, "beam_angle": 20.0, "scaled_intensity": 10.0, "intensity_max": 50.0, "beam_color": Color.WHITE} + renderer.configure(null, {"beam_presentation": 0}) + var inactive_params: Dictionary = params.duplicate() + inactive_params["scaled_intensity"] = 0.0 + renderer.update_beam(light, inactive_params) + test.check(light.get_node_or_null("PeravizFogVolumeGoboBeam") == null, "Inactive Shared Haze mode must not allocate a per-emitter FogVolume") + for rejected_type in ["None", "Glow"]: + inactive_params["beam_type"] = rejected_type + inactive_params["scaled_intensity"] = 10.0 + renderer.update_beam(light, inactive_params) + test.check(light.get_node_or_null("PeravizFogVolumeGoboBeam") == null, "User-facing Shared Haze must not allocate per-emitter FogVolumes") + light.set_meta("peraviz_gobo_texture", texture) + var vector_mesh: Mesh = null + var proxy_mesh: Mesh = null + for mode in [1, 0, 2, 3, 1, 3, 1]: + renderer.cleanup_beam(light) + renderer.configure(null, {"beam_presentation": mode}) + projector.set_shadow_mask_enabled(light, mode == 2) + renderer.update_beam(light, params.duplicate()) + var fog_count: int = 1 if light.get_node_or_null("PeravizFogVolumeGoboBeam") is FogVolume else 0 + test.check(fog_count == 0, "No user-facing presentation may retain a per-emitter FogVolume") + var mask_count: int = 1 if light.has_meta("peraviz_gobo_plane") else 0 + test.check(mask_count == (1 if mode == 2 else 0), "Only Native Shadow mode may retain one physical gobo mask") + if mode == 1: + var vector_beam: MeshInstance3D = renderer.get_beam_resource(light) + test.check(vector_beam != null and vector_beam.mesh != null and vector_beam.visible, "Vector Prism mode must retain visible gobo-equipped beams") + if vector_mesh == null: + vector_mesh = vector_beam.mesh + else: + test.check(vector_beam.mesh == vector_mesh, "Presentation switching must not redesign Vector Prism topology") + elif mode == 3: + var proxy: MeshInstance3D = renderer.get_beam_resource(light) + test.check(proxy != null and proxy.visible and proxy.mesh != null, "Shader Beam Proxy must create one visible reusable proxy") + if proxy_mesh == null: + proxy_mesh = proxy.mesh + else: + test.check(proxy.mesh == proxy_mesh, "Shader proxy instances must share normalized topology") + projector._set_light_projector_texture(light, texture) + var texture_identity: Texture2D = light.light_projector + for pan_tilt in [Vector3(0.35, -0.6, 0.2), Vector3(-0.45, 0.8, -0.1)]: + light.transform = Transform3D(Basis.from_euler(pan_tilt), Vector3(1.0, 2.0, 3.0)) + light.remove_meta("peraviz_projector_base_basis") + var optical_origin := light.transform.origin + var optical_direction := -light.transform.basis.z.normalized() + for angle in [0.0, 90.0, 180.0, 270.0]: + projector.set_presentation_rotation(light, angle) + test.check(light.light_projector == texture_identity, "Surface projector rotation must retain texture identity") + test.check(light.transform.origin.is_equal_approx(optical_origin) and (-light.transform.basis.z.normalized()).is_equal_approx(optical_direction), "Gobo roll must preserve optical origin and centerline at multiple Pan/Tilt orientations") + projector.set_shadow_mask_enabled(light, true) + projector.set_presentation_rotation(light, 180.0) + var rotated_mask: MeshInstance3D = light.get_meta("peraviz_gobo_plane") as MeshInstance3D + test.check(rotated_mask.get_meta("peraviz_gobo_presentation_rotation_deg") == 180.0, "Native shadow mask must follow authoritative physical rotation") + renderer.cleanup_beam(light) + loader.queue_free() + light.queue_free() + await process_frame + test.finish(self) diff --git a/tests/gdscript/test_native_gobo_resource_registry.gd b/tests/gdscript/test_native_gobo_resource_registry.gd index d8d2db0..31c5f3b 100644 --- a/tests/gdscript/test_native_gobo_resource_registry.gd +++ b/tests/gdscript/test_native_gobo_resource_registry.gd @@ -6,9 +6,22 @@ const RotationPresentationScript = preload("res://scripts/runtime/gobo_indexed_r var test = HeadlessTestCaseScript.new() +class PresentationSink: + extends RefCounted + var texture: Texture2D + var rotation_degrees: float = NAN + var calls: int = 0 + + func present(_light: SpotLight3D, resolved_texture: Texture2D, resolved_rotation_degrees: float) -> void: + texture = resolved_texture + rotation_degrees = resolved_rotation_degrees + calls += 1 + func _init() -> void: var failures := PackedStringArray() var registry: RefCounted = RegistryScript.new() + var presentation_sink := PresentationSink.new() + registry.set_presentation_callback(Callable(presentation_sink, "present")) var paths := [_write_mask("gobo_a.png", false), _write_mask("gobo_b.png", true)] registry.install_assets([ {"gobo_asset_id": 101, "wheel_id": 11, "slot_index": 1, "extracted_media_path": paths[0], "media_valid": true}, @@ -20,9 +33,11 @@ func _init() -> void: var first: Dictionary = registry.apply_selection(77, 11, 1, 1, 101, 0, target) var first_mesh: Mesh = beam.mesh _check(bool(first.get("applied", false)) and first_mesh != null, "One native asset should install a normalized prism.", failures) + _check(presentation_sink.texture == light.get_meta("peraviz_gobo_texture", null), "Native selection must bridge the authoritative texture to presentation.", failures) var light_basis: Basis = light.transform.basis var topology_before_pos: int = int(registry.counters().get("topology_resource_updates", 0)) registry.apply_indexed_rotation(77, 11, 1, 45.0, target) + _check(is_equal_approx(presentation_sink.rotation_degrees, 45.0), "Native indexed rotation must bridge the authoritative physical angle.", failures) _check(is_equal_approx(RotationPresentationScript.physical_angle(beam), 45.0), "+45 degrees should remain an unoffset physical Pos value.", failures) _check(beam.mesh == first_mesh and light.transform.basis == light_basis, "Pos must reuse the mesh and leave SpotLight orientation unchanged.", failures) registry.apply_indexed_rotation(77, 11, 1, -45.0, target) @@ -60,6 +75,9 @@ func _init() -> void: _check(beam.transform.basis == other_wheel_basis, "An open Gobo1 Pos must not rotate the visible Gobo2 layer.", failures) registry.apply_selection(77, 12, 2, 2, 0, 0, target) _check(beam.mesh == null, "Open slots should deterministically clear the prism.", failures) + _check(not light.has_meta("peraviz_gobo_texture") and presentation_sink.texture == null, "Open slots must clear native gobo presentation state.", failures) + registry.apply_indexed_rotation(77, 12, 2, 90.0, target) + _check(presentation_sink.texture == null, "Open-slot rotation callbacks must remain safe without optional gobo metadata.", failures) beam.free() light.free() shader_beam.free() diff --git a/tests/gdscript/test_native_renderer_beam_rebind.gd b/tests/gdscript/test_native_renderer_beam_rebind.gd new file mode 100644 index 0000000..0757823 --- /dev/null +++ b/tests/gdscript/test_native_renderer_beam_rebind.gd @@ -0,0 +1,71 @@ +extends SceneTree + +const RegistryScript = preload("res://scripts/runtime/native_renderer_target_registry.gd") +const RendererScript = preload("res://scripts/beam_renderers/volumetric_beam_renderer.gd") +const HeadlessTestCaseScript = preload("res://tests/gdscript/headless_test_case.gd") + +var test = HeadlessTestCaseScript.new() + +class Harness: + extends Node + var renderer = RendererScript.new() + var base := Node3D.new() + var emitter := Node3D.new() + var light := SpotLight3D.new() + + func _ready() -> void: + base.set_meta("peraviz_gdtf_geometry_key", "fixture/Base") + base.set_meta("peraviz_fixture_uuid", "fixture") + emitter.set_meta("peraviz_gdtf_geometry_key", "fixture/Base/Beam") + emitter.set_meta("peraviz_fixture_uuid", "fixture") + emitter.set_meta("peraviz_is_emitter", true) + add_child(base) + base.add_child(emitter) + emitter.add_child(light) + renderer.configure(null, {"beam_presentation": 1}) + + func collect_emitter_lights(_key: String, _nodes: Array) -> Array: + return [light] + + func ensure_beam_runtime(spot: SpotLight3D) -> void: + renderer.ensure_beam(spot) + + func get_beam_resource(spot: SpotLight3D) -> MeshInstance3D: + return renderer.get_beam_resource(spot) + +func _init() -> void: + call_deferred("_run") + +func _run() -> void: + var harness := Harness.new() + get_root().add_child(harness) + await process_frame + var registry = RegistryScript.new() + registry.configure({"node_index": {"base": harness.base, "beam": harness.emitter}, "callbacks": {"collect_emitter_lights": Callable(harness, "collect_emitter_lights"), "ensure_beam_runtime": Callable(harness, "ensure_beam_runtime"), "get_beam_resource": Callable(harness, "get_beam_resource")}}) + registry.install_manifest([{"fixture_uuid": "fixture", "targets": [{"semantic": "beam_profile", "geometry_key": "fixture/Base/Beam", "render_target_id": 201, "beam_optical_profile": {"beam_type": "Spot", "has_projected_beam": true}}, {"semantic": "dimmer", "geometry_key": "fixture/Base", "render_target_id": 201}]}]) + test.check(is_zero_approx(harness.light.light_energy) and not harness.light.visible and harness.light.light_projector == null and not harness.light.shadow_enabled, "Native target setup must leave synthetic fixture light output black before DMX") + test.check(harness.light.get_node_or_null("PeravizFogVolumeGoboBeam") == null and not harness.light.has_meta("peraviz_gobo_plane"), "Zero-DMX setup must not create fog or mask presentation resources") + var record: Dictionary = registry.get_dimmer_target_record(201) + var previous: MeshInstance3D = record.get("beam_instances", [])[0] as MeshInstance3D + harness.renderer.cleanup_beam(harness.light) + var image := Image.create(16, 16, false, Image.FORMAT_RGBA8) + image.fill(Color.WHITE) + harness.light.set_meta("peraviz_gobo_texture", ImageTexture.create_from_image(image)) + harness.renderer.update_beam(harness.light, {"beam_type": "Spot", "beam_range": 8.0, "beam_angle": 20.0, "lens_radius": 0.03, "scaled_intensity": 10.0, "intensity_max": 50.0, "beam_color": Color.WHITE}) + var current: MeshInstance3D = harness.renderer.get_beam_resource(harness.light) + registry.rebind_beam_resource(harness.light, current) + test.check(current != previous and current.visible and current.mesh != null, "Gobo topology replacement must create a ready current Vector Prism") + test.check(registry.get_dimmer_target_record(201).get("beam_instances", [])[0] == current, "Native target records must rebind away from the stale beam resource") + harness.renderer.update_beam_intensity(harness.light, {"beam_type": "Spot", "scaled_intensity": 6.0, "intensity_max": 50.0, "beam_color": Color.RED}) + test.check(current.get_instance_shader_parameter("beam_dynamic_state") == Color(1.0, 0.0, 0.0, 6.0), "Dynamic updates must mutate the rebound current resource") + var texture: Texture2D = harness.light.get_meta("peraviz_gobo_texture") as Texture2D + for selected_texture in [null, texture, null, texture]: + harness.renderer.cleanup_beam(harness.light) + harness.light.set_meta("peraviz_gobo_texture", selected_texture) + harness.renderer.update_beam(harness.light, {"beam_type": "Spot", "beam_range": 8.0, "beam_angle": 20.0, "lens_radius": 0.03, "scaled_intensity": 8.0, "intensity_max": 50.0, "beam_color": Color.BLUE}) + current = harness.renderer.get_beam_resource(harness.light) + registry.rebind_beam_resource(harness.light, current) + test.check(registry.get_dimmer_target_record(201).get("beam_instances", [])[0] == current and current.visible, "Repeated open and gobo topology replacement must keep the native target current") + harness.queue_free() + await process_frame + test.finish(self) diff --git a/tests/gdscript/test_shader_beam_proxy.gd b/tests/gdscript/test_shader_beam_proxy.gd new file mode 100644 index 0000000..c936251 --- /dev/null +++ b/tests/gdscript/test_shader_beam_proxy.gd @@ -0,0 +1,43 @@ +extends SceneTree + +const ControllerScript = preload("res://scripts/beam_renderers/shader_beam_proxy_controller.gd") +const HeadlessTestCaseScript = preload("res://tests/gdscript/headless_test_case.gd") + +var test = HeadlessTestCaseScript.new() + +func _init() -> void: + call_deferred("_run") + +func _run() -> void: + var controller = ControllerScript.new() + var first := SpotLight3D.new() + var second := SpotLight3D.new() + get_root().add_child(first) + get_root().add_child(second) + var image := Image.create(2, 2, false, Image.FORMAT_RGBA8) + image.fill(Color.WHITE) + var texture := ImageTexture.create_from_image(image) + var params := {"beam_type": "Spot", "beam_range": 12.0, "beam_angle": 8.0, "scaled_intensity": 10.0, "intensity_max": 50.0, "beam_color": Color.RED, "gobo_rotation_deg": 0.0} + controller.update_for_light(first, params, texture) + controller.update_for_light(second, params, null) + var first_proxy := controller.get_proxy(first) + var second_proxy := controller.get_proxy(second) + test.check(first_proxy != null and first_proxy.visible, "Active output must create a visible shader proxy") + test.check(first_proxy.mesh == second_proxy.mesh, "All shader proxies must share one normalized mesh") + var mesh_identity := first_proxy.mesh + var material_identity := first_proxy.material_override + for angle in [90.0, 180.0, 270.0]: + params["gobo_rotation_deg"] = angle + controller.update_for_light(first, params, texture) + test.check(first_proxy.mesh == mesh_identity and first_proxy.material_override == material_identity, "Gobo rotation must be uniform-only") + test.check(int(first_proxy.get_meta("peraviz_shader_beam_proxy_state").get("quality_tier")) == 12, "Narrow beams must select the bounded high sample tier") + params["beam_angle"] = 40.0 + controller.update_for_light(first, params, texture) + test.check(int(first_proxy.get_meta("peraviz_shader_beam_proxy_state").get("quality_tier")) == 4, "Wide beams must select the bounded low sample tier") + params["scaled_intensity"] = 0.0 + controller.update_for_light(first, params, texture) + test.check(not first_proxy.visible and controller.get_proxy(first) == first_proxy, "Inactive proxies must be hidden and retained") + first.queue_free() + second.queue_free() + await process_frame + test.finish(self) diff --git a/tests/gdscript/test_shared_haze_presentation.gd b/tests/gdscript/test_shared_haze_presentation.gd new file mode 100644 index 0000000..23000de --- /dev/null +++ b/tests/gdscript/test_shared_haze_presentation.gd @@ -0,0 +1,30 @@ +extends SceneTree + +const ControllerScript = preload("res://scripts/shared_haze_controller.gd") +const HeadlessTestCaseScript = preload("res://tests/gdscript/headless_test_case.gd") + +var test = HeadlessTestCaseScript.new() + +func _init() -> void: + call_deferred("_run") + +func _run() -> void: + var owner := Node3D.new() + get_root().add_child(owner) + var controller = ControllerScript.new() + var bounds := AABB(Vector3(-10.0, -2.0, -8.0), Vector3(20.0, 12.0, 16.0)) + var settings := {"shared_haze_density": 0.015, "shared_haze_margin": 5.0} + for _index in range(100): + controller.update(owner, bounds, true, settings) + var volume: FogVolume = controller.get_volume() + test.check(volume != null and owner.get_child_count() == 1, "Shared Haze must own exactly one scene-level FogVolume") + test.check(volume.shape == RenderingServer.FOG_VOLUME_SHAPE_BOX and volume.size == bounds.size + Vector3.ONE * 10.0, "Shared Haze must auto-size from scene bounds plus margin") + test.check(volume.material is FogMaterial and is_equal_approx((volume.material as FogMaterial).density, 0.015), "Shared Haze must use a neutral FogMaterial density") + controller.update(owner, bounds, false, settings) + test.check(not volume.visible and controller.get_volume() == volume, "Vector mode must hide rather than recreate the shared haze") + controller.update(owner, bounds, true, settings) + test.check(controller.get_volume() == volume and volume.visible, "Both Shared Haze modes must reuse the same volume") + controller.clear() + owner.queue_free() + await process_frame + test.finish(self)