Skip to main content

khora_data/flow/
render.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//! `RenderFlow` — projects the ECS World into a [`RenderWorld`] for the
16//! render lanes to consume from the [`LaneBus`](khora_core::lane::LaneBus).
17//!
18//! This replaces the previous `extract_scene` free function called from the
19//! engine tick. The Flow trait gives us a uniform pattern (select / adapt /
20//! project) and AGDF-ready hooks for future per-domain adaptation (LOD,
21//! frustum culling, etc.).
22
23use khora_core::{
24    asset::Material,
25    math::{Mat4, Vec3},
26    renderer::{
27        api::scene::{GpuMaterial, GpuMesh},
28        light::LightType,
29    },
30    Runtime,
31};
32
33use crate::ecs::{Camera, GlobalTransform, HandleComponent, Light, SemanticDomain, World};
34use crate::flow::{Flow, Selection};
35use crate::register_flow;
36use crate::render::{ExtractedLight, ExtractedMesh, ExtractedView, RenderWorld};
37use khora_core::ecs::entity::EntityId;
38use khora_core::interpolation::{SharedTransformInterpolation, TransformInterpolation};
39
40/// Projects the ECS World into the per-frame [`RenderWorld`] consumed by the
41/// render lanes.
42#[derive(Default)]
43pub struct RenderFlow;
44
45impl Flow for RenderFlow {
46    type View = RenderWorld;
47
48    const DOMAIN: SemanticDomain = SemanticDomain::Render;
49    const NAME: &'static str = "render";
50
51    /// The projection below reads mesh/material handles, `Light` and
52    /// `Camera` (Render domain), `GlobalTransform` (Spatial domain), and —
53    /// through `primary_view` — the editor viewport override, which is
54    /// runtime state with no ECS epoch. Folding its bit-level fingerprint
55    /// into the key keeps editor camera motion from serving stale views.
56    fn cache_key(&self, world: &World, runtime: &Runtime) -> Option<u64> {
57        Some(crate::flow::combine_cache_key([
58            world.instance_id(),
59            world.domain_epoch(SemanticDomain::Render),
60            world.domain_epoch(SemanticDomain::Spatial),
61            crate::render::editor_override_fingerprint(runtime),
62            // Render interpolation makes the projected transforms a function of
63            // the per-frame alpha, which moves without any ECS epoch change.
64            // Fold its bit pattern in so the cached view is never served stale
65            // while bodies visibly interpolate between two sim steps.
66            render_interpolation_alpha(runtime).to_bits() as u64,
67        ]))
68    }
69
70    fn project(&self, world: &World, _sel: &Selection, runtime: &Runtime) -> Self::View {
71        let mut rw = RenderWorld::new();
72        // Render-only interpolation factor between the previous and current
73        // simulation step (0 when no decoupled sim / no Time resource).
74        let alpha = render_interpolation_alpha(runtime);
75        // Engine-owned previous-pose store (resource, not a component): held
76        // read-locked across the mesh extraction. Absent in GPU-free tests.
77        let interp = runtime.resources.get::<SharedTransformInterpolation>();
78        let interp_guard = interp.as_ref().and_then(|shared| shared.read().ok());
79        extract_meshes(world, &mut rw, alpha, interp_guard.as_deref());
80        extract_lights(world, &mut rw);
81        extract_views(world, &mut rw);
82
83        // No active scene Camera (e.g. editor in Editing mode where every
84        // scene Camera is forced inactive)? Fall back to the shared
85        // primary-view resolver, which consults `EditorViewportOverride`.
86        if rw.views.is_empty() {
87            if let Some(view) = crate::render::primary_view(world, runtime) {
88                rw.views.push(view);
89            }
90        }
91        rw
92    }
93}
94
95register_flow!(RenderFlow);
96
97/// Reads the render-interpolation factor published by the scheduler into the
98/// per-frame [`Time`](khora_core::time::Time) resource. Returns `0.0` (no
99/// blend — render at the current transform) when the simulation isn't
100/// decoupled or the resource is absent (e.g. GPU-free Flow tests).
101fn render_interpolation_alpha(runtime: &Runtime) -> f32 {
102    runtime
103        .resources
104        .get::<khora_core::time::SharedTime>()
105        .and_then(|shared| shared.read().ok().map(|t| t.interpolation_alpha))
106        .unwrap_or(0.0)
107}
108
109fn extract_meshes(
110    world: &World,
111    render_world: &mut RenderWorld,
112    alpha: f32,
113    interpolation: Option<&TransformInterpolation>,
114) {
115    let query = world.query::<(EntityId, &GlobalTransform, &HandleComponent<GpuMesh>)>();
116    for (entity_id, transform, gpu_mesh_handle) in query {
117        // Per-entity component lookup, NOT positional: entities with vs without
118        // a material live in different archetypes, so correlating two separate
119        // queries by enumerate-index would mismatch (and drop the GpuMaterial).
120        let material = world
121            .get::<HandleComponent<Box<dyn Material>>>(entity_id)
122            .map(|m| m.handle.clone());
123        let gpu_material = world
124            .get::<HandleComponent<GpuMaterial>>(entity_id)
125            .map(|h| h.handle.clone());
126
127        // Render-only interpolation: when the engine recorded a previous
128        // world-space pose for this entity (it is sim-moved), blend prev →
129        // current by `alpha`. The authoritative `GlobalTransform` is untouched
130        // — only the projected transform is interpolated ("adapt the HOW, not
131        // the WHAT").
132        let transform = match interpolation.and_then(|store| store.previous(entity_id)) {
133            Some(prev) => prev.interpolate(&transform.0, alpha),
134            None => transform.0,
135        };
136
137        render_world.meshes.push(ExtractedMesh {
138            transform,
139            cpu_mesh_uuid: gpu_mesh_handle.uuid,
140            gpu_mesh: gpu_mesh_handle.handle.clone(),
141            material,
142            gpu_material,
143        });
144    }
145}
146
147fn extract_lights(world: &World, render_world: &mut RenderWorld) {
148    let light_query = world.query::<(&Light, &GlobalTransform)>();
149    for (light_comp, global_transform) in light_query {
150        if !light_comp.enabled {
151            continue;
152        }
153
154        let position = global_transform.0.translation();
155        let direction = match &light_comp.light_type {
156            LightType::Directional(dir_light) => {
157                global_transform.0.rotation() * dir_light.direction
158            }
159            LightType::Spot(spot_light) => global_transform.0.rotation() * spot_light.direction,
160            LightType::Point(_) => Vec3::ZERO,
161        };
162
163        render_world.lights.push(ExtractedLight {
164            light_type: light_comp.light_type,
165            position,
166            direction,
167            shadow_view_proj: Mat4::IDENTITY,
168            shadow_atlas_index: None,
169        });
170    }
171}
172
173fn extract_views(world: &World, render_world: &mut RenderWorld) {
174    let camera_query = world.query::<(&Camera, &GlobalTransform)>();
175    for (camera, global_transform) in camera_query {
176        if !camera.is_active {
177            continue;
178        }
179
180        let position = global_transform.0.translation();
181        let rotation = global_transform.0.rotation();
182
183        let rotation_matrix = Mat4::from_quat(rotation.inverse());
184        let translation_matrix = Mat4::from_translation(-position);
185        let view_matrix = rotation_matrix * translation_matrix;
186        let proj_matrix = camera.projection_matrix();
187        let view_proj = proj_matrix * view_matrix;
188
189        render_world.views.push(ExtractedView {
190            view_proj,
191            position,
192        });
193    }
194}