Skip to main content

khora_data/render/
mod.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Render-data layer — the per-frame extracted scene representation.
16//!
17//! Per CLAD this module is a **data** responsibility. The per-frame
18//! projection of the scene is now owned by
19//! [`RenderFlow`](crate::flow::RenderFlow), which publishes a
20//! [`RenderWorld`] into the
21//! [`LaneBus`](khora_core::lane::LaneBus) during the Substrate Pass.
22//!
23//! Render lanes consume that view from the bus; they no longer query the
24//! ECS World directly.
25
26mod editor_view;
27mod frame_graph;
28mod gizmo;
29mod grid;
30mod wireframe;
31mod world;
32
33pub use editor_view::EditorViewportOverride;
34pub use frame_graph::{
35    submit_frame_graph, FrameGraph, OverlayPassSlot, PassContribution, PassDescriptor, ResourceId,
36    ScenePassSlot, SharedFrameGraph, SkyboxPassSlot, UiPassSlot,
37};
38pub use gizmo::GizmoFrame;
39pub use grid::GridConfig;
40pub use wireframe::WireframeConfig;
41pub use world::{ExtractedLight, ExtractedMesh, ExtractedView, RenderWorld};
42
43// Shadow contract lives in `khora_core::renderer::api::shadow` now.
44// Re-export at this path for backwards-compatibility during the
45// migration; once every consumer points at the new path, these
46// aliases can be deleted.
47pub use khora_core::renderer::api::shadow::{
48    bindings as shadow_bindings, fill_shadow_bind_group_entries, shadow_bind_group_layout_entries,
49    ShadowEntries, ShadowEntry, ShadowFrame, ShadowGpuBindings,
50};
51
52use khora_core::{
53    math::{Mat4, Vec3},
54    renderer::api::resource::ViewInfo,
55    Runtime,
56};
57
58use crate::ecs::{Camera, GlobalTransform, World};
59
60/// Shadow result for a single light: view-projection matrix + atlas layer index.
61pub type ShadowResult = (khora_core::math::Mat4, i32);
62
63/// Extracts the first **active** camera as a [`ViewInfo`] suitable for
64/// pushing to the renderer.
65///
66/// Returns `None` if no entity has an active camera (editor edit mode,
67/// empty scene, etc.) — callers can fall back to a default `ViewInfo`.
68///
69/// Lives outside the [`RenderFlow`](crate::flow::RenderFlow) because it is
70/// also used by the editor for non-render concerns (camera prepare, gizmo
71/// space, etc.).
72pub fn extract_active_camera_view(world: &World) -> Option<ViewInfo> {
73    for (camera, global_transform) in world.query::<(&Camera, &GlobalTransform)>() {
74        if !camera.is_active {
75            continue;
76        }
77
78        let world_matrix = global_transform.to_matrix();
79        let camera_position = Vec3::new(
80            world_matrix.cols[3][0],
81            world_matrix.cols[3][1],
82            world_matrix.cols[3][2],
83        );
84        let view_matrix = world_matrix.inverse().unwrap_or(Mat4::IDENTITY);
85        let projection_matrix = camera.projection_matrix();
86
87        return Some(ViewInfo::new(
88            view_matrix,
89            projection_matrix,
90            camera_position,
91        ));
92    }
93    None
94}
95
96/// Returns the primary [`ExtractedView`] for the frame: the first active
97/// scene `Camera`, or — if none — the editor's
98/// [`EditorViewportOverride`].
99///
100/// Shared by [`RenderFlow`](crate::flow::RenderFlow) and
101/// [`ShadowFlow`](crate::flow::ShadowFlow) so both flows agree on which
102/// view their data is built around (CSM frustum slicing in particular
103/// needs the same camera the lit pass will sample shadows from).
104pub fn primary_view(world: &World, runtime: &Runtime) -> Option<ExtractedView> {
105    for (camera, global_transform) in world.query::<(&Camera, &GlobalTransform)>() {
106        if !camera.is_active {
107            continue;
108        }
109        let position = global_transform.0.translation();
110        let rotation = global_transform.0.rotation();
111        let view_matrix = Mat4::from_quat(rotation.inverse()) * Mat4::from_translation(-position);
112        let view_proj = camera.projection_matrix() * view_matrix;
113        return Some(ExtractedView {
114            view_proj,
115            position,
116        });
117    }
118    runtime
119        .resources
120        .get::<EditorViewportOverride>()
121        .and_then(|o| o.get())
122}
123
124/// Bit-level fingerprint of the [`EditorViewportOverride`] as seen by
125/// [`primary_view`]: distinguishes "no override" from "override present"
126/// and any change in the override's value (editor camera moves do not
127/// mutate the ECS, so flows that consult `primary_view` must fold this
128/// into their cache key or they would serve stale views while the editor
129/// camera flies).
130pub(crate) fn editor_override_fingerprint(runtime: &Runtime) -> u64 {
131    use std::hash::{Hash, Hasher};
132
133    let Some(view) = runtime
134        .resources
135        .get::<EditorViewportOverride>()
136        .and_then(|o| o.get())
137    else {
138        return 0;
139    };
140
141    let mut hasher = std::collections::hash_map::DefaultHasher::new();
142    // Presence marker, so an override that happens to hash to 0 still
143    // differs from "no override".
144    1u8.hash(&mut hasher);
145    for col in &view.view_proj.cols {
146        col.x.to_bits().hash(&mut hasher);
147        col.y.to_bits().hash(&mut hasher);
148        col.z.to_bits().hash(&mut hasher);
149        col.w.to_bits().hash(&mut hasher);
150    }
151    view.position.x.to_bits().hash(&mut hasher);
152    view.position.y.to_bits().hash(&mut hasher);
153    view.position.z.to_bits().hash(&mut hasher);
154    hasher.finish()
155}