concinnity_render/backend.rs
1//! RenderBackend trait: the union of methods every graphics backend
2//! implements, dispatched dynamically by GraphicsSystem so the per-frame
3//! step + setup logic lives in one cfg-free copy instead of three.
4//!
5//! Each concrete backend (MtlContext / DxContext / VkContext) supplies a
6//! thin forwarder impl that delegates to the existing inherent methods
7//! (see metal/backend.rs, directx/backend.rs, vulkan/backend.rs).
8//!
9//! Two cross-backend signature variances are handled here:
10//! - `upload_skinned`: Metal uses three shader payloads (vert + frag +
11//! shadow); DX/VK use one (frag). The trait method takes all three;
12//! DX/VK ignore the unused bytes.
13//! - `setup_chunk_streaming`: Metal binds chunk textures per draw and
14//! ignores the (texture_slot, normal_map_slot) args; DX/VK bake them
15//! into a shared descriptor at setup time.
16//!
17//! `render_stats` has a default no-op impl so a backend with no draw-call /
18//! object counters need not override it; all three shipping backends do.
19
20use crate::auto_exposure::AutoExposureSettings;
21use crate::backend_init::{BackendInit, ShaderBytes, SwapchainConfig};
22use crate::error::{RenderError, RenderResult};
23use crate::input::RenderInput;
24use crate::keymap::KeyMap;
25use crate::mesh_payload::{SkinnedVertex, Vertex};
26use crate::profile::RenderStats;
27use crate::render_types::{
28 LineVertex, MaterialUniforms, PostProcessTunables, SkinnedDrawObject, TextDrawCall,
29};
30use crate::rt_reflections::RtReflectionSettings;
31use crate::scene_flow::SceneControl;
32use crate::ssao::SsaoSettings;
33use crate::ssgi::SsgiSettings;
34use crate::ssr::SsrSettings;
35use crate::volumetric_fog::FogSettings;
36use alloc::string::String;
37use alloc::string::ToString;
38use alloc::vec::Vec;
39
40/// Per-frame inputs for [`RenderBackend::draw_frame`]. `world_hidden` is set when
41/// an opaque menu backdrop covers the scene: the backend skips every world pass
42/// and presents only the overlay (`text_calls`) over a cleared target.
43#[derive(Clone, Copy)]
44pub struct FrameParams<'a> {
45 /// Seconds since the world started, for time-driven effects.
46 pub elapsed: f32,
47 /// Vertical field of view in radians.
48 pub fov_y_radians: f32,
49 /// Near clip distance in world units.
50 pub near: f32,
51 /// Far clip distance in world units.
52 pub far: f32,
53 /// World-space camera position.
54 pub cam_pos: [f32; 3],
55 /// Overlay draw calls for this frame.
56 pub text_calls: &'a [TextDrawCall],
57 /// Expanded line ribbons (`lines::build_vertices`) for this frame's camera,
58 /// drawn depth-tested into the scene after the world passes. Empty on any
59 /// frame that submits no lines, which also drops the pass from the graph.
60 pub lines: &'a [LineVertex],
61 /// `true` when an opaque menu backdrop covers the scene.
62 pub world_hidden: bool,
63 /// Viewport view mode + show flags for the frame (`ViewOverrides` when the
64 /// editor publishes one, defaults otherwise). Backends run their seeded
65 /// graph inputs through `render_graph::apply_view` and steer the composite
66 /// by the mode.
67 pub view_mode: concinnity_core::gfx::view_modes::ViewMode,
68 /// Feature passes to run this frame.
69 pub show: concinnity_core::gfx::view_modes::ShowFlags,
70}
71
72/// One streamed chunk's geometry plus placement, supplied to
73/// [`RenderBackend::add_chunk_mesh`]. `frame` reclaims retired deferred frees
74/// before the chunk is placed in the streaming headroom.
75#[derive(Clone, Copy)]
76pub struct ChunkMesh<'a> {
77 /// Chunk vertices.
78 pub verts: &'a [Vertex],
79 /// Chunk indices, mesh-relative.
80 pub idxs: &'a [u16],
81 /// Column-major placement matrix.
82 pub model: [[f32; 4]; 4],
83 /// Index into the shared texture pool for the albedo map.
84 pub texture_slot: usize,
85 /// Index into the shared texture pool for the normal map.
86 pub normal_map_slot: usize,
87 /// Per-chunk material scalars.
88 pub material: MaterialUniforms,
89 /// Current frame number, used to reclaim retired deferred frees.
90 pub frame: u64,
91}
92
93/// One draw slot's fresh geometry, supplied to
94/// [`RenderBackend::rebuild_static_geometry`] when an asset hot-reload
95/// changed its vertex / index count and the slot can no longer hold the new
96/// data in place. The backend rebuilds the entire shared vertex / index
97/// buffer; draws not named here keep their current geometry, copied byte-for-
98/// byte from the live buffers. `indices` are mesh-relative (0-based); the
99/// backend rebases them onto whatever new vertex region the draw lands in.
100pub struct DrawGeometryUpdate {
101 /// The draw slot whose geometry is replaced.
102 pub draw_idx: usize,
103 /// Replacement vertices.
104 pub vertices: Vec<Vertex>,
105 /// Replacement indices, mesh-relative.
106 pub indices: Vec<u16>,
107 /// One slice per additional LOD, ordered mip 0 → mip N-1. Each is
108 /// `(switch_distance, mesh-relative indices)`. Empty for meshes
109 /// declared `lod_levels <= 1`.
110 pub lod_alternates: Vec<(f32, Vec<u16>)>,
111}
112
113/// One skinned draw slot's fresh geometry, supplied to
114/// [`RenderBackend::rebuild_skinned_geometry`] when an asset hot-reload
115/// changed its vertex / index count and the slot can no longer hold the new
116/// data in its existing region of the shared skinned vertex / index buffers.
117/// The backend rebuilds both shared buffers; slots not named here keep their
118/// current geometry, copied byte-for-byte from the live buffers and re-based
119/// onto whatever new vertex region they land in. `indices` are mesh-relative
120/// (0-based); the backend rebases them onto the new vertex region.
121pub struct SkinnedDrawGeometryUpdate {
122 /// The skinned slot whose geometry is replaced.
123 pub skinned_index: usize,
124 /// Replacement vertices.
125 pub vertices: Vec<SkinnedVertex>,
126 /// Replacement indices, mesh-relative.
127 pub indices: Vec<u16>,
128}
129
130/// The post-rebuild layout for one skinned slot, returned by
131/// [`RenderBackend::rebuild_skinned_geometry`] so the asset hot-reload
132/// helper can refresh its `SkinnedMeshSourceEntry`s'
133/// `vertex_base` / `vertex_count` / `index_count` to point at the new
134/// regions. Returned for every slot (both the ones whose geometry was
135/// replaced and the ones whose geometry was carried over) because the
136/// rebuild may have shifted every slot's `vertex_base`.
137/// Constructed only by the `cn debug` binary's skinned-rebuild reload pass;
138/// reads as dead under `cargo check --lib`.
139pub struct SkinnedSlotLayout {
140 /// The skinned slot this layout describes.
141 pub skinned_index: usize,
142 /// First vertex of the slot's region in the shared skinned buffer.
143 pub vertex_base: u32,
144 /// Vertices in the slot's region.
145 pub vertex_count: usize,
146 /// Indices in the slot's region.
147 pub index_count: usize,
148}
149
150/// The resolved per-feature quality settings for [`RenderBackend::apply_quality_settings`].
151/// `GraphicsSystem` derives these from its stored `PostProcessConfig` (with the
152/// user's persisted toggle overrides applied) whenever a Quality-group toggle
153/// changes, so the backend receives ready-to-use settings rather than re-deriving
154/// from the asset. Each `Option` mirrors the init-time gate: `None` means the
155/// feature is off and its passes / resources should be torn down; `Some` means it
156/// is on and its resources should exist. A backend without a live-rebuild path
157/// ignores this (the choice still persists and applies at the next launch).
158pub struct QualitySettings {
159 /// Temporal anti-aliasing on/off (the `Taa` anti-aliasing mode). The backend
160 /// additionally suppresses TAA while temporal upscaling is active (the scaler
161 /// does its own accumulation). The other anti-aliasing modes are the composite
162 /// FXAA edge filter, which rides `PostProcessTunables.fxaa` (pushed via
163 /// `update_post_process`), not this pass-rebuild payload.
164 pub taa: bool,
165 /// Screen-space ambient occlusion, or `None` when off.
166 pub ssao: Option<SsaoSettings>,
167 /// Screen-space reflections, or `None` when off.
168 pub ssr: Option<SsrSettings>,
169 /// Hardware ray-traced reflections. The backend further gates this on GPU
170 /// ray-tracing support, falling back to leaving it off when unsupported.
171 pub rt_reflections: Option<RtReflectionSettings>,
172 /// Screen-space global illumination, or `None` when off.
173 pub ssgi: Option<SsgiSettings>,
174 /// Per-axis divisor for the roughness-aware reflection blur target (the
175 /// reduced-resolution first pass of the SSR / RT reflection composite),
176 /// resolved from `PostProcessConfig.reflection_blur_resolution`. Every backend
177 /// sizes its blur target at render / this on a live reflection rebuild.
178 pub reflection_blur_scale: u32,
179 /// Auto-exposure, or `None` when off.
180 pub auto_exposure: Option<AutoExposureSettings>,
181 /// The authored exposure bias (stops) auto-exposure applies on top of its
182 /// adapted value; carried so a live auto-exposure enable matches init.
183 pub auto_exposure_bias_ev: f32,
184}
185
186/// GPU/device capability flags, queried from the backend once it is built.
187/// Surfaced so the settings menu can gray out (and make inert) toggles the
188/// device cannot honor -- e.g. ray-traced reflections on a GPU without hardware
189/// ray tracing. Mirrors an RHI-style capability set: a handful of bools held in
190/// memory and re-queried each launch, never persisted, so it is always correct
191/// for the current device + driver.
192#[derive(Clone, Copy, Debug)]
193pub struct DeviceCapabilities {
194 /// Hardware ray tracing for the RT-reflections pass: DXR 1.1 on DirectX, the
195 /// ray-query device extensions on Vulkan (and not under XeSS), and
196 /// `MTLDevice::supportsRaytracing` on Metal.
197 pub ray_tracing: bool,
198 /// Whether the upscaler implementation is a choice (FSR3 / DLSS / XeSS)
199 /// rather than fixed. DirectX and Vulkan offer the selection; Metal always
200 /// upscales through MetalFX, so the row has nothing to pick.
201 pub selectable_upscaler: bool,
202 /// Whether a retired build-time draw slot may be recycled by a runtime
203 /// clone. Metal's per-frame RT topology refresh re-admits recycled
204 /// build-time slots; DirectX / Vulkan key their cull BVH + RT tables to
205 /// fixed build-time indices and cannot refit, so only the runtime-append
206 /// region recycles there. Read by the engine's draw-slot allocator.
207 pub reuses_build_slots: bool,
208 /// Whether a built draw slot's material and cull distance may be rewritten
209 /// in place ([`RenderBackend::set_draw_material`] /
210 /// [`RenderBackend::set_draw_cull_distance`]). Metal rebuilds its per-object
211 /// buffer from the draw list every frame, so a rewritten slot draws with the
212 /// new material next frame; DirectX / Vulkan bake per-object material state
213 /// at build time and would keep drawing the old one. Read by the editor's
214 /// live draw seam, which sends the edit to a world rebuild instead.
215 pub rewrites_draws: bool,
216}
217
218impl DeviceCapabilities {
219 /// Every capability present. The trait default, so a backend that does not
220 /// report capabilities never wrongly disables a toggle (it keeps the prior
221 /// behavior: the feature no-ops with a warning on an incapable device).
222 pub const ALL: Self = Self {
223 ray_tracing: true,
224 selectable_upscaler: true,
225 reuses_build_slots: true,
226 rewrites_draws: true,
227 };
228}
229
230impl Default for DeviceCapabilities {
231 fn default() -> Self {
232 Self::ALL
233 }
234}
235
236/// Coarse GPU vendor class, derived per backend from the adapter's reported
237/// vendor id (DirectX / Vulkan) or unified-memory / Apple-family signals (Metal).
238/// Used only to pick default quality and to gate vendor-specific options (e.g.
239/// which upscalers to offer); never persisted.
240#[derive(Clone, Copy, Debug, PartialEq, Eq)]
241pub enum GpuVendor {
242 /// Apple silicon.
243 Apple,
244 /// NVIDIA.
245 Nvidia,
246 /// AMD.
247 Amd,
248 /// Intel.
249 Intel,
250 /// A vendor the probe does not recognise.
251 Other,
252}
253
254/// Coarse performance class for default-quality selection, ordered low -> high so
255/// callers can compare with `>=`. Each backend maps its native signals (memory
256/// budget, discrete / integrated, Apple GPU family) onto this via `classify_tier`.
257#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
258pub enum GpuTier {
259 /// Unknown hardware: the conservative default, never the top preset. Sorts
260 /// lowest so a comparison-based resolver treats it as the floor.
261 Unknown,
262 /// Integrated / low-power GPU: the lowest quality tier.
263 Integrated,
264 /// Older or small discrete GPU, or an Apple base M-series: entry quality.
265 EntryDiscrete,
266 /// Mainstream discrete GPU, or an Apple Pro: mid quality.
267 MidDiscrete,
268 /// Enthusiast discrete GPU, or an Apple Max / Ultra: high quality.
269 HighDiscrete,
270}
271
272/// A coarse, Copy snapshot of the active GPU's class, queried from the backend
273/// once it is built (mirrors `DeviceCapabilities`). Read at init to choose
274/// sensible default graphics quality; never persisted, re-queried each launch so
275/// it is always correct for the current device + driver. The GPU *name* is
276/// deliberately omitted (it is not `Copy`); a backend exposes the name separately
277/// when a UI needs it.
278#[derive(Clone, Copy, Debug)]
279pub struct GpuProfile {
280 /// The GPU's vendor.
281 pub vendor: GpuVendor,
282 /// The performance tier the probe placed the GPU in.
283 pub tier: GpuTier,
284 /// Dedicated VRAM on a discrete GPU, or the recommended working-set on a
285 /// unified-memory GPU. 0 when the backend / driver cannot report it.
286 pub memory_budget_bytes: u64,
287 /// Whether the GPU shares memory with the host.
288 pub unified_memory: bool,
289 /// Whether the GPU is a discrete card.
290 pub discrete: bool,
291}
292
293impl GpuProfile {
294 /// Conservative fallback for a backend that does not report a profile:
295 /// unknown hardware picks the cautious baseline, never a high preset. The
296 /// opposite default from `DeviceCapabilities::ALL` -- a feature gate fails
297 /// open (assume capable, no-op with a warning if not), but quality
298 /// auto-config fails safe (assume modest, never overdrive a weak GPU).
299 pub const UNKNOWN: Self = Self {
300 vendor: GpuVendor::Other,
301 tier: GpuTier::Unknown,
302 memory_budget_bytes: 0,
303 unified_memory: false,
304 discrete: false,
305 };
306}
307
308impl Default for GpuProfile {
309 fn default() -> Self {
310 Self::UNKNOWN
311 }
312}
313
314/// The cheap signals every backend can gather about its GPU, mapped to a coarse
315/// `GpuTier` by one shared rule so the three backends classify consistently and
316/// the mapping is unit-testable without a GPU. The backends differ in what they
317/// can report (Apple exposes a GPU family; DirectX / Vulkan expose a VRAM figure
318/// and a discrete / integrated flag), so this carries the union and the rule
319/// uses whichever signals are present.
320pub struct GpuClassInput {
321 /// The GPU's vendor.
322 pub vendor: GpuVendor,
323 /// Device memory the driver reports as budgeted for this process.
324 pub memory_budget_bytes: u64,
325 /// Whether the GPU is a discrete card.
326 pub discrete: bool,
327 /// Apple GPU family generation rank (7 = M1 .. 10 = M4), or 0 for a non-Apple
328 /// GPU. Apple silicon classifies by generation; everything else by VRAM.
329 pub apple_family: u8,
330}
331
332/// The Apple GPU family generation rank a device name implies, or 0 when the name
333/// is not an Apple silicon GPU. Metal reads the rank straight off the device
334/// (`MTLDevice::supportsFamily`); Vulkan has no equivalent query, so a MoltenVK
335/// build recovers it from the reported device name ("Apple M2 Max"). Without it
336/// Apple silicon falls through `classify_tier`'s integrated branch and the two
337/// backends disagree on the same GPU. `M<n>` maps to `n + 6`, matching Metal's
338/// `MTLGPUFamily::Apple7` = M1.
339pub fn apple_family_from_device_name(name: &str) -> u8 {
340 let Some(rest) = name.strip_prefix("Apple M") else {
341 return 0;
342 };
343 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
344 match digits.parse::<u8>() {
345 Ok(n) if n >= 1 => n.saturating_add(6),
346 _ => 0,
347 }
348}
349
350/// Map the gathered GPU signals to a coarse performance tier. Apple silicon is
351/// classified by GPU family generation (family alone cannot separate base from
352/// Pro / Max / Ultra within a generation -- a working-set refinement can split
353/// them later); a non-Apple integrated / low-power GPU is the lowest tier; a
354/// discrete GPU is bucketed by dedicated VRAM. An unreporting device (no memory,
355/// not discrete) stays `Unknown` so the resolver uses the conservative baseline.
356pub fn classify_tier(input: &GpuClassInput) -> GpuTier {
357 const GB: u64 = 1 << 30;
358 // Apple silicon: classify by GPU family generation.
359 if input.vendor == GpuVendor::Apple && input.apple_family >= 7 {
360 return match input.apple_family {
361 7 => GpuTier::EntryDiscrete, // M1 class
362 8 => GpuTier::MidDiscrete, // M2 class
363 _ => GpuTier::HighDiscrete, // M3 / M4 and newer
364 };
365 }
366 // Any non-Apple integrated / low-power GPU is the lowest tier (Apple silicon
367 // is unified too, but it returned above via its family branch).
368 if !input.discrete {
369 return GpuTier::Integrated;
370 }
371 // Discrete GPU: bucket by dedicated VRAM.
372 match input.memory_budget_bytes {
373 0 => GpuTier::Unknown,
374 b if b >= 12 * GB => GpuTier::HighDiscrete,
375 b if b >= 6 * GB => GpuTier::MidDiscrete,
376 _ => GpuTier::EntryDiscrete,
377 }
378}
379
380/// The set of operations GraphicsSystem performs on a graphics backend.
381/// Implementations are thin forwarders to the inherent methods on
382/// MtlContext / DxContext / VkContext.
383///
384/// The asset / world.jsonl hot-reload mutators below (`update_color_lut`,
385/// `rebuild_*_geometry`, `clone_static_draw_object`, etc.) are provided no-op
386/// methods driven only by the `cn debug` binary's reload passes, so they have
387/// no call site under `cargo check --lib`. Allow dead code at the trait level
388/// rather than annotating each; the required interface methods are never
389/// subject to the lint, so this only covers the binary-driven provided methods.
390pub trait RenderBackend: SceneControl + Send {
391 /// Window / input lifecycle.
392 fn window_closed(&mut self) -> bool;
393 /// Confine the cursor to the window.
394 fn capture_cursor(&mut self);
395 /// Take the input sampled since the last call.
396 fn take_input(&mut self) -> RenderInput;
397 /// Block until the GPU has drained every submitted frame.
398 fn wait_idle(&self);
399
400 /// Per-frame drive. See [`FrameParams`] for the inputs.
401 fn draw_frame(&mut self, params: FrameParams<'_>) -> RenderResult<()>;
402 /// Push the camera's view matrix, column-major.
403 fn update_view(&mut self, matrix: [[f32; 4]; 4]);
404
405 /// Push this frame's changed model matrices, one `(draw slot, matrix)`
406 /// entry per moved draw object, applied in order. Batched so the trait is
407 /// crossed once per frame rather than once per entity; the caller sends
408 /// only slots whose matrix actually changed. An out-of-range slot is
409 /// ignored.
410 fn update_models(&mut self, updates: &[(u32, [[f32; 4]; 4])]);
411
412 /// Retire a draw object: hide it from every pass (main, shadow, velocity)
413 /// and exclude it from the ray-tracing acceleration structure, so a
414 /// despawned entity's slot leaves no ghost. The slot's geometry buffers are
415 /// untouched; the engine's draw-slot allocator returns the index to its
416 /// free list so a later `clone_static_draw_object` can recycle it. A no-op
417 /// if the index is out of range.
418 fn retire_draw_object(&mut self, draw_idx: usize);
419
420 /// Skinning. `vert_bytes` and `shadow_bytes` are Metal-only payloads;
421 /// DX/VK ignore them.
422 fn upload_skinned(
423 &mut self,
424 vertices: &[SkinnedVertex],
425 indices: &[u32],
426 draw_objects: Vec<SkinnedDrawObject>,
427 vert_bytes: &[u8],
428 frag_bytes: &[u8],
429 shadow_bytes: &[u8],
430 ) -> RenderResult<()>;
431 /// Push one skinned slot's joint matrices for this frame.
432 fn update_skinned_pose(&mut self, skinned_index: usize, matrices: &[[[f32; 4]; 4]]);
433
434 /// Attach morph-target data to the skinned draw objects, called once after
435 /// `upload_skinned`: `morphs[i]` belongs to draw object `i` (instance
436 /// copies share their template's data via the `Arc`). Default no-op for a
437 /// backend without a morph deformation path.
438 fn upload_skinned_morphs(
439 &mut self,
440 _morphs: Vec<Option<alloc::sync::Arc<crate::mesh_payload::PayloadMorphs>>>,
441 ) {
442 }
443
444 /// Push a skinned object's current morph-target weights, sampled by the
445 /// animation system each frame. A no-op when the index is out of range or
446 /// the object carries no morph targets.
447 fn update_morph_weights(&mut self, _skinned_index: usize, _weights: &[f32]) {}
448
449 // Runtime skinned spawn (pre-reserved instance pool): a backend pre-reserves
450 // hidden bind-pose copies at load (`SkinnedMesh.max_instances`) and reveals
451 // one per skinned SpawnRequest. The default no-op implementations are a
452 // fallback for a backend that has not wired runtime skinned spawn, where a
453 // skinned SpawnRequest finds nothing to claim and is dropped.
454
455 /// Reveal the pre-reserved skinned instance at `instance_index` (a hidden
456 /// bind-pose copy expanded at load): show it at `model` and reset its
457 /// palette to bind so it does not flash a previous occupant's pose. Which
458 /// instance to use is decided by the engine's instance pool; the backend
459 /// only applies it. A no-op if the index is out of range.
460 fn reveal_skinned_instance(&mut self, _instance_index: usize, _model: [[f32; 4]; 4]) {}
461
462 /// Hide a live skinned instance. The engine's instance pool returns the
463 /// slot for reuse; the backend only hides it. A no-op if the index is out
464 /// of range.
465 fn retire_skinned_draw_object(&mut self, _skinned_index: usize) {}
466
467 /// Push this frame's changed skinned model-to-world matrices, one
468 /// `(skinned index, matrix)` entry per moved instance, applied in order
469 /// (a skinned object animates in place unless something moves it). Cheap:
470 /// the per-frame cull rebuild reads the object's model directly, so this
471 /// just writes the fields. Out-of-range indices are ignored; default
472 /// no-op for a backend without movable skinned instances.
473 fn update_skinned_models(&mut self, _updates: &[(u32, [[f32; 4]; 4])]) {}
474
475 /// Texture streaming. Albedo and normal maps share one handle-indexed pool,
476 /// so every streamed texture (whatever its role) flows through these. The
477 /// image carries its GPU format and mip chain: RGBA8 regenerates mips on
478 /// upload, block-compressed formats upload their chain verbatim.
479 fn evict_texture_slot(&mut self, slot: usize) -> Result<(), String>;
480 /// Replace a texture slot's image after a streaming upload.
481 fn update_texture_slot(
482 &mut self,
483 slot: usize,
484 image: &crate::build::texture::TextureImage,
485 ) -> RenderResult<()>;
486
487 /// Mesh streaming.
488 fn evict_mesh(&mut self, draw_idx: usize, retire_frame: u64) -> Result<(), String>;
489 /// Upload a streamed mesh's geometry into a draw slot.
490 fn upload_mesh(
491 &mut self,
492 draw_idx: usize,
493 verts: &[Vertex],
494 idxs: &[u16],
495 frame: u64,
496 ) -> RenderResult<()>;
497
498 /// Seed the streamed-mesh sub-allocators with one reserved headroom block
499 /// (byte ranges in the shared vertex / index buffers) instead of the
500 /// per-mesh build-time regions. Used by the shrinkable-seed path: the
501 /// streamed geometry is no longer baked into the buffers at build time, so
502 /// the renderer hands the allocators one contiguous block sized to the
503 /// cap-many resident meshes rather than the whole streamed set. Implemented
504 /// on Metal + DirectX + Vulkan. Default no-op: a backend without the
505 /// shrinkable seed keeps freeing each mesh's build-time region in
506 /// `setup_mesh_streaming`.
507 fn seed_mesh_streaming(
508 &mut self,
509 vtx_offset: u64,
510 vtx_bytes: u64,
511 idx_offset: u64,
512 idx_bytes: u64,
513 ) {
514 let _ = (vtx_offset, vtx_bytes, idx_offset, idx_bytes);
515 }
516
517 /// Voxel-world chunk streaming. `texture_slot` and `normal_map_slot`
518 /// are ignored by Metal (it binds chunk textures per draw).
519 fn setup_chunk_streaming(
520 &mut self,
521 chunk_vtx_bytes: usize,
522 chunk_idx_bytes: usize,
523 texture_slot: usize,
524 normal_map_slot: usize,
525 ) -> RenderResult<()>;
526 /// The destination draw slot comes from the engine's allocator, like
527 /// `clone_static_draw_object`; the freed slot is likewise returned to it by
528 /// the caller of `remove_chunk_mesh`.
529 fn add_chunk_mesh(
530 &mut self,
531 mesh: ChunkMesh<'_>,
532 dst: crate::draw_slot::SlotAlloc,
533 ) -> RenderResult<()>;
534 /// Free a streamed chunk's geometry, retiring it after `retire_frame`.
535 fn remove_chunk_mesh(&mut self, draw_idx: usize, retire_frame: u64) -> Result<(), String>;
536 /// Move a streamed chunk by replacing its placement matrix.
537 fn set_chunk_model(&mut self, draw_idx: usize, model: [[f32; 4]; 4]) -> Result<(), String>;
538
539 /// Device capability flags, queried from the GPU once the backend is built.
540 /// Read by GraphicsSystem to gray out + disable settings rows the device
541 /// cannot honor. Default: all capable, so a backend that does not report
542 /// capabilities keeps every toggle live (the feature then no-ops with a
543 /// warning on an incapable device, as before).
544 fn capabilities(&self) -> DeviceCapabilities {
545 DeviceCapabilities::ALL
546 }
547
548 /// Coarse GPU performance profile, queried once the backend is built. Read at
549 /// init to pick default graphics quality on first launch. Default: `UNKNOWN`
550 /// (the conservative tier), so a backend that does not report a profile never
551 /// makes the resolver auto-select a high preset.
552 fn gpu_profile(&self) -> GpuProfile {
553 GpuProfile::UNKNOWN
554 }
555
556 /// The overlay coordinate space: the window's content size in logical,
557 /// DPI-independent units (points on macOS, client pixels on Windows, window
558 /// coordinates on Linux). Every backend reports the cursor in these same
559 /// units, so UI hit-testing, text layout, and the overlay shader's divide to
560 /// NDC all share one space regardless of the backing scale. A backend
561 /// converts to attachment pixels only where a pixel rect is unavoidable,
562 /// through `fullscreen::clip_rect_to_scissor`.
563 ///
564 /// Default `(0.0, 0.0)` for a headless backend with no window.
565 fn logical_size(&self) -> (f32, f32) {
566 (0.0, 0.0)
567 }
568 /// Per-frame draw-call / object counters. Default no-op so a backend that
569 /// tracks none still satisfies the trait; all three shipping backends
570 /// override it.
571 fn render_stats(&self) -> RenderStats {
572 RenderStats::default()
573 }
574
575 /// Show or hide the OS cursor for an in-engine UI cursor (e.g. a MainMenu),
576 /// independent of camera capture. Edge-triggered by the backend, so calling
577 /// it every frame with the same value is cheap. Default no-op: a backend
578 /// without a free-mode cursor hide leaves the system cursor visible (DX /
579 /// Vulkan today).
580 fn set_ui_cursor_hidden(&mut self, hidden: bool) {
581 let _ = hidden;
582 }
583
584 /// Whether the real cursor has left the window, so an in-engine UI cursor
585 /// should stop drawing (windowed / borderless). The backend confines the
586 /// cursor to the active screen while in fullscreen, so it reports `false`
587 /// there. Default `false` (inside): backends without window-bounds tracking
588 /// (DX / Vulkan today) always draw the in-engine cursor.
589 fn cursor_outside_window(&self) -> bool {
590 false
591 }
592
593 /// Tell the backend a togglable menu (a Screen toggled by an Escape KeyBinding)
594 /// coexists with a captured camera. In this mode Escape routes to the ECS
595 /// (so the menu shows/hides) instead of releasing the cursor inline, and a
596 /// click never recaptures the cursor (it fires a UI action). Set once at
597 /// setup. Default no-op: backends without dynamic capture (DX / Vulkan today)
598 /// keep the static behavior.
599 fn set_menu_mode(&mut self, on: bool) {
600 let _ = on;
601 }
602
603 /// Drive cursor capture from the menu state each frame: capture for camera
604 /// control, release while a menu is open. Edge-triggered by the backend.
605 /// Default no-op (DX / Vulkan): they keep their startup capture decision.
606 fn set_camera_capture(&mut self, capture: bool) {
607 let _ = capture;
608 }
609
610 /// Supply the reflection-probe placements (from declared `ReflectionProbe`
611 /// assets, or empty to auto-seed from the scene bounds). The backend bakes a
612 /// cube per placement and samples the nearest for the specular reflection.
613 /// Pushed once after construction. Default no-op: backends without probe
614 /// support (DX / Vulkan today) keep the sky reflection.
615 fn set_reflection_probes(&mut self, probes: &[crate::reflection_probe::ProbePlacement]) {
616 let _ = probes;
617 }
618
619 /// Turn display sync (vsync) on or off at runtime, applied to presentation.
620 /// Edge-triggered by the backend, so calling it with the unchanged value is
621 /// cheap. Default no-op: a backend that only honors vsync at init ignores
622 /// runtime changes.
623 fn set_vsync(&mut self, on: bool) {
624 let _ = on;
625 }
626
627 /// Switch the window between windowed / borderless / fullscreen at runtime.
628 /// The change flows through the backend's normal resize path (no GPU rebuild
629 /// beyond the resize it triggers). Default no-op for backends without a
630 /// window (embedded / preview) or that don't yet implement it.
631 fn set_window_mode(&mut self, mode: crate::components::WindowMode) {
632 let _ = mode;
633 }
634
635 /// Resize the window's content area at runtime (meaningful in windowed mode).
636 /// Drives the same resize path as a user-dragged resize. Default no-op for
637 /// backends without a window or that don't yet implement it.
638 fn set_window_size(&mut self, width: u32, height: u32) {
639 let _ = (width, height);
640 }
641
642 /// The display modes (pixel resolution + refresh rate) the display this
643 /// backend renders to supports, unshaped (the caller dedups + sorts).
644 /// Default empty: a backend that cannot enumerate (or has no window) makes
645 /// the Resolution row fall back to the static preset list.
646 fn display_modes(&self) -> Vec<crate::display_mode::DisplayMode> {
647 Vec::new()
648 }
649
650 /// The mode the display is currently running, if the backend can read it.
651 /// Shown by the Resolution row when the user has never chosen a mode (the
652 /// display keeps its desktop mode until one is chosen). Default `None`.
653 fn current_display_mode(&self) -> Option<crate::display_mode::DisplayMode> {
654 None
655 }
656
657 /// Select the display mode to hold while the window is in fullscreen. The
658 /// backend applies it whenever the window is (or becomes) fullscreen and
659 /// restores the display's original mode when the window leaves fullscreen
660 /// or shuts down; outside fullscreen the choice is only remembered. Default
661 /// no-op: a backend without mode switching leaves the display alone.
662 fn set_display_mode(&mut self, mode: crate::display_mode::DisplayMode) {
663 let _ = mode;
664 }
665
666 /// Replace the live post-process tunables (bloom / exposure / vignette /
667 /// LUT blend / FXAA). These are pushed to the bloom + composite shaders each
668 /// frame, so a change takes effect on the next draw with no allocation or
669 /// pipeline rebuild. Only the authored half travels here: the composite's
670 /// display-output flags belong to the display the backend negotiated with
671 /// at init, so a push cannot disturb them. Default no-op: a backend that
672 /// only reads the tunables at init ignores runtime changes.
673 fn update_post_process(&mut self, tunables: PostProcessTunables) {
674 let _ = tunables;
675 }
676
677 /// Set the live ambient (IBL) light scale. Unlike the post-process params
678 /// above, `ambient_intensity` lives in the shared `LightUniforms` (uploaded
679 /// each frame by the main lighting pass), so it takes its own setter rather
680 /// than `update_post_process`. Default no-op: only Metal mutates it live
681 /// today; DirectX / Vulkan keep the init-time value (they read it at init).
682 fn set_ambient_intensity(&mut self, value: f32) {
683 let _ = value;
684 }
685
686 /// Replace the live directional-light set (the sun). Unlike the local
687 /// lights, which ride a per-scene storage buffer sized once at init, the
688 /// directional slots are a fixed-size array in the shared `LightUniforms`,
689 /// so a new set is written in place: the backend re-packs the array and
690 /// re-caches whatever it derived from the first light at init (the cascade
691 /// shadow direction, the fog sun). Default no-op: a backend that only reads
692 /// the lights at init keeps the init-time sun.
693 fn update_directional_lights(&mut self, lights: &[crate::components::DirectionalLight]) {
694 let _ = lights;
695 }
696
697 /// Push the gameplay movement key map. The backend resolves each canonical
698 /// `InputKey` to its native key code and decodes physical key events through the
699 /// map (instead of hardcoded keys), so a settings-menu rebind takes effect on
700 /// the next key event. Pushed once after the backend is built and again on
701 /// each rebind. Default no-op: a backend without keymap decode keeps its
702 /// built-in defaults.
703 fn set_keymap(&mut self, keymap: &KeyMap) {
704 let _ = keymap;
705 }
706
707 /// Apply a change to the quality-feature toggles (TAA / SSAO / SSR / RT
708 /// reflections / SSGI / auto-exposure) live. Unlike the post-process params,
709 /// these gate render passes whose GPU resources (pipelines, render targets,
710 /// ray-tracing acceleration structures) are built once at init, so applying a
711 /// change rebuilds the affected resources in place rather than flipping a
712 /// uniform. Default no-op: a backend that only reads these at init ignores
713 /// runtime changes (DirectX / Vulkan today), so the choice persists and takes
714 /// effect at the next launch there.
715 fn apply_quality_settings(&mut self, settings: QualitySettings) {
716 let _ = settings;
717 }
718
719 /// Set the shadow cascade re-render cadence live. The cascade scheduler reads
720 /// the policy at the start of each shadow pass, so a change takes effect on the
721 /// next draw with no pipeline rebuild or allocation (unlike the shadow map
722 /// resolution, which is sized once at init). Default no-op: a backend that only
723 /// reads the cadence at init keeps the init-time value (DirectX / Vulkan
724 /// today), so the choice persists and takes effect at the next launch there.
725 fn set_shadow_update(&mut self, update: crate::components::ShadowUpdate) {
726 let _ = update;
727 }
728
729 /// Set the shadow distance (world units the cascades cover, capped at the
730 /// camera far plane) live. The per-frame cascade-split computation reads it
731 /// each draw, so a change takes effect on the next frame with no allocation or
732 /// rebuild (it sizes no GPU resource, unlike the shadow map resolution).
733 /// Default no-op: a backend that only reads the distance at init keeps the
734 /// init-time value (DirectX / Vulkan today), so the choice persists and takes
735 /// effect at the next launch there.
736 fn set_shadow_distance(&mut self, distance: u32) {
737 let _ = distance;
738 }
739
740 /// Set the live shadow cascade count (1..=4). The cascade-split math + the
741 /// re-render schedule read it each frame and only the first `count` cascades
742 /// are projected, rendered, and sampled (the array capacity stays 4), so a
743 /// change takes effect on the next frame with no resize or rebuild. Default
744 /// no-op: a backend that only reads the count at init keeps the init-time
745 /// value (DirectX / Vulkan today), so the choice persists and takes effect at
746 /// the next launch there.
747 fn set_shadow_cascades(&mut self, count: u32) {
748 let _ = count;
749 }
750
751 /// Update the live scalar sub-tunables of the SSAO / SSR / SSGI / auto-exposure
752 /// passes (radius, intensity, distance, EV bounds, adaptation speed). Unlike
753 /// `apply_quality_settings`, this rebuilds nothing: each backend re-reads these
754 /// values from its stored `*Settings` structs into a per-frame uniform every
755 /// draw, so mutating them takes effect on the next frame with no pipeline /
756 /// target rebuild and no TAA-history reset. Only the fields of a feature that is
757 /// currently on are honoured (its settings are present); a value for an off
758 /// feature is ignored here and applies when the feature next turns on. The
759 /// structural sub-knobs (gather resolution, ray / step counts) are NOT live and
760 /// still ride `apply_quality_settings`. Default no-op: a backend that reads
761 /// these only at init keeps the init-time values (DirectX / Vulkan today), so
762 /// the choice persists and takes effect at the next launch there.
763 fn update_quality_params(&mut self, settings: QualitySettings) {
764 let _ = settings;
765 }
766
767 /// Shared atomic flag the backend polls at frame start to trigger a
768 /// shader rebuild. `Some` only under `cn debug` on backends that ship
769 /// hot-reload (Metal today); `None` on production runs and on backends
770 /// that have not implemented hot-reload yet. The debug server reads this
771 /// to forward `reload-shaders` commands; the filesystem watcher writes
772 /// it directly. Default: `None`.
773 fn shader_reload_flag(&self) -> Option<alloc::sync::Arc<core::sync::atomic::AtomicBool>> {
774 None
775 }
776
777 /// Replace the live colour-grading LUT with a fresh `size³` RGBA8 payload.
778 /// Driven by asset hot-reload (`cn debug` only). Default no-op: backends
779 /// that have not implemented the swap leave the LUT bound at whatever
780 /// payload was uploaded at init.
781 fn update_color_lut(&mut self, size: u32, data: &[u8]) -> Result<(), String> {
782 let _ = (size, data);
783 Ok(())
784 }
785
786 /// `(vertex_count, index_count)` for the static draw at `draw_idx`, or
787 /// `None` when the index is out of range / the backend does not expose
788 /// the field. Used by asset hot-reload to detect size-changing
789 /// reloads before attempting [`Self::update_mesh_geometry`], which
790 /// rejects size mismatches. Default returns `None`; backends that
791 /// implement the rebuild path also override this.
792 fn draw_geometry_size(&self, draw_idx: usize) -> Option<(usize, usize)> {
793 let _ = draw_idx;
794 None
795 }
796
797 /// Per-LOD-alternate index counts for the static draw at `draw_idx`,
798 /// ordered from LOD1 upward (LOD0 is reported by
799 /// [`Self::draw_geometry_size`]). Returns `None` when the index is out of
800 /// range or the backend does not expose its LOD layout. Used by asset
801 /// hot-reload alongside [`Self::draw_geometry_size`] to detect
802 /// size-changing reloads: a `.glb` that re-exports with a different LOD
803 /// breakdown queues the entry for [`Self::rebuild_static_geometry`]
804 /// instead of [`Self::update_mesh_geometry`]'s in-place write.
805 fn draw_lod_index_counts(&self, draw_idx: usize) -> Option<Vec<usize>> {
806 let _ = draw_idx;
807 None
808 }
809
810 /// Rebuild the shared static-mesh vertex + index buffers, replacing the
811 /// geometry of each `DrawGeometryUpdate.draw_idx` with the new
812 /// vertices / indices / LOD alternates. Draws not named in `changes`
813 /// keep their current geometry, copied byte-for-byte from the live
814 /// buffers. The slot's `vertex_count`, `index_count`, and
815 /// `lod_alternates` index offsets are rewritten as the new buffers are
816 /// laid out. Driven by asset hot-reload (`cn debug` only) when a
817 /// size-changing `.glb` re-export means the existing
818 /// [`Self::update_mesh_geometry`] in-place write no longer fits.
819 /// `wait_idle` first; the rebuild swaps the GPU buffers wholesale.
820 /// Default no-op: backends that have not implemented the rebuild
821 /// return `Ok(())` and the size-changing reload is logged + skipped at
822 /// the caller (the existing in-place path already errored on size
823 /// mismatch).
824 fn rebuild_static_geometry(&mut self, changes: Vec<DrawGeometryUpdate>) -> RenderResult<()> {
825 let _ = changes;
826 Ok(())
827 }
828
829 /// Replace a `SkinnedMesh` draw slot's vertex + index data in place.
830 /// Driven by asset hot-reload (`cn debug` only). Reuses the slot's
831 /// existing vertex region + index region in the shared skinned vertex /
832 /// index buffers (created once by [`Self::upload_skinned`]), so the new
833 /// geometry must match the slot's init-time vertex count + index count
834 /// and the new skeleton must keep the same joint count; pipelines stay
835 /// untouched, only the bytes change. `vertex_base` is the init-time
836 /// vertex offset (in vertex units) into the shared buffer; indices are
837 /// rebased onto it before writing. Default no-op.
838 fn update_skinned_mesh_geometry(
839 &mut self,
840 skinned_index: usize,
841 vertex_base: u32,
842 verts: &[SkinnedVertex],
843 idxs: &[u16],
844 ) -> Result<(), String> {
845 let _ = (skinned_index, vertex_base, verts, idxs);
846 Ok(())
847 }
848
849 /// Rebuild the shared skinned-mesh vertex + index buffers, replacing the
850 /// geometry of each `SkinnedDrawGeometryUpdate.skinned_index` with the
851 /// new vertices / indices. Slots not named in `changes` keep their
852 /// current geometry, copied byte-for-byte from the live buffers and
853 /// re-based onto the new vertex region they land in. Returns the
854 /// post-rebuild layout (one [`SkinnedSlotLayout`] per slot, in
855 /// `skinned_index` order) so the caller can refresh its source-map
856 /// `vertex_base` / `vertex_count` / `index_count` to point at the new
857 /// regions. Driven by asset hot-reload (`cn debug` only) when a
858 /// size-changing `.glb` re-export means the existing
859 /// [`Self::update_skinned_mesh_geometry`] in-place write no longer fits.
860 /// The backend `wait_idle`s first; the rebuild swaps the GPU buffers
861 /// wholesale. The skinned pipelines, shadow + velocity + SSAO + SSR
862 /// variants, and `skinned_draw_objects` slot metadata
863 /// (`texture_slot` / `normal_map_slot` / `material` / `joint_count`)
864 /// all stay untouched; only the `index_offset` / `index_count` on each
865 /// `SkinnedDrawObject` (and the buffers themselves) move. Default no-op
866 /// (returns an empty layout vec): backends that have not implemented
867 /// the rebuild leave the size-changing reload as logged + skipped at
868 /// the caller, the same behaviour as before, since the in-place path
869 /// already errored on size mismatch.
870 fn rebuild_skinned_geometry(
871 &mut self,
872 changes: Vec<SkinnedDrawGeometryUpdate>,
873 ) -> Result<Vec<SkinnedSlotLayout>, String> {
874 let _ = changes;
875 Ok(Vec::new())
876 }
877
878 /// Update a skinned slot's joint count and resize the backend's per-slot
879 /// joint-matrix buffers to match. Driven by asset hot-reload (`cn debug`
880 /// only) when a re-imported `.glb`'s skeleton has a different joint
881 /// count than the slot was initialised with. Shrinking truncates the
882 /// per-slot Vec; growing seeds the new entries to identity so the slot
883 /// renders undeformed on the next `update_skinned_pose`. The skinned
884 /// shaders consume the joints buffer through a pointer (not a fixed-
885 /// size array) and use vertex-attribute-encoded joint indices, so no
886 /// pipeline or shader rebuild is required for a joint-count change;
887 /// only the CPU-side per-slot buffer and `SkinnedDrawObject.joint_count`
888 /// change. Default no-op: backends that have not implemented the resize
889 /// leave the skeleton-shape change logged + skipped at the caller.
890 fn update_skinned_skeleton(
891 &mut self,
892 skinned_index: usize,
893 new_joint_count: usize,
894 ) -> Result<(), String> {
895 let _ = (skinned_index, new_joint_count);
896 Ok(())
897 }
898
899 /// Replace a `Mesh` draw slot's vertex + index data in place. Driven by
900 /// asset hot-reload (`cn debug` only). Reuses the slot's existing offset
901 /// in the shared vertex / index buffers, so the new geometry must match
902 /// the slot's init-time vertex count + index count; a size-changing
903 /// reload returns an error so the caller can queue
904 /// [`Self::rebuild_static_geometry`] instead, which repacks the shared
905 /// buffers. Each entry in
906 /// `lod_alternates` (`(switch_distance, mesh-relative indices)`) is
907 /// written to the matching slot's pre-allocated LOD index region; the
908 /// number of LODs and each LOD's index count must match the slot's
909 /// init-time layout, otherwise the call returns an error so the caller
910 /// can queue [`Self::rebuild_static_geometry`]. `switch_distance` is
911 /// re-stored per LOD so a JSON-side tweak to `lod_distances` propagates
912 /// without a process restart. Default no-op.
913 fn update_mesh_geometry(
914 &mut self,
915 draw_idx: usize,
916 verts: &[Vertex],
917 idxs: &[u16],
918 lod_alternates: &[(f32, Vec<u16>)],
919 ) -> Result<(), String> {
920 let _ = (draw_idx, verts, idxs, lod_alternates);
921 Ok(())
922 }
923
924 /// Replace the live IBL environment map with a freshly precomputed payload.
925 /// `payload` is the serialised byte format emitted by
926 /// `concinnity_core::build::environment_map::compile_environment_map_payload`
927 /// (header + irradiance cube + prefilter mip chain), so init and hot-reload
928 /// share a single byte format. Driven by asset hot-reload (`cn debug`
929 /// only). Default no-op: backends that have not implemented the swap leave
930 /// the IBL cubes bound at whatever payload was uploaded at init.
931 fn update_environment_map(&mut self, payload: &[u8]) -> RenderResult<()> {
932 let _ = payload;
933 Ok(())
934 }
935
936 /// Replace the live volumetric-fog settings, or disable the fog pass when
937 /// `None`. Driven by world.jsonl hot-reload (`cn debug` only). Default
938 /// no-op: backends that have not implemented the swap leave the fog pass
939 /// at whatever settings were resolved at init.
940 ///
941 /// A backend that built its fog pipeline lazily based on the world's
942 /// init-time `VolumetricFog` cannot enable the pass via this call when
943 /// the world started with no fog declared; re-enabling fog on a world
944 /// that did not declare it at startup requires a relaunch.
945 fn update_fog_settings(&mut self, settings: Option<FogSettings>) {
946 let _ = settings;
947 }
948
949 /// Capture the last presented frame to a PNG at `path` and return the saved
950 /// path. Driven by the `cn debug` WS `screenshot` command for headless
951 /// on-GPU render verification. Default `Err`: a backend without a capture
952 /// path reports it unsupported (all current backends override this).
953 fn screenshot(&mut self, path: &str) -> Result<String, String> {
954 let _ = path;
955 Err("screenshot capture not supported on this backend".to_string())
956 }
957
958 /// Instantiate a runtime copy of an existing draw object at a new transform:
959 /// re-use the source slot's geometry region (`vertex_offset` / `vertex_count`
960 /// / `index_offset` / `index_count` / `base_vertex` / `lod_alternates`) and
961 /// copy its texture slots, material, and cull distance, swapping only the
962 /// model matrix. The new slot reuses one freed by `retire_draw_object` before
963 /// growing the draw-object vec. The destination slot comes from the
964 /// engine's draw-slot allocator: `Reuse` overwrites a vacated entry,
965 /// `Append` grows the vec (the index always equals the current length,
966 /// which implementations debug-assert). Driven by runtime entity spawn
967 /// (`SpawnRequest`). The copy is non-cullable (sentinel AABB) and drawn
968 /// every frame, since the init-time BVH cannot refit to admit a slot added
969 /// at runtime; moving copies (the common case) opt out of the static BVH
970 /// exactly like streamed chunks and held items. Default no-op (returns
971 /// `Err`): backends without an implementation leave the spawn path
972 /// logged + skipped at the caller.
973 fn clone_static_draw_object(
974 &mut self,
975 src_draw_idx: usize,
976 model: [[f32; 4]; 4],
977 dst: crate::draw_slot::SlotAlloc,
978 ) -> Result<(), String> {
979 let _ = (src_draw_idx, model, dst);
980 Err("clone_static_draw_object: not implemented on this backend".to_string())
981 }
982
983 /// Rewrite a draw slot's material parameters + texture/normal-map pool
984 /// indices in place. Driven by the editor's live draw seam when a Prop edits
985 /// its `material` arg. Default no-op; a backend that implements it reports
986 /// [`DeviceCapabilities::rewrites_draws`], which is what the caller gates on
987 /// rather than pushing an edit that would not land.
988 fn set_draw_material(
989 &mut self,
990 draw_idx: usize,
991 material: MaterialUniforms,
992 texture_slot: usize,
993 normal_map_slot: usize,
994 ) {
995 let _ = (draw_idx, material, texture_slot, normal_map_slot);
996 }
997
998 /// Rewrite a draw slot's `cull_distance` in place. Driven by the editor's
999 /// live draw seam when a Prop edits its `cull_distance` arg. Default no-op,
1000 /// gated by the same [`DeviceCapabilities::rewrites_draws`] flag.
1001 fn set_draw_cull_distance(&mut self, draw_idx: usize, cull_distance: f32) {
1002 let _ = (draw_idx, cull_distance);
1003 }
1004
1005 /// Append a projected-decal record at runtime, returning a stable slot
1006 /// index the caller hands to [`Self::remove_decal`] later. Lets a
1007 /// gameplay system stamp bullet holes, footprints, or other ad-hoc
1008 /// decals after the world has built. Backends that have not implemented
1009 /// the runtime path return `Err`; the caller logs and drops the request.
1010 fn add_decal(&mut self, record: crate::decal::DecalRecord) -> Result<usize, String> {
1011 let _ = record;
1012 Err("add_decal: not implemented on this backend".to_string())
1013 }
1014
1015 /// Tombstone a runtime decal slot. The id returned by
1016 /// [`Self::add_decal`] becomes invalid; the next add may reuse it.
1017 /// Default no-op-with-Err: backends without a runtime path leave the
1018 /// remove logged + skipped at the caller.
1019 fn remove_decal(&mut self, decal_id: usize) -> Result<(), String> {
1020 let _ = decal_id;
1021 Err("remove_decal: not implemented on this backend".to_string())
1022 }
1023
1024 /// Append a particle-emitter record at runtime, returning a stable slot
1025 /// index. The backend allocates the per-emitter GPU pool + atomic
1026 /// spawn counter (matching the init-time path) so the compute kernel
1027 /// can begin ticking on the next frame. Default no-op-with-Err.
1028 fn add_emitter(
1029 &mut self,
1030 record: crate::particles::ParticleEmitterRecord,
1031 ) -> Result<usize, String> {
1032 let _ = record;
1033 Err("add_emitter: not implemented on this backend".to_string())
1034 }
1035
1036 /// Tombstone a runtime emitter slot and release its GPU pool +
1037 /// counter buffers (the GPU keeps them alive via its own refcount
1038 /// until any in-flight command buffer that referenced them completes).
1039 /// Default no-op-with-Err.
1040 fn remove_emitter(&mut self, emitter_id: usize) -> Result<(), String> {
1041 let _ = emitter_id;
1042 Err("remove_emitter: not implemented on this backend".to_string())
1043 }
1044
1045 /// Rebuild the live main / instanced / shadow render pipelines from
1046 /// freshly compiled world-loaded shader stage bytes. Driven by asset
1047 /// hot-reload (`cn debug` only) when one of the captured `Shader`
1048 /// source files is saved or a debug-WS `reload-assets` command fires.
1049 /// Each `Some(bytes)` replaces the matching live pipeline (and any
1050 /// dependent state: bindless-texture argument encoder, cull pipeline,
1051 /// instanced variant, shadow variant); `None` leaves the pipeline
1052 /// untouched (e.g. a world without an instanced shader passes `None`
1053 /// for the instanced slot). The backend should build every replacement
1054 /// into a temporary first and only swap when every build succeeds;
1055 /// mirrors the safety pattern in the Metal backend's `hot_reload` so a
1056 /// compile error never overwrites a live pipeline with a half-built
1057 /// replacement. Default no-op (returns `Err`): backends without an
1058 /// implementation leave the world-loaded shader reload logged + skipped
1059 /// at the caller.
1060 ///
1061 /// Skinned-mesh variants are out of scope here: their pipelines depend
1062 /// on the world's `SkinnedMesh`-injected library bytes that
1063 /// [`Self::upload_skinned`] consumes and drops.
1064 fn update_world_shader_pipelines(
1065 &mut self,
1066 vert_bytes: Option<&[u8]>,
1067 frag_bytes: Option<&[u8]>,
1068 shadow_bytes: Option<&[u8]>,
1069 vert_instanced_bytes: Option<&[u8]>,
1070 ) -> Result<(), String> {
1071 let _ = (vert_bytes, frag_bytes, shadow_bytes, vert_instanced_bytes);
1072 Err("update_world_shader_pipelines: not implemented on this backend".to_string())
1073 }
1074
1075 /// Build the render pipeline for one shader bucket from its compiled stage
1076 /// bytes, making draws that carry that bucket renderable. Called by the
1077 /// streaming pump when a scene that exclusively owns the bucket's `Shader`
1078 /// pins: init skipped the build, so this is where the cost lands (behind
1079 /// the loading screen, since the bucket counts as scene-resident content).
1080 /// Bucket 0 is the world default program and is never installed this way.
1081 ///
1082 /// Default no-op-with-Ok: a backend that renders every draw with the world
1083 /// default program has no per-bucket pipeline to build, and the bucket is
1084 /// resident as far as scene loading is concerned.
1085 fn install_world_shader(&mut self, bucket: u32, shader: ShaderBytes<'_>) -> RenderResult<()> {
1086 let _ = (bucket, shader);
1087 Ok(())
1088 }
1089
1090 /// Release one shader bucket's render pipeline, undoing
1091 /// [`Self::install_world_shader`]. Called when the owning scene unpins;
1092 /// draws carrying the bucket stop rendering until it is installed again.
1093 /// Default no-op, for the same reason as above.
1094 fn evict_world_shader(&mut self, bucket: u32) {
1095 let _ = bucket;
1096 }
1097
1098 /// The swapchain-level configuration this live backend can hot-swap a world
1099 /// onto, or `None` when the backend cannot reload a world in place (it must
1100 /// be fully rebuilt instead). Read by GraphicsSystem when a transplanted
1101 /// backend is handed a new world (the `cn editor` live SAVE): the swap reuses
1102 /// the backend via [`Self::reload_world`] only when this equals the new
1103 /// world's `BackendInit::swapchain_config`; a `None` or a mismatch routes to a
1104 /// full rebuild (recreating the window). Default `None`: DirectX / Vulkan
1105 /// (and any backend without a real `reload_world`) always rebuild.
1106 fn hot_swap_config(&self) -> Option<SwapchainConfig> {
1107 None
1108 }
1109
1110 /// Re-upload a new world's GPU content onto this already-constructed backend,
1111 /// reusing the live device + window + swapchain instead of building a new one.
1112 /// Driven by the `cn editor` live SAVE: after a structural edit recompiles the
1113 /// blobs, GraphicsSystem transplants the running backend into the rebuilt
1114 /// world and calls this so the edit applies without recreating the OS window
1115 /// or re-initialising the GPU device. The backend waits for the GPU to idle,
1116 /// drops the old world's content resources, and rebuilds them from `init` on
1117 /// the retained hardware. Only ever called when [`Self::hot_swap_config`]
1118 /// reported a config matching `init.swapchain_config()`, so the swapchain
1119 /// (pixel format / frames-in-flight / EDR) is guaranteed unchanged. Default
1120 /// `Err`/unsupported: DirectX / Vulkan fall back to a full rebuild (no
1121 /// regression; a real implementation is Windows-pending like the rest).
1122 fn reload_world(&mut self, init: BackendInit<'_>) -> RenderResult<()> {
1123 let _ = init;
1124 Err(RenderError::Other(
1125 "reload_world: not supported on this backend".to_string(),
1126 ))
1127 }
1128}
1129
1130// A do-nothing backend used to exercise the trait's provided (default) method
1131// bodies without a GPU: the smallest valid bodies for the required methods,
1132// no defaults overridden. Shared by this module's tests and the ops tests.
1133#[cfg(test)]
1134pub(crate) mod test_stub {
1135 use super::*;
1136
1137 pub(crate) struct StubBackend;
1138
1139 impl SceneControl for StubBackend {
1140 fn update_visibility(&mut self, _draw_idx: usize, _visible: bool) {}
1141 fn set_fade(&mut self, _fade: f32) {}
1142 }
1143
1144 impl RenderBackend for StubBackend {
1145 fn window_closed(&mut self) -> bool {
1146 false
1147 }
1148 fn capture_cursor(&mut self) {}
1149 fn take_input(&mut self) -> RenderInput {
1150 RenderInput::default()
1151 }
1152 fn wait_idle(&self) {}
1153 fn draw_frame(&mut self, _params: FrameParams<'_>) -> RenderResult<()> {
1154 Ok(())
1155 }
1156 fn update_view(&mut self, _matrix: [[f32; 4]; 4]) {}
1157 fn update_models(&mut self, _updates: &[(u32, [[f32; 4]; 4])]) {}
1158 fn retire_draw_object(&mut self, _draw_idx: usize) {}
1159 fn upload_skinned(
1160 &mut self,
1161 _vertices: &[SkinnedVertex],
1162 _indices: &[u32],
1163 _draw_objects: Vec<SkinnedDrawObject>,
1164 _vert_bytes: &[u8],
1165 _frag_bytes: &[u8],
1166 _shadow_bytes: &[u8],
1167 ) -> RenderResult<()> {
1168 Ok(())
1169 }
1170 fn update_skinned_pose(&mut self, _skinned_index: usize, _matrices: &[[[f32; 4]; 4]]) {}
1171 fn evict_texture_slot(&mut self, _slot: usize) -> Result<(), String> {
1172 Ok(())
1173 }
1174 fn update_texture_slot(
1175 &mut self,
1176 _slot: usize,
1177 _image: &crate::build::texture::TextureImage,
1178 ) -> RenderResult<()> {
1179 Ok(())
1180 }
1181 fn evict_mesh(&mut self, _draw_idx: usize, _retire_frame: u64) -> Result<(), String> {
1182 Ok(())
1183 }
1184 fn upload_mesh(
1185 &mut self,
1186 _draw_idx: usize,
1187 _verts: &[Vertex],
1188 _idxs: &[u16],
1189 _frame: u64,
1190 ) -> RenderResult<()> {
1191 Ok(())
1192 }
1193 fn setup_chunk_streaming(
1194 &mut self,
1195 _chunk_vtx_bytes: usize,
1196 _chunk_idx_bytes: usize,
1197 _texture_slot: usize,
1198 _normal_map_slot: usize,
1199 ) -> RenderResult<()> {
1200 Ok(())
1201 }
1202 fn add_chunk_mesh(
1203 &mut self,
1204 _mesh: ChunkMesh<'_>,
1205 _dst: crate::draw_slot::SlotAlloc,
1206 ) -> RenderResult<()> {
1207 Ok(())
1208 }
1209 fn remove_chunk_mesh(
1210 &mut self,
1211 _draw_idx: usize,
1212 _retire_frame: u64,
1213 ) -> Result<(), String> {
1214 Ok(())
1215 }
1216 fn set_chunk_model(
1217 &mut self,
1218 _draw_idx: usize,
1219 _model: [[f32; 4]; 4],
1220 ) -> Result<(), String> {
1221 Ok(())
1222 }
1223 }
1224}
1225
1226#[cfg(test)]
1227mod tests {
1228 use super::*;
1229
1230 use alloc::vec;
1231 const GB: u64 = 1 << 30;
1232
1233 fn input(
1234 vendor: GpuVendor,
1235 memory_budget_bytes: u64,
1236 discrete: bool,
1237 apple_family: u8,
1238 ) -> GpuClassInput {
1239 GpuClassInput {
1240 vendor,
1241 memory_budget_bytes,
1242 discrete,
1243 apple_family,
1244 }
1245 }
1246
1247 #[test]
1248 fn unknown_profile_is_the_conservative_default() {
1249 // The opposite default from capabilities: quality auto-config fails safe.
1250 let p = GpuProfile::default();
1251 assert_eq!(p.tier, GpuTier::Unknown);
1252 assert_eq!(p.vendor, GpuVendor::Other);
1253 assert_eq!(p.memory_budget_bytes, 0);
1254 // Unknown sorts below every real tier, so a `>=` resolver treats it as
1255 // the floor.
1256 assert!(GpuTier::Unknown < GpuTier::Integrated);
1257 assert!(GpuTier::Integrated < GpuTier::EntryDiscrete);
1258 assert!(GpuTier::EntryDiscrete < GpuTier::MidDiscrete);
1259 assert!(GpuTier::MidDiscrete < GpuTier::HighDiscrete);
1260 }
1261
1262 #[test]
1263 fn apple_family_reads_the_generation_out_of_the_device_name() {
1264 // The names MoltenVK reports, mapped onto Metal's family ranks.
1265 assert_eq!(apple_family_from_device_name("Apple M1"), 7);
1266 assert_eq!(apple_family_from_device_name("Apple M2 Max"), 8);
1267 assert_eq!(apple_family_from_device_name("Apple M3 Pro"), 9);
1268 assert_eq!(apple_family_from_device_name("Apple M4 Ultra"), 10);
1269 // A generation past what Metal's SDK names yet still ranks above M3, so
1270 // a newer Mac is not demoted.
1271 assert!(apple_family_from_device_name("Apple M9") > 9);
1272 }
1273
1274 #[test]
1275 fn non_apple_device_names_report_no_family() {
1276 for name in [
1277 "NVIDIA GeForce RTX 4090",
1278 "AMD Radeon RX 7900 XTX",
1279 "Intel(R) Arc(tm) A770",
1280 // Apple's own non-M naming, and a truncated / malformed report.
1281 "Apple A17 Pro",
1282 "Apple M",
1283 "Apple MX",
1284 "",
1285 ] {
1286 assert_eq!(apple_family_from_device_name(name), 0, "{name}");
1287 }
1288 }
1289
1290 #[test]
1291 fn apple_family_from_a_name_reaches_the_same_tier_metal_does() {
1292 // The whole point of the name probe: a MoltenVK build must land on the
1293 // tier the Metal backend reports for the same silicon, not on the
1294 // integrated floor a zero family falls through to.
1295 let family = apple_family_from_device_name("Apple M2 Max");
1296 assert_eq!(
1297 classify_tier(&input(GpuVendor::Apple, 32 * GB, false, family)),
1298 GpuTier::MidDiscrete
1299 );
1300 assert_eq!(
1301 classify_tier(&input(GpuVendor::Apple, 32 * GB, false, 0)),
1302 GpuTier::Integrated
1303 );
1304 }
1305
1306 #[test]
1307 fn apple_silicon_classifies_by_generation() {
1308 // Unified memory is large on Apple silicon, but the family generation
1309 // (not the working-set) decides the tier, so the huge shared budget does
1310 // not read as a high-VRAM discrete card.
1311 assert_eq!(
1312 classify_tier(&input(GpuVendor::Apple, 16 * GB, false, 7)),
1313 GpuTier::EntryDiscrete // M1
1314 );
1315 assert_eq!(
1316 classify_tier(&input(GpuVendor::Apple, 24 * GB, false, 8)),
1317 GpuTier::MidDiscrete // M2
1318 );
1319 assert_eq!(
1320 classify_tier(&input(GpuVendor::Apple, 48 * GB, false, 9)),
1321 GpuTier::HighDiscrete // M3
1322 );
1323 assert_eq!(
1324 classify_tier(&input(GpuVendor::Apple, 64 * GB, false, 10)),
1325 GpuTier::HighDiscrete // M4 and newer cap at high
1326 );
1327 }
1328
1329 #[test]
1330 fn discrete_gpu_classifies_by_vram() {
1331 // An Intel-Mac AMD dGPU or a PC discrete card: vendor is not Apple and
1332 // there is no Apple family, so VRAM buckets the tier.
1333 assert_eq!(
1334 classify_tier(&input(GpuVendor::Nvidia, 24 * GB, true, 0)),
1335 GpuTier::HighDiscrete
1336 );
1337 assert_eq!(
1338 classify_tier(&input(GpuVendor::Amd, 8 * GB, true, 0)),
1339 GpuTier::MidDiscrete
1340 );
1341 assert_eq!(
1342 classify_tier(&input(GpuVendor::Nvidia, 4 * GB, true, 0)),
1343 GpuTier::EntryDiscrete
1344 );
1345 // A discrete card that reports no memory budget is left Unknown rather
1346 // than guessed high.
1347 assert_eq!(
1348 classify_tier(&input(GpuVendor::Amd, 0, true, 0)),
1349 GpuTier::Unknown
1350 );
1351 }
1352
1353 #[test]
1354 fn integrated_gpu_is_the_lowest_tier() {
1355 // Non-Apple integrated part: no dedicated memory, not unified, no Apple
1356 // family.
1357 assert_eq!(
1358 classify_tier(&input(GpuVendor::Intel, 0, false, 0)),
1359 GpuTier::Integrated
1360 );
1361 }
1362
1363 #[test]
1364 fn vram_bucket_boundaries() {
1365 // Boundaries are inclusive lower bounds (>= 12 GB high, >= 6 GB mid).
1366 assert_eq!(
1367 classify_tier(&input(GpuVendor::Nvidia, 12 * GB, true, 0)),
1368 GpuTier::HighDiscrete
1369 );
1370 assert_eq!(
1371 classify_tier(&input(GpuVendor::Nvidia, 12 * GB - 1, true, 0)),
1372 GpuTier::MidDiscrete
1373 );
1374 assert_eq!(
1375 classify_tier(&input(GpuVendor::Nvidia, 6 * GB, true, 0)),
1376 GpuTier::MidDiscrete
1377 );
1378 assert_eq!(
1379 classify_tier(&input(GpuVendor::Nvidia, 6 * GB - 1, true, 0)),
1380 GpuTier::EntryDiscrete
1381 );
1382 }
1383
1384 pub(crate) use super::test_stub::StubBackend;
1385
1386 const IDENTITY: [[f32; 4]; 4] = [
1387 [1.0, 0.0, 0.0, 0.0],
1388 [0.0, 1.0, 0.0, 0.0],
1389 [0.0, 0.0, 1.0, 0.0],
1390 [0.0, 0.0, 0.0, 1.0],
1391 ];
1392
1393 // Minimal QualitySettings with every feature off, so no *Settings sub-type
1394 // needs constructing.
1395 fn stub_quality() -> QualitySettings {
1396 QualitySettings {
1397 taa: false,
1398 ssao: None,
1399 ssr: None,
1400 rt_reflections: None,
1401 ssgi: None,
1402 reflection_blur_scale: 1,
1403 auto_exposure: None,
1404 auto_exposure_bias_ev: 0.0,
1405 }
1406 }
1407
1408 #[test]
1409 fn default_query_methods_report_conservative_values() {
1410 let backend = StubBackend;
1411 // Capabilities fail open: a backend that does not report keeps every
1412 // toggle live.
1413 assert!(backend.capabilities().ray_tracing);
1414 // Quality auto-config fails safe: the unknown/conservative profile.
1415 assert_eq!(backend.gpu_profile().tier, GpuTier::Unknown);
1416 assert_eq!(backend.gpu_profile().vendor, GpuVendor::Other);
1417 assert_eq!(backend.gpu_profile().memory_budget_bytes, 0);
1418 // Diagnostics a backend may leave to the default: zeroed here.
1419 assert_eq!(backend.logical_size(), (0.0, 0.0));
1420 assert_eq!(backend.render_stats(), RenderStats::default());
1421 // No window-bounds tracking: the in-engine cursor always draws.
1422 assert!(!backend.cursor_outside_window());
1423 // No display enumeration and no hot-reload flag wired.
1424 assert!(backend.display_modes().is_empty());
1425 assert!(backend.current_display_mode().is_none());
1426 assert!(backend.shader_reload_flag().is_none());
1427 // Not hot-swap-capable: a live world reload routes to a full rebuild.
1428 assert!(backend.hot_swap_config().is_none());
1429 // No geometry-size introspection for the reload size check.
1430 assert!(backend.draw_geometry_size(0).is_none());
1431 assert!(backend.draw_lod_index_counts(0).is_none());
1432 }
1433
1434 #[test]
1435 fn default_mutators_are_noops_and_fallible_hooks_report_defaults() {
1436 let mut backend = StubBackend;
1437
1438 // Runtime skinned-spawn fallbacks: nothing to reveal or hide.
1439 backend.reveal_skinned_instance(0, IDENTITY);
1440 backend.retire_skinned_draw_object(0);
1441 backend.update_skinned_models(&[(0, IDENTITY)]);
1442
1443 // Streaming + cursor + capture no-ops.
1444 backend.seed_mesh_streaming(0, 0, 0, 0);
1445 backend.set_ui_cursor_hidden(true);
1446 backend.set_menu_mode(true);
1447 backend.set_camera_capture(true);
1448 backend.set_reflection_probes(&[]);
1449
1450 // Presentation + window no-ops.
1451 backend.set_vsync(true);
1452 backend.set_window_mode(crate::components::WindowMode::Fullscreen);
1453 backend.set_window_size(1280, 720);
1454 backend.set_display_mode(crate::display_mode::DisplayMode {
1455 width: 1920,
1456 height: 1080,
1457 refresh_hz: 60,
1458 });
1459
1460 // Live look + input tunable no-ops.
1461 backend.update_post_process(PostProcessTunables::DEFAULT);
1462 backend.set_ambient_intensity(1.0);
1463 backend.set_keymap(&KeyMap::default());
1464 backend.apply_quality_settings(stub_quality());
1465 backend.update_quality_params(stub_quality());
1466 backend.set_shadow_update(crate::components::ShadowUpdate::EveryFrame);
1467 backend.set_shadow_distance(200);
1468 backend.set_shadow_cascades(3);
1469 backend.update_fog_settings(None);
1470 backend.update_directional_lights(&[]);
1471 backend.set_draw_material(0, MaterialUniforms::DEFAULT, 0, 0);
1472 backend.set_draw_cull_distance(0, 50.0);
1473
1474 // Fallible hot-reload hooks that succeed by default (no-op Ok).
1475 assert!(backend.update_color_lut(2, &[0u8; 32]).is_ok());
1476 assert!(backend.rebuild_static_geometry(vec![]).is_ok());
1477 assert!(backend.update_skinned_mesh_geometry(0, 0, &[], &[]).is_ok());
1478 assert!(backend.rebuild_skinned_geometry(vec![]).unwrap().is_empty());
1479 assert!(backend.update_skinned_skeleton(0, 0).is_ok());
1480 assert!(backend.update_mesh_geometry(0, &[], &[], &[]).is_ok());
1481 assert!(backend.update_environment_map(&[]).is_ok());
1482
1483 // Fallible hooks a bare backend does not implement: they report Err.
1484 assert!(backend.screenshot("unused.png").is_err());
1485 assert!(
1486 backend
1487 .clone_static_draw_object(0, IDENTITY, crate::draw_slot::SlotAlloc::Append(0))
1488 .is_err()
1489 );
1490 assert!(backend.add_decal(stub_decal()).is_err());
1491 assert!(backend.remove_decal(0).is_err());
1492 assert!(backend.add_emitter(stub_emitter()).is_err());
1493 assert!(backend.remove_emitter(0).is_err());
1494 assert!(
1495 backend
1496 .update_world_shader_pipelines(None, None, None, None)
1497 .is_err()
1498 );
1499 }
1500
1501 // A minimal empty-world BackendInit borrowing `window`, for exercising the
1502 // default `reload_world`. Empty slices are `'static`; the only real borrow
1503 // is the window args.
1504 fn empty_backend_init(window: &crate::components::Window) -> BackendInit<'_> {
1505 use crate::backend_init::{
1506 MediaPayloads, PostSettings, SceneData, ShaderBytes, ShadowParams, WorldFx,
1507 };
1508 BackendInit {
1509 window,
1510 validation: false,
1511 frames_in_flight: 2,
1512 vsync: false,
1513 clear_color: [0.0; 4],
1514 hot_reload: false,
1515 capture: false,
1516 scene: SceneData {
1517 vertices: &[],
1518 indices: &[],
1519 draw_objects: Vec::new(),
1520 instanced_clusters: Vec::new(),
1521 n_skinned: 0,
1522 n_chunk_max: 0,
1523 },
1524 shaders: vec![ShaderBytes {
1525 vert: &[],
1526 frag: &[],
1527 shadow: &[],
1528 vert_instanced: &[],
1529 deferred: false,
1530 }],
1531 media: MediaPayloads {
1532 textures: &[],
1533 text_atlases: Vec::new(),
1534 env_map_bytes: None,
1535 color_lut_bytes: None,
1536 },
1537 light_uniforms: crate::render_types::LightUniforms::DEFAULT,
1538 local_lights: Vec::new(),
1539 spot_shadows: Vec::new(),
1540 area_lights: Vec::new(),
1541 shadows: ShadowParams {
1542 map_size: 0,
1543 update: crate::components::ShadowUpdate::default(),
1544 distance: 0,
1545 cascades: 1,
1546 },
1547 anisotropy: 1,
1548 planar_planes: 0,
1549 post: PostSettings {
1550 post_process: PostProcessTunables::DEFAULT,
1551 taa_enabled: false,
1552 ssao: None,
1553 ssr: None,
1554 ssgi: None,
1555 rt_reflections: None,
1556 rt_dynamic: crate::rt_geom::RtDynamicMode::Auto,
1557 rt_skinned_geometry: true,
1558 reflection_blur_scale: 1,
1559 auto_exposure: None,
1560 auto_exposure_bias_ev: 0.0,
1561 hdr_display: false,
1562 hdr_pq: false,
1563 temporal_upscaling: false,
1564 upscale_scale: 1.0,
1565 upscale_backend: crate::components::UpscalerBackend::Auto,
1566 occlusion_two_pass: false,
1567 },
1568 fx: WorldFx {
1569 decals: Vec::new(),
1570 particles: Vec::new(),
1571 fog: None,
1572 water_surfaces: Vec::new(),
1573 glass_panels: Vec::new(),
1574 sdf_volumes: Vec::new(),
1575 },
1576 requirements: Default::default(),
1577 }
1578 }
1579
1580 #[test]
1581 fn default_reload_world_is_unsupported() {
1582 // A backend without a real reload path reports the swap unsupported, so
1583 // the caller falls back to a full rebuild.
1584 let mut backend = StubBackend;
1585 let window = crate::components::Window::default();
1586 assert!(backend.reload_world(empty_backend_init(&window)).is_err());
1587 }
1588
1589 fn stub_decal() -> crate::decal::DecalRecord {
1590 crate::decal::DecalRecord {
1591 model: IDENTITY,
1592 inv_model: IDENTITY,
1593 texture_slot: 0,
1594 tint: [1.0; 4],
1595 }
1596 }
1597
1598 fn stub_emitter() -> crate::particles::ParticleEmitterRecord {
1599 crate::particles::ParticleEmitterRecord {
1600 texture_slot: 0,
1601 position: [0.0; 3],
1602 direction: [0.0, 1.0, 0.0],
1603 spread_cos: 1.0,
1604 speed_min: 0.0,
1605 speed_max: 1.0,
1606 lifetime_min: 0.0,
1607 lifetime_max: 1.0,
1608 gravity: [0.0, -9.8, 0.0],
1609 spawn_rate: 1.0,
1610 max_particles: 1,
1611 size_start: 1.0,
1612 size_end: 1.0,
1613 color_start: [1.0; 4],
1614 color_end: [1.0; 4],
1615 }
1616 }
1617}