Render continuously and optimize the glTF viewer

Remove vsync and dirty-flag frame gating, optimize PBR sampling and lighting, add a direct-to-canvas loadout, and preserve camera state on viewer resize.

Add regression coverage and update rendering documentation.

Amp-Thread-ID: https://ampcode.com/threads/T-01a0b382-1e79-712b-abd1-d29201c9bb5e
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-09-18 08:58:27 +00:00
co-authored by heaust
parent bc957cf40d
commit 4784ad7e87
16 changed files with 573 additions and 178 deletions
-7
View File
@@ -161,7 +161,6 @@ impl Core {
if names.iter().any(|name| self.store.borrow().uses_rows(name)) {
self.refresh()?;
}
self.data.borrow_mut().mark_dirty();
Ok(())
}
@@ -219,7 +218,6 @@ impl Core {
.borrow_mut()
.delete_object(name, id)
.map_err(JsError::new)?;
self.data.borrow_mut().mark_dirty();
Ok(())
}
@@ -233,7 +231,6 @@ impl Core {
.borrow()
.sync_rows(name, first, count, gpu, &self.data.borrow())
.map_err(|error| JsError::new(&error))?;
self.data.borrow_mut().mark_dirty();
Ok(())
}
@@ -269,7 +266,6 @@ impl Core {
.borrow_mut()
.upload_texture(name, mip_level, image, gpu)
.map_err(|error| JsError::new(&error))?;
self.data.borrow_mut().mark_dirty();
Ok(())
}
@@ -296,9 +292,6 @@ impl Core {
.as_ref()
.is_some_and(|gpu| gpu.timestamp_queries);
self.render.set_profiling(enabled && supported);
if enabled && supported {
self.data.borrow_mut().mark_dirty();
}
supported
}
+3 -16
View File
@@ -108,7 +108,6 @@ impl RenderData {
pending: HashMap::new(),
};
data.create_rows("signals".into(), 1, 32, "f32".into())?;
data.write_signal(5, 1.0);
Ok(data)
}
@@ -321,25 +320,13 @@ impl RenderData {
}
}
pub fn render_pending(&self) -> bool {
self.signal(4) == 0.0 && self.signal(5) != 0.0 && self.signal(6) == 0.0
}
pub fn begin_render(&mut self) -> bool {
if !self.render_pending() {
return false;
}
self.write_signal(5, 0.0);
true
}
pub fn mark_dirty(&mut self) {
self.write_signal(5, 1.0);
pub fn render_allowed(&self) -> bool {
// Lane 5 (legacy dirty flag) no longer gates continuous rendering.
self.signal(4) == 0.0 && self.signal(6) == 0.0
}
pub fn loadout_ready(&mut self) {
self.write_signal(6, 0.0);
self.mark_dirty();
}
fn signal(&self, index: usize) -> f32 {
+17 -15
View File
@@ -1,34 +1,36 @@
use super::RenderData;
#[test]
fn dirty_signal_is_consumed_once() {
fn rendering_does_not_require_a_dirty_signal() {
let mut data = RenderData::new(64).unwrap();
assert!(data.rows("signals").is_some());
assert!(data.begin_render());
assert!(!data.begin_render());
data.mark_dirty();
assert!(data.begin_render());
assert!(!data.render_pending());
for dirty in [0.0, 1.0, 0.0] {
data.write_signal(5, dirty);
assert!(data.render_allowed());
data.update_signals(0.001, 2, 0.002, 0);
assert!(data.render_allowed());
}
}
#[test]
fn skip_and_bundle_signals_hold_dirty_work() {
fn skip_and_bundle_signals_still_block_rendering() {
let mut data = RenderData::new(64).unwrap();
assert!(data.begin_render());
data.write_signal(5, 0.0);
assert!(data.render_allowed());
data.write_signal(4, 1.0);
data.mark_dirty();
assert!(!data.begin_render());
assert!(!data.render_allowed());
data.write_signal(4, 0.0);
assert!(data.begin_render());
assert!(data.render_allowed());
data.write_signal(6, 1.0);
data.mark_dirty();
assert!(!data.begin_render());
assert!(!data.render_allowed());
data.write_signal(4, 1.0);
data.loadout_ready();
assert!(data.begin_render());
assert!(!data.render_allowed());
data.write_signal(4, 0.0);
assert!(data.render_allowed());
}
fn rows_of(data: &RenderData, name: &str) -> u32 {
+41 -30
View File
@@ -1,5 +1,7 @@
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use wasm_bindgen::prelude::wasm_bindgen;
@@ -14,22 +16,22 @@ const channel = new MessageChannel();
const ready = [];
channel.port1.onmessage = () => ready.shift()?.();
export function nextFrame() {
return new Promise(resolve => requestAnimationFrame(resolve));
}
export function yieldTask() {
return new Promise(resolve => {
ready.push(resolve);
channel.port2.postMessage(0);
});
}
export function idleTask() {
return new Promise(resolve => setTimeout(resolve, 16));
}
"#)]
extern "C" {
#[wasm_bindgen(js_name = nextFrame)]
async fn next_frame();
#[wasm_bindgen(js_name = yieldTask)]
async fn yield_task();
#[wasm_bindgen(js_name = idleTask)]
async fn idle_task();
}
pub struct RenderLoop {
@@ -102,13 +104,15 @@ impl RenderLoop {
let control = self.clone();
wasm_bindgen_futures::spawn_local(async move {
loop {
next_frame().await;
// Let control messages run without waiting for display vsync.
yield_task().await;
if !control.playing.get() {
idle_task().await;
continue;
}
let started = js_sys::Date::now();
let fps = control.fps.get();
if fps != 0 && started - control.last.get() < 900.0 / f64::from(fps) {
if fps != 0 && started - control.last.get() < 1000.0 / f64::from(fps) {
continue;
}
let delta = (started - control.last.replace(started)) / 1000.0;
@@ -120,37 +124,40 @@ impl RenderLoop {
let mut data = data.borrow_mut();
data.update_signals(delta as f32, frame, elapsed as f32, control.fps.get());
}
let submission = if data.borrow().render_pending() {
let submission = if data.borrow().render_allowed() {
if let (Some(gpu), Some(loadout)) =
(gpu.borrow_mut().as_mut(), store.borrow_mut().active_mut())
{
if data.borrow_mut().begin_render() {
let profile = control.profiling.get()
&& !control.profile_pending.get()
&& started >= control.profile_after.get();
let result = {
let data = data.borrow();
gpu.render(loadout, &data, profile)
};
match result {
Ok(profile) => profile.map(|profile| {
(profile, gpu.adapter.clone(), gpu.width, gpu.height)
}),
Err(_) => {
data.borrow_mut().mark_dirty();
None
}
}
} else {
None
}
let profile = control.profiling.get()
&& !control.profile_pending.get()
&& started >= control.profile_after.get();
gpu.render(loadout, &data.borrow(), profile)
.ok()
.map(|profile| {
let completed = Arc::new(AtomicBool::new(false));
let callback = completed.clone();
gpu.queue.on_submitted_work_done(move || {
callback.store(true, Ordering::Release);
});
(
profile,
gpu.adapter.clone(),
gpu.width,
gpu.height,
completed,
)
})
} else {
None
}
} else {
None
};
if let Some((profile, adapter, width, height)) = submission {
let Some((profile, adapter, width, height, completed)) = submission else {
idle_task().await;
continue;
};
if let Some(profile) = profile {
control.profile_pending.set(true);
control.profile_after.set(started + 250.0);
let control = control.clone();
@@ -190,6 +197,10 @@ impl RenderLoop {
control.profile_pending.set(false);
});
}
// Bound GPU work in flight instead of queuing frames faster than it can draw.
while !completed.load(Ordering::Acquire) {
yield_task().await;
}
}
});
}