display: make DisplayControls cloneable in a multi-thread context
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
use embassy_time::{Duration, Timer};
|
use embassy_time::{Duration, Timer};
|
||||||
use figments::surface::Surface;
|
use figments::surface::Surface;
|
||||||
|
use figments_render::output::Brightness;
|
||||||
use core::{fmt::Debug, ops::{Deref, DerefMut}};
|
use core::{fmt::Debug, ops::{Deref, DerefMut}};
|
||||||
use log::*;
|
use log::*;
|
||||||
|
|
||||||
use crate::events::DisplayControls;
|
use crate::display::DisplayControls;
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, Copy)]
|
#[derive(Default, Debug, Clone, Copy)]
|
||||||
pub struct Animation {
|
pub struct Animation {
|
||||||
@@ -28,7 +29,7 @@ impl<S: Surface> AnimationActor for S {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct AnimDisplay<'a>(pub &'a DisplayControls);
|
pub struct AnimDisplay<'a>(pub &'a mut DisplayControls);
|
||||||
|
|
||||||
impl<'a> AnimationActor for AnimDisplay<'a> {
|
impl<'a> AnimationActor for AnimDisplay<'a> {
|
||||||
fn get_opacity(&self) -> u8 {
|
fn get_opacity(&self) -> u8 {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ async fn main(spawner: Spawner) {
|
|||||||
hi_spawn.must_spawn(renderbug_embassy::tasks::render::render(peripherals.RMT, peripherals.GPIO5.degrade(), surfaces, garage.display.clone(), wdt));
|
hi_spawn.must_spawn(renderbug_embassy::tasks::render::render(peripherals.RMT, peripherals.GPIO5.degrade(), surfaces, garage.display.clone(), wdt));
|
||||||
}
|
}
|
||||||
#[cfg(feature="headless")]
|
#[cfg(feature="headless")]
|
||||||
garage.display.render_is_running.signal(true);
|
garage.display.notify_render_is_running(true);
|
||||||
|
|
||||||
#[cfg(feature="motion")]
|
#[cfg(feature="motion")]
|
||||||
{
|
{
|
||||||
|
|||||||
116
src/display.rs
116
src/display.rs
@@ -1,5 +1,6 @@
|
|||||||
|
use embassy_sync::{blocking_mutex::{raw::CriticalSectionRawMutex, Mutex}, signal::Signal, watch::{Receiver, Watch}};
|
||||||
use figments::prelude::*;
|
use figments::prelude::*;
|
||||||
use core::fmt::Debug;
|
use core::{fmt::Debug, sync::atomic::{AtomicBool, AtomicU8}};
|
||||||
use alloc::sync::Arc;
|
use alloc::sync::Arc;
|
||||||
|
|
||||||
//use super::{Output};
|
//use super::{Output};
|
||||||
@@ -7,14 +8,11 @@ use figments_render::{
|
|||||||
gamma::{GammaCurve, WithGamma}, output::{Brightness, GammaCorrected, OutputAsync}, power::AsMilliwatts, smart_leds::PowerManagedWriterAsync
|
gamma::{GammaCurve, WithGamma}, output::{Brightness, GammaCorrected, OutputAsync}, power::AsMilliwatts, smart_leds::PowerManagedWriterAsync
|
||||||
};
|
};
|
||||||
use smart_leds::SmartLedsWriteAsync;
|
use smart_leds::SmartLedsWriteAsync;
|
||||||
|
|
||||||
use crate::events::DisplayControls;
|
|
||||||
|
|
||||||
// FIXME: We need a way to specify a different buffer format from the 'native' hardware output, due to sometimes testing with GRB strips instead of RGB
|
// FIXME: We need a way to specify a different buffer format from the 'native' hardware output, due to sometimes testing with GRB strips instead of RGB
|
||||||
pub struct BikeOutput<T: SmartLedsWriteAsync> {
|
pub struct BikeOutput<T: SmartLedsWriteAsync> {
|
||||||
pixbuf: [T::Color; 178],
|
pixbuf: [T::Color; 178],
|
||||||
writer: PowerManagedWriterAsync<T>,
|
writer: PowerManagedWriterAsync<T>,
|
||||||
controls: Arc<DisplayControls>
|
controls: DisplayControls
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T:SmartLedsWriteAsync> GammaCorrected for BikeOutput<T> where T::Color: PixelBlend<Rgb<u8>> + PixelFormat + Debug + WithGamma, T::Error: Debug {
|
impl<T:SmartLedsWriteAsync> GammaCorrected for BikeOutput<T> where T::Color: PixelBlend<Rgb<u8>> + PixelFormat + Debug + WithGamma, T::Error: Debug {
|
||||||
@@ -24,7 +22,7 @@ impl<T:SmartLedsWriteAsync> GammaCorrected for BikeOutput<T> where T::Color: Pix
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<T: SmartLedsWriteAsync> BikeOutput<T> where T::Color: PixelBlend<Rgb<u8>> + PixelFormat + WithGamma + 'static, T::Error: core::fmt::Debug {
|
impl<T: SmartLedsWriteAsync> BikeOutput<T> where T::Color: PixelBlend<Rgb<u8>> + PixelFormat + WithGamma + 'static, T::Error: core::fmt::Debug {
|
||||||
pub fn new(target: T, max_mw: u32, controls: Arc<DisplayControls>) -> Self {
|
pub fn new(target: T, max_mw: u32, controls: DisplayControls) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pixbuf: [Default::default(); 178],
|
pixbuf: [Default::default(); 178],
|
||||||
writer: PowerManagedWriterAsync::new(target, max_mw),
|
writer: PowerManagedWriterAsync::new(target, max_mw),
|
||||||
@@ -39,15 +37,19 @@ impl<T: SmartLedsWriteAsync> BikeOutput<T> where T::Color: PixelBlend<Rgb<u8>> +
|
|||||||
|
|
||||||
impl<'a, T: SmartLedsWriteAsync + 'a> OutputAsync<'a, SegmentSpace> for BikeOutput<T> where T::Color: PixelBlend<Rgb<u8>> + Debug + 'static + AsMilliwatts + PixelFormat + WithGamma, [T::Color; 178]: AsMilliwatts + WithGamma + Copy, T::Error: core::fmt::Debug {
|
impl<'a, T: SmartLedsWriteAsync + 'a> OutputAsync<'a, SegmentSpace> for BikeOutput<T> where T::Color: PixelBlend<Rgb<u8>> + Debug + 'static + AsMilliwatts + PixelFormat + WithGamma, [T::Color; 178]: AsMilliwatts + WithGamma + Copy, T::Error: core::fmt::Debug {
|
||||||
async fn commit_async(&mut self) -> Result<(), T::Error> {
|
async fn commit_async(&mut self) -> Result<(), T::Error> {
|
||||||
let c = self.controls.as_ref();
|
self.writer.controls().set_brightness(self.controls.brightness());
|
||||||
self.writer.controls().set_brightness(c.brightness());
|
self.writer.controls().set_on(self.controls.is_on());
|
||||||
self.writer.controls().set_on(c.is_on());
|
|
||||||
// TODO: We should grab the power used here and somehow feed it back into the telemetry layer, probably just via another atomic u32
|
// TODO: We should grab the power used here and somehow feed it back into the telemetry layer, probably just via another atomic u32
|
||||||
self.writer.write(&self.pixbuf).await
|
self.writer.write(&self.pixbuf).await
|
||||||
}
|
}
|
||||||
|
|
||||||
type HardwarePixel = T::Color;
|
type HardwarePixel = T::Color;
|
||||||
type Error = T::Error;
|
type Error = T::Error;
|
||||||
|
type Controls = DisplayControls;
|
||||||
|
|
||||||
|
fn controls(&self) -> Option<&Self::Controls> {
|
||||||
|
Some(&self.controls)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a, T: SmartLedsWriteAsync> Sample<'a, SegmentSpace> for BikeOutput<T> where T::Color: Debug + PixelFormat + 'a, [T::Color; 178]: Sample<'a, SegmentSpace, Output = T::Color> {
|
impl<'a, T: SmartLedsWriteAsync> Sample<'a, SegmentSpace> for BikeOutput<T> where T::Color: Debug + PixelFormat + 'a, [T::Color; 178]: Sample<'a, SegmentSpace, Output = T::Color> {
|
||||||
@@ -139,3 +141,99 @@ pub struct Uniforms {
|
|||||||
pub frame: usize,
|
pub frame: usize,
|
||||||
pub primary_color: Hsv
|
pub primary_color: Hsv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ControlData {
|
||||||
|
on: AtomicBool,
|
||||||
|
brightness: AtomicU8
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ControlData {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
on: AtomicBool::new(true),
|
||||||
|
brightness: AtomicU8::new(255)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A watch that indicates whether or not the rendering engine is running. If the display is off or sleeping, this will be false.
|
||||||
|
static RENDER_IS_RUNNING: Watch<CriticalSectionRawMutex, bool, 5> = Watch::new();
|
||||||
|
|
||||||
|
// TODO: Implement something similar for a system-wide sleep mechanism
|
||||||
|
pub struct DisplayControls {
|
||||||
|
data: Arc<ControlData>,
|
||||||
|
render_pause: Arc<Signal<CriticalSectionRawMutex, bool>>,
|
||||||
|
render_run_receiver: Receiver<'static, CriticalSectionRawMutex, bool, 5>
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for DisplayControls {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
data: Arc::clone(&self.data),
|
||||||
|
render_pause: Arc::clone(&self.render_pause),
|
||||||
|
render_run_receiver: RENDER_IS_RUNNING.receiver().expect("Could not create enough render running receivers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DisplayControls {
|
||||||
|
pub fn is_on(&self) -> bool {
|
||||||
|
self.data.on.load(core::sync::atomic::Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn brightness(&self) -> u8 {
|
||||||
|
self.data.brightness.load(core::sync::atomic::Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: its a bit weird we have a pub function for the renderer's privates to wait while hiding render_pause, but directly expose render_is_running for any task to wait on
|
||||||
|
pub async fn wait_until_display_is_on(&self) {
|
||||||
|
if let Some(true) = self.render_pause.try_take() {
|
||||||
|
while self.render_pause.wait().await {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn notify_render_is_running(&mut self, value: bool) {
|
||||||
|
RENDER_IS_RUNNING.sender().send(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn wait_until_render_is_running(&mut self) {
|
||||||
|
while !self.render_run_receiver.get().await {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GammaCorrected for DisplayControls {
|
||||||
|
fn set_gamma(&mut self, gamma: GammaCurve) {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Brightness for DisplayControls {
|
||||||
|
fn set_brightness(&mut self, brightness: u8) {
|
||||||
|
self.data.brightness.store(brightness, core::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_on(&mut self, is_on: bool) {
|
||||||
|
self.data.on.store(is_on, core::sync::atomic::Ordering::Relaxed);
|
||||||
|
self.render_pause.signal(!is_on);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Debug for DisplayControls {
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> {
|
||||||
|
f.debug_struct("DisplayControls")
|
||||||
|
.field("on", &self.data.on)
|
||||||
|
.field("brightness", &self.data.brightness)
|
||||||
|
.field("render_pause", &self.render_pause.signaled())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for DisplayControls {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
data: Default::default(),
|
||||||
|
render_pause: Default::default(),
|
||||||
|
render_run_receiver: RENDER_IS_RUNNING.receiver().unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,8 @@
|
|||||||
use embassy_sync::{blocking_mutex::raw::{CriticalSectionRawMutex, NoopRawMutex}, channel::Channel, pubsub::PubSubChannel};
|
use embassy_sync::{blocking_mutex::raw::{CriticalSectionRawMutex, NoopRawMutex}, channel::Channel, pubsub::PubSubChannel};
|
||||||
use embassy_time::Duration;
|
use embassy_time::Duration;
|
||||||
use nalgebra::{Vector2, Vector3};
|
use nalgebra::{Vector2, Vector3};
|
||||||
use alloc::sync::Arc;
|
|
||||||
|
|
||||||
use crate::ego::engine::MotionState;
|
use crate::{display::DisplayControls, ego::engine::MotionState};
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default, Debug)]
|
#[derive(Clone, Copy, Default, Debug)]
|
||||||
pub enum Scene {
|
pub enum Scene {
|
||||||
@@ -60,7 +59,13 @@ pub enum Notification {
|
|||||||
SetBrakelight(bool),
|
SetBrakelight(bool),
|
||||||
|
|
||||||
// TODO: BPM detection via bluetooth
|
// TODO: BPM detection via bluetooth
|
||||||
Beat
|
Beat,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub enum Telemetry {
|
||||||
|
Notification(Notification),
|
||||||
|
Prediction(Prediction),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
@@ -69,71 +74,13 @@ pub enum SensorSource {
|
|||||||
GPS
|
GPS
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Make this clone() able, so multiple threads can point to the same underlying atomics for the hardware controls
|
|
||||||
// FIXME: We only ever hold this behind an Arc and therefore end up storing a Signal inside of an Arc<>... which defeats the whole purpose and can introduce a deadlock
|
|
||||||
pub struct DisplayControls {
|
|
||||||
on: AtomicBool,
|
|
||||||
brightness: AtomicU8,
|
|
||||||
// FIXME: This should get turned into a embassy_sync::Watch sender, so multiple tasks can wait on the renderer to be running.
|
|
||||||
pub render_is_running: Signal<CriticalSectionRawMutex, bool>,
|
|
||||||
render_pause: Signal<CriticalSectionRawMutex, bool>
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DisplayControls {
|
|
||||||
pub fn is_on(&self) -> bool {
|
|
||||||
self.on.load(core::sync::atomic::Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn brightness(&self) -> u8 {
|
|
||||||
self.brightness.load(core::sync::atomic::Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_brightness(&self, brightness: u8) {
|
|
||||||
self.brightness.store(brightness, core::sync::atomic::Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIXME: its a bit weird we have a pub function for the renderer's privates to wait while hiding render_pause, but directly expose render_is_running for any task to wait on
|
|
||||||
pub async fn wait_for_on(&self) {
|
|
||||||
self.render_pause.wait().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_on(&self, is_on: bool) {
|
|
||||||
self.on.store(is_on, core::sync::atomic::Ordering::Relaxed);
|
|
||||||
if is_on {
|
|
||||||
self.render_pause.signal(true);
|
|
||||||
} else {
|
|
||||||
self.render_pause.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl core::fmt::Debug for DisplayControls {
|
|
||||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> Result<(), core::fmt::Error> {
|
|
||||||
f.debug_struct("DisplayControls")
|
|
||||||
.field("on", &self.on)
|
|
||||||
.field("brightness", &self.brightness)
|
|
||||||
.field("render_is_running", &self.render_is_running.signaled())
|
|
||||||
.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DisplayControls {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
on: AtomicBool::new(true),
|
|
||||||
brightness: AtomicU8::new(255),
|
|
||||||
render_is_running: Signal::new(),
|
|
||||||
render_pause: Signal::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct BusGarage {
|
pub struct BusGarage {
|
||||||
pub motion: Channel<NoopRawMutex, Measurement, 5>,
|
pub motion: Channel<NoopRawMutex, Measurement, 5>,
|
||||||
pub notify: PubSubChannel<CriticalSectionRawMutex, Notification, 5, 2, 4>,
|
pub notify: PubSubChannel<CriticalSectionRawMutex, Notification, 5, 2, 4>,
|
||||||
pub predict: Channel<CriticalSectionRawMutex, Prediction, 15>,
|
pub predict: Channel<CriticalSectionRawMutex, Prediction, 15>,
|
||||||
pub display: Arc<DisplayControls>
|
pub telemetry: PubSubChannel<CriticalSectionRawMutex, Telemetry, 15, 2, 4>,
|
||||||
|
pub display: DisplayControls
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for BusGarage {
|
impl Default for BusGarage {
|
||||||
@@ -142,6 +89,7 @@ impl Default for BusGarage {
|
|||||||
motion: Channel::new(),
|
motion: Channel::new(),
|
||||||
notify: PubSubChannel::new(),
|
notify: PubSubChannel::new(),
|
||||||
predict: Channel::new(),
|
predict: Channel::new(),
|
||||||
|
telemetry: PubSubChannel::new(),
|
||||||
display: Default::default()
|
display: Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ use figments_render::output::{GammaCorrected, OutputAsync};
|
|||||||
use log::{info, warn};
|
use log::{info, warn};
|
||||||
use rgb::Rgba;
|
use rgb::Rgba;
|
||||||
use nalgebra::ComplexField;
|
use nalgebra::ComplexField;
|
||||||
use alloc::sync::Arc;
|
|
||||||
|
|
||||||
use crate::{display::{BikeOutput, SegmentSpace, Uniforms}, events::DisplayControls};
|
use crate::display::{BikeOutput, DisplayControls, SegmentSpace, Uniforms};
|
||||||
|
|
||||||
//TODO: Import the bike surfaces from renderbug-prime, somehow make those surfaces into tasks
|
//TODO: Import the bike surfaces from renderbug-prime, somehow make those surfaces into tasks
|
||||||
|
|
||||||
#[embassy_executor::task]
|
#[embassy_executor::task]
|
||||||
pub async fn render(rmt: esp_hal::peripherals::RMT<'static>, gpio: AnyPin<'static>, surfaces: BufferedSurfacePool<Uniforms, SegmentSpace, Rgba<u8>>, controls: Arc<DisplayControls>, mut wdt: Wdt<esp_hal::peripherals::TIMG0<'static>>) {
|
pub async fn render(rmt: esp_hal::peripherals::RMT<'static>, gpio: AnyPin<'static>, surfaces: BufferedSurfacePool<Uniforms, SegmentSpace, Rgba<u8>>, safety_surfaces: BufferedSurfacePool<Uniforms, SegmentSpace, Rgba<u8>>, mut controls: DisplayControls, mut wdt: Wdt<esp_hal::peripherals::TIMG0<'static>>) {
|
||||||
let frequency: Rate = Rate::from_mhz(80);
|
let frequency: Rate = Rate::from_mhz(80);
|
||||||
let rmt = Rmt::new(rmt, frequency)
|
let rmt = Rmt::new(rmt, frequency)
|
||||||
.expect("Failed to initialize RMT").into_async();
|
.expect("Failed to initialize RMT").into_async();
|
||||||
@@ -45,7 +44,7 @@ pub async fn render(rmt: esp_hal::peripherals::RMT<'static>, gpio: AnyPin<'stati
|
|||||||
//output.set_gamma(GammaCurve::new(2.1));
|
//output.set_gamma(GammaCurve::new(2.1));
|
||||||
|
|
||||||
info!("Rendering started! {}ms since boot", Instant::now().as_millis());
|
info!("Rendering started! {}ms since boot", Instant::now().as_millis());
|
||||||
controls.render_is_running.signal(true);
|
controls.notify_render_is_running(true);
|
||||||
|
|
||||||
const FPS: u64 = 80;
|
const FPS: u64 = 80;
|
||||||
const RENDER_BUDGET: Duration = Duration::from_millis(1000 / FPS);
|
const RENDER_BUDGET: Duration = Duration::from_millis(1000 / FPS);
|
||||||
@@ -57,6 +56,8 @@ pub async fn render(rmt: esp_hal::peripherals::RMT<'static>, gpio: AnyPin<'stati
|
|||||||
output.blank();
|
output.blank();
|
||||||
|
|
||||||
surfaces.render_to(&mut output, &uniforms);
|
surfaces.render_to(&mut output, &uniforms);
|
||||||
|
// TODO: We should split up the safety layers so they always have full power
|
||||||
|
safety_surfaces.render_to(&mut output, &uniforms);
|
||||||
|
|
||||||
// Finally, write out the rendered frame
|
// Finally, write out the rendered frame
|
||||||
output.commit_async().await.expect("Failed to commit frame");
|
output.commit_async().await.expect("Failed to commit frame");
|
||||||
@@ -65,14 +66,14 @@ pub async fn render(rmt: esp_hal::peripherals::RMT<'static>, gpio: AnyPin<'stati
|
|||||||
|
|
||||||
if !controls.is_on() {
|
if !controls.is_on() {
|
||||||
warn!("Renderer is sleeping zzzz");
|
warn!("Renderer is sleeping zzzz");
|
||||||
//controls.render_is_running.signal(false);
|
controls.notify_render_is_running(false);
|
||||||
output.blank();
|
output.blank();
|
||||||
wdt.disable();
|
wdt.disable();
|
||||||
controls.wait_for_on().await;
|
controls.wait_until_display_is_on().await;
|
||||||
wdt.feed();
|
wdt.feed();
|
||||||
wdt.enable();
|
wdt.enable();
|
||||||
warn!("Renderer is awake !!!!");
|
warn!("Renderer is awake !!!!");
|
||||||
//controls.render_is_running.signal(true);
|
controls.notify_render_is_running(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if render_duration < RENDER_BUDGET {
|
if render_duration < RENDER_BUDGET {
|
||||||
|
|||||||
Reference in New Issue
Block a user