BlueGit

golden-age paperdoll-renderer

Parallel production IO + PBO readback; preview latest-wins (Tim t1063u/t1064u)

Encode/write runs on a bounded IO queue so the worker starts the next GPU
view without waiting on disk; atlases prefer in-memory PNG bytes. GPU
readback uses a 3-deep PBO+fence ring. Live preview is a latest-wins slot
so render never waits on texture upload or UI repaint.

timfa Grok Bot · pushed by timfa

Authored 2026-11-10 · 4 days ago

Hash
2297f64d31aa498f1fc990da041461d20ee352b2
Parent
be63931f
8 files changed +1310 −483
README.md +1 −1
@@ -28,7 +28,7 @@ The first project view shows the bodies. **Choose male / Choose female** selects
28 28 - Drag to orbit. **Show fixed render frame** shows the yellow orthographic export frame; set **Fixed render height**, **Center Y** (image center) and **Pivot Y** (orbit point) per male/female and save. Without a manual pivot the camera orbits around the vertical centre of the model's rest-pose bounds (or **Center Y** as a fallback with a diagnostic when there is no geometry); **Auto centre** clears a manual override. The fixed frame and production are orthographic, so there is no perspective distortion at any pitch. **Fit animations** computes once a render height and Center Y that contain all clips, all frames (at project fps) and all 60 angles, with a 5% margin on each side; the pivot stays. Afterwards the frame is fixed again: there is no automatic zoom per frame, clip or layer. Equipment that sticks out beyond the body needs extra height set by hand. Body and equipment share exactly the same camera. Unchecking gives a free perspective preview with scroll zoom, seen from 2.5× further away with a narrower field of view (≈15.4°) for weak perspective; that zoom does not change the export. A real 24-bit depth buffer keeps front surfaces in front.
29 29 - Pick an animation clip and use **Pause / Play**.
30 30 - **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.
31 - **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). The preview is replaced immediately by every finished pass: highres → detail → target/diffuse → depth → normal → specular. All passes keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
31 - **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). Production keeps several GPU views in flight (PBO readback ring) and encodes/writes PNGs on a bounded background IO queue so the next view can start without waiting on disk; atlases are built from in-memory PNG bytes when available instead of re-decoding from disk. The live preview of the latest finished pass is a latest-wins slot: the render worker never waits on texture upload or UI repaint, and the UI may skip intermediate frames. Shown passes still follow highres → detail → target/diffuse → depth → normal → specular, keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
32 32 - Set width, height and fps in the Bodies view. New projects start at 92 × 128 pixels and 8 fps with 8-bit gray depth; an existing `.ppd` keeps its saved values. A new project with the default bodies already has **Fit animations** applied for both sexes. **Trim atlas: body / equipment** (both on by default; older `.ppd` files load with both on) crops the atlas views to their content, see *Atlases and .ppda*. **Semi-transparency allowed (body)** is off by default (also for older `.ppd` files). Off means binary alpha: after the resolve, a pixel with coverage ≥ 0.5 becomes fully opaque in every map (diffuse, normal, specular, depth) and anything below becomes fully empty in every map, so all masks match. On keeps the soft edges. Each equipment preset has its own **Semi-transparency allowed** checkbox (also off by default). The bundled `female-hair` preset has it on. The setting is in `render.json` (`semi_transparency`), so a run only resumes with the same setting.
33 33 - **Look for interrupted runs** (also in the equipment editor) lists cancelled, failed or aborted runs of the same body/equipment set with the number of recorded PNGs. **Resume** only continues if source files and settings are exactly equal to that run; otherwise the difference is shown. After cancelling, the run appears in this list immediately. See *Resuming* below.
34 34
@@ -28,7 +28,7 @@ The first project view shows the bodies. **Choose male / Choose female** selects
28- Drag to orbit. **Show fixed render frame** shows the yellow orthographic export frame; set **Fixed render height**, **Center Y** (image center) and **Pivot Y** (orbit point) per male/female and save. Without a manual pivot the camera orbits around the vertical centre of the model's rest-pose bounds (or **Center Y** as a fallback with a diagnostic when there is no geometry); **Auto centre** clears a manual override. The fixed frame and production are orthographic, so there is no perspective distortion at any pitch. **Fit animations** computes once a render height and Center Y that contain all clips, all frames (at project fps) and all 60 angles, with a 5% margin on each side; the pivot stays. Afterwards the frame is fixed again: there is no automatic zoom per frame, clip or layer. Equipment that sticks out beyond the body needs extra height set by hand. Body and equipment share exactly the same camera. Unchecking gives a free perspective preview with scroll zoom, seen from 2.5× further away with a narrower field of view (≈15.4°) for weak perspective; that zoom does not change the export. A real 24-bit depth buffer keeps front surfaces in front.
28- Drag to orbit. **Show fixed render frame** shows the yellow orthographic export frame; set **Fixed render height**, **Center Y** (image center) and **Pivot Y** (orbit point) per male/female and save. Without a manual pivot the camera orbits around the vertical centre of the model's rest-pose bounds (or **Center Y** as a fallback with a diagnostic when there is no geometry); **Auto centre** clears a manual override. The fixed frame and production are orthographic, so there is no perspective distortion at any pitch. **Fit animations** computes once a render height and Center Y that contain all clips, all frames (at project fps) and all 60 angles, with a 5% margin on each side; the pivot stays. Afterwards the frame is fixed again: there is no automatic zoom per frame, clip or layer. Equipment that sticks out beyond the body needs extra height set by hand. Body and equipment share exactly the same camera. Unchecking gives a free perspective preview with scroll zoom, seen from 2.5× further away with a narrower field of view (≈15.4°) for weak perspective; that zoom does not change the export. A real 24-bit depth buffer keeps front surfaces in front.
29- Pick an animation clip and use **Pause / Play**.
29- Pick an animation clip and use **Pause / Play**.
30- **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.
30- **Yaw − / Yaw +** step through twelve 30° angles; **pitch -60 / -30 / 0 / 30 / 60** choose the height. Yaw 0 looks at the front of every model. The orientation is fixed by the glTF standard (+Y up, the front faces +Z, the left side is +X); models and animations load as-is, without automatic correction: yaw 0 = front (right hand on the left of the image), 90 = left side, 180 = back, 270 = right side; positive pitch looks from above. A test with labelled markers guards this convention.
31- **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). The preview is replaced immediately by every finished pass: highres → detail → target/diffuse → depth → normal → specular. All passes keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
31- **Render all angles** renders all animations × all frames at project fps × 60 angles (5 pitches × 12 yaws). After the loose maps it writes one atlas per map type plus a `.ppda` file (see *Atlases and .ppda*). Production keeps several GPU views in flight (PBO readback ring) and encodes/writes PNGs on a bounded background IO queue so the next view can start without waiting on disk; atlases are built from in-memory PNG bytes when available instead of re-decoding from disk. The live preview of the latest finished pass is a latest-wins slot: the render worker never waits on texture upload or UI repaint, and the UI may skip intermediate frames. Shown passes still follow highres → detail → target/diffuse → depth → normal → specular, keep their aspect with centered letterboxing and nearest-neighbor display. Progress counts real passes; **Cancel render** keeps valid partial output. **Live 3D** returns to the model; a next finished render pass may replace the preview again.
32- Set width, height and fps in the Bodies view. New projects start at 92 × 128 pixels and 8 fps with 8-bit gray depth; an existing `.ppd` keeps its saved values. A new project with the default bodies already has **Fit animations** applied for both sexes. **Trim atlas: body / equipment** (both on by default; older `.ppd` files load with both on) crops the atlas views to their content, see *Atlases and .ppda*. **Semi-transparency allowed (body)** is off by default (also for older `.ppd` files). Off means binary alpha: after the resolve, a pixel with coverage ≥ 0.5 becomes fully opaque in every map (diffuse, normal, specular, depth) and anything below becomes fully empty in every map, so all masks match. On keeps the soft edges. Each equipment preset has its own **Semi-transparency allowed** checkbox (also off by default). The bundled `female-hair` preset has it on. The setting is in `render.json` (`semi_transparency`), so a run only resumes with the same setting.
32- Set width, height and fps in the Bodies view. New projects start at 92 × 128 pixels and 8 fps with 8-bit gray depth; an existing `.ppd` keeps its saved values. A new project with the default bodies already has **Fit animations** applied for both sexes. **Trim atlas: body / equipment** (both on by default; older `.ppd` files load with both on) crops the atlas views to their content, see *Atlases and .ppda*. **Semi-transparency allowed (body)** is off by default (also for older `.ppd` files). Off means binary alpha: after the resolve, a pixel with coverage ≥ 0.5 becomes fully opaque in every map (diffuse, normal, specular, depth) and anything below becomes fully empty in every map, so all masks match. On keeps the soft edges. Each equipment preset has its own **Semi-transparency allowed** checkbox (also off by default). The bundled `female-hair` preset has it on. The setting is in `render.json` (`semi_transparency`), so a run only resumes with the same setting.
33- **Look for interrupted runs** (also in the equipment editor) lists cancelled, failed or aborted runs of the same body/equipment set with the number of recorded PNGs. **Resume** only continues if source files and settings are exactly equal to that run; otherwise the difference is shown. After cancelling, the run appears in this list immediately. See *Resuming* below.
33- **Look for interrupted runs** (also in the equipment editor) lists cancelled, failed or aborted runs of the same body/equipment set with the number of recorded PNGs. **Resume** only continues if source files and settings are exactly equal to that run; otherwise the difference is shown. After cancelling, the run appears in this list immediately. See *Resuming* below.
34
34
src/atlas.rs +19 −6
@@ -350,6 +350,8 @@ pub struct AtlasJob<'a> {
350 350 pub cancel: &'a AtomicBool,
351 351 /// Crop every view to its content (t1030u).
352 352 pub trim: bool,
353 /// Optional in-memory encoded PNGs (production IO pipeline); disk is the fallback.
354 pub cells: Option<&'a BTreeMap<String, std::sync::Arc<Vec<u8>>>>,
353 355 }
354 356
355 357 fn rows_of(plan: &Plan) -> Vec<(usize, u32)> {
@@ -376,12 +378,22 @@ fn load_cell(
376 378 .png_sha256
377 379 .get(&rel)
378 380 .ok_or_else(|| format!("atlas: {rel} ontbreekt in de checksums"))?;
379 let path = job.root.join(&rel);
380 export_set::verify_regular_file(&path, job.root).map_err(|e| e.to_string())?;
381 let bytes = export_set::read_bounded_file(&path, MAX_PNG_BYTES).map_err(|e| e.to_string())?;
382 if digest::sha256_hex(&bytes) != *expected {
383 return Err(format!("atlas: checksum van {rel} klopt niet meer"));
384 }
381 let bytes = if let Some(cells) = job.cells.and_then(|c| c.get(&rel)) {
382 let bytes = (**cells).clone();
383 if digest::sha256_hex(&bytes) != *expected {
384 return Err(format!("atlas: checksum van {rel} (geheugen) klopt niet"));
385 }
386 bytes
387 } else {
388 let path = job.root.join(&rel);
389 export_set::verify_regular_file(&path, job.root).map_err(|e| e.to_string())?;
390 let bytes =
391 export_set::read_bounded_file(&path, MAX_PNG_BYTES).map_err(|e| e.to_string())?;
392 if digest::sha256_hex(&bytes) != *expected {
393 return Err(format!("atlas: checksum van {rel} klopt niet meer"));
394 }
395 bytes
396 };
385 397 if kind == MapKind::Depth {
386 398 // Copied in its own encoding, byte-exact (lossless for every format).
387 399 let fmt = crate::camera::DepthFormat::from_doc(job.render_doc)?;
@@ -1906,6 +1918,7 @@ mod tests {
1906 1918 attempt: 1,
1907 1919 cancel: &cancel,
1908 1920 trim: false,
1921 cells: None,
1909 1922 };
1910 1923 // Re-running over identical files keeps them (no clobber, same result).
1911 1924 let before = fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap();
@@ -350,6 +350,8 @@ pub struct AtlasJob<'a> {
350 pub cancel: &'a AtomicBool,
350 pub cancel: &'a AtomicBool,
351 /// Crop every view to its content (t1030u).
351 /// Crop every view to its content (t1030u).
352 pub trim: bool,
352 pub trim: bool,
353 /// Optional in-memory encoded PNGs (production IO pipeline); disk is the fallback.
354 pub cells: Option<&'a BTreeMap<String, std::sync::Arc<Vec<u8>>>>,
353}
355}
354
356
355fn rows_of(plan: &Plan) -> Vec<(usize, u32)> {
357fn rows_of(plan: &Plan) -> Vec<(usize, u32)> {
@@ -376,12 +378,22 @@ fn load_cell(
376 .png_sha256
378 .png_sha256
377 .get(&rel)
379 .get(&rel)
378 .ok_or_else(|| format!("atlas: {rel} ontbreekt in de checksums"))?;
380 .ok_or_else(|| format!("atlas: {rel} ontbreekt in de checksums"))?;
379 let path = job.root.join(&rel);
381 let bytes = if let Some(cells) = job.cells.and_then(|c| c.get(&rel)) {
380 export_set::verify_regular_file(&path, job.root).map_err(|e| e.to_string())?;
382 let bytes = (**cells).clone();
381 let bytes = export_set::read_bounded_file(&path, MAX_PNG_BYTES).map_err(|e| e.to_string())?;
383 if digest::sha256_hex(&bytes) != *expected {
382 if digest::sha256_hex(&bytes) != *expected {
384 return Err(format!("atlas: checksum van {rel} (geheugen) klopt niet"));
383 return Err(format!("atlas: checksum van {rel} klopt niet meer"));
385 }
384 }
386 bytes
387 } else {
388 let path = job.root.join(&rel);
389 export_set::verify_regular_file(&path, job.root).map_err(|e| e.to_string())?;
390 let bytes =
391 export_set::read_bounded_file(&path, MAX_PNG_BYTES).map_err(|e| e.to_string())?;
392 if digest::sha256_hex(&bytes) != *expected {
393 return Err(format!("atlas: checksum van {rel} klopt niet meer"));
394 }
395 bytes
396 };
385 if kind == MapKind::Depth {
397 if kind == MapKind::Depth {
386 // Copied in its own encoding, byte-exact (lossless for every format).
398 // Copied in its own encoding, byte-exact (lossless for every format).
387 let fmt = crate::camera::DepthFormat::from_doc(job.render_doc)?;
399 let fmt = crate::camera::DepthFormat::from_doc(job.render_doc)?;
@@ -1906,6 +1918,7 @@ mod tests {
1906 attempt: 1,
1918 attempt: 1,
1907 cancel: &cancel,
1919 cancel: &cancel,
1908 trim: false,
1920 trim: false,
1921 cells: None,
1909 };
1922 };
1910 // Re-running over identical files keeps them (no clobber, same result).
1923 // Re-running over identical files keeps them (no clobber, same result).
1911 let before = fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap();
1924 let before = fs::read(tmp.0.join("female-body-atlas.ppda")).unwrap();
src/editor.rs +24 −7
@@ -1029,16 +1029,30 @@ impl EditorApp {
1029 1029 }
1030 1030
1031 1031 fn service_gpu(&mut self, gl: Option<&std::sync::Arc<glow::Context>>) {
1032 let request = self
1033 .render_worker
1034 .as_ref()
1035 .and_then(|worker| worker.gpu.try_recv().ok());
1036 if let Some(request) = request {
1037 if let Some(gl) = gl {
1032 // Fill the PBO pipeline: accept several GPU requests per UI frame, then
1033 // complete any readbacks whose fences have signaled (Tim t1063u).
1034 if let Some(gl) = gl {
1035 for _ in 0..crate::production::GPU_PIPELINE_DEPTH + 1 {
1036 let request = self
1037 .render_worker
1038 .as_ref()
1039 .and_then(|worker| worker.gpu.try_recv().ok());
1040 let Some(request) = request else {
1041 break;
1042 };
1038 1043 unsafe {
1039 1044 request.service(&mut self.gpu_renderer, gl);
1040 1045 }
1041 } else {
1046 }
1047 unsafe {
1048 let _ = self.gpu_renderer.poll_readbacks(gl, false);
1049 }
1050 } else {
1051 while let Some(request) = self
1052 .render_worker
1053 .as_ref()
1054 .and_then(|worker| worker.gpu.try_recv().ok())
1055 {
1042 1056 request.fail("Glow-context ontbreekt".into());
1043 1057 }
1044 1058 }
@@ -1047,6 +1061,8 @@ impl EditorApp {
1047 1061 let Some(worker) = &self.render_worker else {
1048 1062 return;
1049 1063 };
1064 // Pull latest-wins preview independently of progress events (Tim t1064u).
1065 worker.preview.apply_to(&mut self.render_ui);
1050 1066 let ended = worker
1051 1067 .handle
1052 1068 .as_ref()
@@ -3158,6 +3174,7 @@ mod tests {
3158 3174 let (_, gpu) = std::sync::mpsc::channel();
3159 3175 app.render_worker = Some(crate::production::RenderThread {
3160 3176 mailbox: Default::default(),
3177 preview: Default::default(),
3161 3178 gpu,
3162 3179 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
3163 3180 handle: None,
@@ -1029,16 +1029,30 @@ impl EditorApp {
1029 }
1029 }
1030
1030
1031 fn service_gpu(&mut self, gl: Option<&std::sync::Arc<glow::Context>>) {
1031 fn service_gpu(&mut self, gl: Option<&std::sync::Arc<glow::Context>>) {
1032 let request = self
1032 // Fill the PBO pipeline: accept several GPU requests per UI frame, then
1033 .render_worker
1033 // complete any readbacks whose fences have signaled (Tim t1063u).
1034 .as_ref()
1034 if let Some(gl) = gl {
1035 .and_then(|worker| worker.gpu.try_recv().ok());
1035 for _ in 0..crate::production::GPU_PIPELINE_DEPTH + 1 {
1036 if let Some(request) = request {
1036 let request = self
1037 if let Some(gl) = gl {
1037 .render_worker
1038 .as_ref()
1039 .and_then(|worker| worker.gpu.try_recv().ok());
1040 let Some(request) = request else {
1041 break;
1042 };
1038 unsafe {
1043 unsafe {
1039 request.service(&mut self.gpu_renderer, gl);
1044 request.service(&mut self.gpu_renderer, gl);
1040 }
1045 }
1041 } else {
1046 }
1047 unsafe {
1048 let _ = self.gpu_renderer.poll_readbacks(gl, false);
1049 }
1050 } else {
1051 while let Some(request) = self
1052 .render_worker
1053 .as_ref()
1054 .and_then(|worker| worker.gpu.try_recv().ok())
1055 {
1042 request.fail("Glow-context ontbreekt".into());
1056 request.fail("Glow-context ontbreekt".into());
1043 }
1057 }
1044 }
1058 }
@@ -1047,6 +1061,8 @@ impl EditorApp {
1047 let Some(worker) = &self.render_worker else {
1061 let Some(worker) = &self.render_worker else {
1048 return;
1062 return;
1049 };
1063 };
1064 // Pull latest-wins preview independently of progress events (Tim t1064u).
1065 worker.preview.apply_to(&mut self.render_ui);
1050 let ended = worker
1066 let ended = worker
1051 .handle
1067 .handle
1052 .as_ref()
1068 .as_ref()
@@ -3158,6 +3174,7 @@ mod tests {
3158 let (_, gpu) = std::sync::mpsc::channel();
3174 let (_, gpu) = std::sync::mpsc::channel();
3159 app.render_worker = Some(crate::production::RenderThread {
3175 app.render_worker = Some(crate::production::RenderThread {
3160 mailbox: Default::default(),
3176 mailbox: Default::default(),
3177 preview: Default::default(),
3161 gpu,
3178 gpu,
3162 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
3179 cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
3163 handle: None,
3180 handle: None,
src/export_fixture.rs +1 −0
@@ -228,6 +228,7 @@ impl SyntheticRun {
228 228 attempt: 0,
229 229 cancel: &cancel,
230 230 trim: self.trim,
231 cells: None,
231 232 },
232 233 &mut |_| {},
233 234 )?;
@@ -228,6 +228,7 @@ impl SyntheticRun {
228 attempt: 0,
228 attempt: 0,
229 cancel: &cancel,
229 cancel: &cancel,
230 trim: self.trim,
230 trim: self.trim,
231 cells: None,
231 },
232 },
232 &mut |_| {},
233 &mut |_| {},
233 )?;
234 )?;
src/i18n_table.rs +12 −0
@@ -863,4 +863,16 @@ pub const TABLE: &[(&str, &str)] = &[
863 863 ("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}"),
864 864 ("BLEND-laag niet over het ondoorzichtige oppervlak gemengd: {blend_over:?}", "BLEND layer not blended over the opaque surface: {blend_over:?}"),
865 865 ("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}, body-uv-top={body_uv:?}, equipment-uv-bottom={equipment_uv:?}, blend-over={blend_over:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}, body-uv-top={body_uv:?}, equipment-uv-bottom={equipment_uv:?}, blend-over={blend_over:?}"),
866 ("atlas: checksum van {rel} (geheugen) klopt niet", "atlas: checksum of {rel} (memory) does not match"),
867 ("IO-queue gestopt", "IO queue stopped"),
868 ("IO-queue ontbreekt", "IO queue missing"),
869 ("IO-queue al gestopt", "IO queue already stopped"),
870 ("IO-queue gestopt zonder antwoord", "IO queue stopped without a reply"),
871 ("GPU-renderer gestopt", "GPU renderer stopped"),
872 ("{e}; {err}", "{e}; {err}"),
873 ("PBO-readback wachtstatus 0x{status:x}", "PBO readback wait status 0x{status:x}"),
874 ("PBO map mislukt", "PBO map failed"),
875 ("PBO depth-map mislukt", "PBO depth map failed"),
876 ("ongeldige highres-afmetingen", "invalid highres dimensions"),
877 ("GPU-antwoord ontbreekt", "GPU reply missing"),
866 878 ];
@@ -863,4 +863,16 @@ pub const TABLE: &[(&str, &str)] = &[
863 ("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}"),
863 ("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}"),
864 ("BLEND-laag niet over het ondoorzichtige oppervlak gemengd: {blend_over:?}", "BLEND layer not blended over the opaque surface: {blend_over:?}"),
864 ("BLEND-laag niet over het ondoorzichtige oppervlak gemengd: {blend_over:?}", "BLEND layer not blended over the opaque surface: {blend_over:?}"),
865 ("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}, body-uv-top={body_uv:?}, equipment-uv-bottom={equipment_uv:?}, blend-over={blend_over:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}, body-uv-top={body_uv:?}, equipment-uv-bottom={equipment_uv:?}, blend-over={blend_over:?}"),
865 ("GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}, body-uv-top={body_uv:?}, equipment-uv-bottom={equipment_uv:?}, blend-over={blend_over:?}", "GPU depth PASS: native {native_bits}-bit, front={near:?}, next-frame={next:?}, reversed={reverse:?}, behind-layer={hidden:?}, textured-front={textured:?}, alpha-cutout={cutout:?}, shiny={shiny:?}, matte={matte:?}, body-surface texture×factor={surface_front:?}, body-surface factor-only={surface_swap:?}, body-uv-top={body_uv:?}, equipment-uv-bottom={equipment_uv:?}, blend-over={blend_over:?}"),
866 ("atlas: checksum van {rel} (geheugen) klopt niet", "atlas: checksum of {rel} (memory) does not match"),
867 ("IO-queue gestopt", "IO queue stopped"),
868 ("IO-queue ontbreekt", "IO queue missing"),
869 ("IO-queue al gestopt", "IO queue already stopped"),
870 ("IO-queue gestopt zonder antwoord", "IO queue stopped without a reply"),
871 ("GPU-renderer gestopt", "GPU renderer stopped"),
872 ("{e}; {err}", "{e}; {err}"),
873 ("PBO-readback wachtstatus 0x{status:x}", "PBO readback wait status 0x{status:x}"),
874 ("PBO map mislukt", "PBO map failed"),
875 ("PBO depth-map mislukt", "PBO depth map failed"),
876 ("ongeldige highres-afmetingen", "invalid highres dimensions"),
877 ("GPU-antwoord ontbreekt", "GPU reply missing"),
866];
878];
src/io_queue.rs added +253 −0
@@ -0,0 +1,253 @@
1 //! Ordered, bounded encode+write queue for production maps (Tim t1063u).
2 //!
3 //! The render worker resolves a view, then hands already-encoded PNG bytes to a
4 //! single IO thread that publishes them with [`crate::resume::RunSession`] in
5 //! deterministic journal order. A bounded channel provides backpressure so only
6 //! a few views sit in flight between GPU/CPU and disk.
7 use crate::resume::RunSession;
8 use std::{
9 collections::BTreeMap,
10 sync::{
11 atomic::{AtomicBool, Ordering},
12 mpsc, Arc,
13 },
14 thread::{self, JoinHandle},
15 };
16
17 /// Max view-bundles waiting on the IO thread (backpressure).
18 pub const IO_QUEUE_VIEWS: usize = 4;
19
20 /// One planned PNG: relative path + encoded file bytes.
21 #[derive(Debug)]
22 pub struct EncodedMap {
23 pub rel: String,
24 pub bytes: Vec<u8>,
25 }
26
27 /// All maps of one view, in [`crate::production::SAVED_ORDER`].
28 #[derive(Debug)]
29 pub struct ViewPublish {
30 pub maps: Vec<EncodedMap>,
31 }
32
33 enum Msg {
34 View(ViewPublish),
35 /// Stop after draining; reply with the session and digests.
36 Finish(mpsc::SyncSender<IoFinish>),
37 }
38
39 /// Result of shutting down the IO thread.
40 pub struct IoFinish {
41 pub session: RunSession,
42 pub png_sha256: BTreeMap<String, String>,
43 pub error: Option<String>,
44 }
45
46 /// Handle for the ordered publisher.
47 pub struct IoQueue {
48 tx: Option<mpsc::SyncSender<Msg>>,
49 /// Shared cancel; IO stops accepting new publishes when set (still finishes Finish).
50 cancel: Arc<AtomicBool>,
51 handle: Option<JoinHandle<()>>,
52 finished: bool,
53 }
54
55 impl IoQueue {
56 /// Move `session` onto a dedicated IO thread. `seed` are digests already
57 /// verified (resume skip); they are merged into the final map.
58 pub fn start(
59 session: RunSession,
60 seed: BTreeMap<String, String>,
61 cancel: Arc<AtomicBool>,
62 ) -> Self {
63 let (tx, rx) = mpsc::sync_channel::<Msg>(IO_QUEUE_VIEWS);
64 let handle = thread::spawn(move || {
65 let mut session = session;
66 let mut png_sha256 = seed;
67 let mut error: Option<String> = None;
68 for msg in rx {
69 match msg {
70 Msg::View(view) => {
71 // Once queued, always publish — cancel only blocks new
72 // submit()s so Resume keeps already-finished maps.
73 if error.is_some() {
74 continue;
75 }
76 match publish_view(&mut session, &mut png_sha256, view) {
77 Ok(()) => {}
78 Err(e) => error = Some(e),
79 }
80 }
81 Msg::Finish(reply) => {
82 let _ = reply.send(IoFinish {
83 session,
84 png_sha256,
85 error,
86 });
87 return;
88 }
89 }
90 }
91 });
92 Self {
93 tx: Some(tx),
94 cancel,
95 handle: Some(handle),
96 finished: false,
97 }
98 }
99
100 /// Enqueue one view. Blocks when the queue is full (backpressure).
101 /// Returns Err if the IO thread is gone or cancel was set before send.
102 pub fn submit(&self, view: ViewPublish) -> Result<(), String> {
103 if self.cancel.load(Ordering::Relaxed) {
104 return Err("geannuleerd".into());
105 }
106 self.tx
107 .as_ref()
108 .ok_or_else(|| "IO-queue gestopt".to_string())?
109 .send(Msg::View(view))
110 .map_err(|_| "IO-queue gestopt".to_string())
111 }
112
113 /// Drain the queue, stop the thread, and return session + digests.
114 pub fn finish(mut self) -> Result<(RunSession, BTreeMap<String, String>), String> {
115 let finished = self.shutdown()?;
116 self.finished = true;
117 if let Some(e) = finished.error {
118 return Err(e);
119 }
120 Ok((finished.session, finished.png_sha256))
121 }
122
123 fn shutdown(&mut self) -> Result<IoFinish, String> {
124 let tx = self
125 .tx
126 .take()
127 .ok_or_else(|| "IO-queue al gestopt".to_string())?;
128 let (reply_tx, reply_rx) = mpsc::sync_channel(1);
129 tx.send(Msg::Finish(reply_tx))
130 .map_err(|_| "IO-queue gestopt".to_string())?;
131 drop(tx);
132 let finished = reply_rx
133 .recv()
134 .map_err(|_| "IO-queue gestopt zonder antwoord".to_string())?;
135 if let Some(h) = self.handle.take() {
136 let _ = h.join();
137 }
138 Ok(finished)
139 }
140 }
141
142 impl Drop for IoQueue {
143 fn drop(&mut self) {
144 if self.finished || self.tx.is_none() {
145 return;
146 }
147 let _ = self.shutdown();
148 }
149 }
150
151 fn publish_view(
152 session: &mut RunSession,
153 png_sha256: &mut BTreeMap<String, String>,
154 view: ViewPublish,
155 ) -> Result<(), String> {
156 for map in view.maps {
157 let digest = session.publish(&map.rel, &map.bytes)?;
158 png_sha256.insert(map.rel, digest);
159 }
160 Ok(())
161 }
162
163 #[cfg(test)]
164 mod tests {
165 use super::*;
166 use crate::resume::RunSession;
167 use std::path::PathBuf;
168
169 struct Temp(PathBuf);
170 impl Temp {
171 fn new() -> Self {
172 let p = std::env::temp_dir().join(format!(
173 "ppd-io-queue-{}-{}",
174 std::process::id(),
175 std::time::SystemTime::now()
176 .duration_since(std::time::UNIX_EPOCH)
177 .unwrap()
178 .as_nanos()
179 ));
180 std::fs::create_dir_all(&p).unwrap();
181 Self(p)
182 }
183 }
184 impl Drop for Temp {
185 fn drop(&mut self) {
186 let _ = std::fs::remove_dir_all(&self.0);
187 }
188 }
189
190 #[test]
191 fn publishes_in_submit_order_and_respects_backpressure_capacity() {
192 let tmp = Temp::new();
193 let session = RunSession::fresh(&tmp.0).unwrap();
194 let cancel = Arc::new(AtomicBool::new(false));
195 let q = IoQueue::start(session, BTreeMap::new(), cancel);
196 for i in 0..6u32 {
197 q.submit(ViewPublish {
198 maps: vec![EncodedMap {
199 rel: format!("raw files/m/f{i}.png"),
200 bytes: format!("png-{i}").into_bytes(),
201 }],
202 })
203 .unwrap();
204 }
205 let (session, digests) = q.finish().unwrap();
206 drop(session);
207 let mut keys: Vec<_> = digests.keys().cloned().collect();
208 keys.sort();
209 assert_eq!(
210 keys,
211 (0..6)
212 .map(|i| format!("raw files/m/f{i}.png"))
213 .collect::<Vec<_>>()
214 );
215 // Journal order matches submit order.
216 let journal = std::fs::read_to_string(tmp.0.join(crate::resume::JOURNAL)).unwrap();
217 let mut seen = Vec::new();
218 for line in journal.lines() {
219 if line.trim().is_empty() {
220 continue;
221 }
222 let v: serde_json::Value = serde_json::from_str(line).unwrap();
223 if let Some(p) = v.get("path").and_then(|x| x.as_str()) {
224 seen.push(p.to_string());
225 }
226 }
227 assert_eq!(
228 seen,
229 (0..6)
230 .map(|i| format!("raw files/m/f{i}.png"))
231 .collect::<Vec<_>>()
232 );
233 }
234
235 #[test]
236 fn cancel_before_submit_returns_error() {
237 let tmp = Temp::new();
238 let session = RunSession::fresh(&tmp.0).unwrap();
239 let cancel = Arc::new(AtomicBool::new(true));
240 let q = IoQueue::start(session, BTreeMap::new(), cancel);
241 let err = q
242 .submit(ViewPublish {
243 maps: vec![EncodedMap {
244 rel: "a.png".into(),
245 bytes: b"x".to_vec(),
246 }],
247 })
248 .unwrap_err();
249 assert!(err.contains("geannuleerd"));
250 // finish still returns (with cancel error if anything was queued — here none)
251 let _ = q.finish();
252 }
253 }
@@ -0,0 +1,253 @@
1//! Ordered, bounded encode+write queue for production maps (Tim t1063u).
2//!
3//! The render worker resolves a view, then hands already-encoded PNG bytes to a
4//! single IO thread that publishes them with [`crate::resume::RunSession`] in
5//! deterministic journal order. A bounded channel provides backpressure so only
6//! a few views sit in flight between GPU/CPU and disk.
7use crate::resume::RunSession;
8use std::{
9 collections::BTreeMap,
10 sync::{
11 atomic::{AtomicBool, Ordering},
12 mpsc, Arc,
13 },
14 thread::{self, JoinHandle},
15};
16
17/// Max view-bundles waiting on the IO thread (backpressure).
18pub const IO_QUEUE_VIEWS: usize = 4;
19
20/// One planned PNG: relative path + encoded file bytes.
21#[derive(Debug)]
22pub struct EncodedMap {
23 pub rel: String,
24 pub bytes: Vec<u8>,
25}
26
27/// All maps of one view, in [`crate::production::SAVED_ORDER`].
28#[derive(Debug)]
29pub struct ViewPublish {
30 pub maps: Vec<EncodedMap>,
31}
32
33enum Msg {
34 View(ViewPublish),
35 /// Stop after draining; reply with the session and digests.
36 Finish(mpsc::SyncSender<IoFinish>),
37}
38
39/// Result of shutting down the IO thread.
40pub struct IoFinish {
41 pub session: RunSession,
42 pub png_sha256: BTreeMap<String, String>,
43 pub error: Option<String>,
44}
45
46/// Handle for the ordered publisher.
47pub struct IoQueue {
48 tx: Option<mpsc::SyncSender<Msg>>,
49 /// Shared cancel; IO stops accepting new publishes when set (still finishes Finish).
50 cancel: Arc<AtomicBool>,
51 handle: Option<JoinHandle<()>>,
52 finished: bool,
53}
54
55impl IoQueue {
56 /// Move `session` onto a dedicated IO thread. `seed` are digests already
57 /// verified (resume skip); they are merged into the final map.
58 pub fn start(
59 session: RunSession,
60 seed: BTreeMap<String, String>,
61 cancel: Arc<AtomicBool>,
62 ) -> Self {
63 let (tx, rx) = mpsc::sync_channel::<Msg>(IO_QUEUE_VIEWS);
64 let handle = thread::spawn(move || {
65 let mut session = session;
66 let mut png_sha256 = seed;
67 let mut error: Option<String> = None;
68 for msg in rx {
69 match msg {
70 Msg::View(view) => {
71 // Once queued, always publish — cancel only blocks new
72 // submit()s so Resume keeps already-finished maps.
73 if error.is_some() {
74 continue;
75 }
76 match publish_view(&mut session, &mut png_sha256, view) {
77 Ok(()) => {}
78 Err(e) => error = Some(e),
79 }
80 }
81 Msg::Finish(reply) => {
82 let _ = reply.send(IoFinish {
83 session,
84 png_sha256,
85 error,
86 });
87 return;
88 }
89 }
90 }
91 });
92 Self {
93 tx: Some(tx),
94 cancel,
95 handle: Some(handle),
96 finished: false,
97 }
98 }
99
100 /// Enqueue one view. Blocks when the queue is full (backpressure).
101 /// Returns Err if the IO thread is gone or cancel was set before send.
102 pub fn submit(&self, view: ViewPublish) -> Result<(), String> {
103 if self.cancel.load(Ordering::Relaxed) {
104 return Err("geannuleerd".into());
105 }
106 self.tx
107 .as_ref()
108 .ok_or_else(|| "IO-queue gestopt".to_string())?
109 .send(Msg::View(view))
110 .map_err(|_| "IO-queue gestopt".to_string())
111 }
112
113 /// Drain the queue, stop the thread, and return session + digests.
114 pub fn finish(mut self) -> Result<(RunSession, BTreeMap<String, String>), String> {
115 let finished = self.shutdown()?;
116 self.finished = true;
117 if let Some(e) = finished.error {
118 return Err(e);
119 }
120 Ok((finished.session, finished.png_sha256))
121 }
122
123 fn shutdown(&mut self) -> Result<IoFinish, String> {
124 let tx = self
125 .tx
126 .take()
127 .ok_or_else(|| "IO-queue al gestopt".to_string())?;
128 let (reply_tx, reply_rx) = mpsc::sync_channel(1);
129 tx.send(Msg::Finish(reply_tx))
130 .map_err(|_| "IO-queue gestopt".to_string())?;
131 drop(tx);
132 let finished = reply_rx
133 .recv()
134 .map_err(|_| "IO-queue gestopt zonder antwoord".to_string())?;
135 if let Some(h) = self.handle.take() {
136 let _ = h.join();
137 }
138 Ok(finished)
139 }
140}
141
142impl Drop for IoQueue {
143 fn drop(&mut self) {
144 if self.finished || self.tx.is_none() {
145 return;
146 }
147 let _ = self.shutdown();
148 }
149}
150
151fn publish_view(
152 session: &mut RunSession,
153 png_sha256: &mut BTreeMap<String, String>,
154 view: ViewPublish,
155) -> Result<(), String> {
156 for map in view.maps {
157 let digest = session.publish(&map.rel, &map.bytes)?;
158 png_sha256.insert(map.rel, digest);
159 }
160 Ok(())
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::resume::RunSession;
167 use std::path::PathBuf;
168
169 struct Temp(PathBuf);
170 impl Temp {
171 fn new() -> Self {
172 let p = std::env::temp_dir().join(format!(
173 "ppd-io-queue-{}-{}",
174 std::process::id(),
175 std::time::SystemTime::now()
176 .duration_since(std::time::UNIX_EPOCH)
177 .unwrap()
178 .as_nanos()
179 ));
180 std::fs::create_dir_all(&p).unwrap();
181 Self(p)
182 }
183 }
184 impl Drop for Temp {
185 fn drop(&mut self) {
186 let _ = std::fs::remove_dir_all(&self.0);
187 }
188 }
189
190 #[test]
191 fn publishes_in_submit_order_and_respects_backpressure_capacity() {
192 let tmp = Temp::new();
193 let session = RunSession::fresh(&tmp.0).unwrap();
194 let cancel = Arc::new(AtomicBool::new(false));
195 let q = IoQueue::start(session, BTreeMap::new(), cancel);
196 for i in 0..6u32 {
197 q.submit(ViewPublish {
198 maps: vec![EncodedMap {
199 rel: format!("raw files/m/f{i}.png"),
200 bytes: format!("png-{i}").into_bytes(),
201 }],
202 })
203 .unwrap();
204 }
205 let (session, digests) = q.finish().unwrap();
206 drop(session);
207 let mut keys: Vec<_> = digests.keys().cloned().collect();
208 keys.sort();
209 assert_eq!(
210 keys,
211 (0..6)
212 .map(|i| format!("raw files/m/f{i}.png"))
213 .collect::<Vec<_>>()
214 );
215 // Journal order matches submit order.
216 let journal = std::fs::read_to_string(tmp.0.join(crate::resume::JOURNAL)).unwrap();
217 let mut seen = Vec::new();
218 for line in journal.lines() {
219 if line.trim().is_empty() {
220 continue;
221 }
222 let v: serde_json::Value = serde_json::from_str(line).unwrap();
223 if let Some(p) = v.get("path").and_then(|x| x.as_str()) {
224 seen.push(p.to_string());
225 }
226 }
227 assert_eq!(
228 seen,
229 (0..6)
230 .map(|i| format!("raw files/m/f{i}.png"))
231 .collect::<Vec<_>>()
232 );
233 }
234
235 #[test]
236 fn cancel_before_submit_returns_error() {
237 let tmp = Temp::new();
238 let session = RunSession::fresh(&tmp.0).unwrap();
239 let cancel = Arc::new(AtomicBool::new(true));
240 let q = IoQueue::start(session, BTreeMap::new(), cancel);
241 let err = q
242 .submit(ViewPublish {
243 maps: vec![EncodedMap {
244 rel: "a.png".into(),
245 bytes: b"x".to_vec(),
246 }],
247 })
248 .unwrap_err();
249 assert!(err.contains("geannuleerd"));
250 // finish still returns (with cancel error if anything was queued — here none)
251 let _ = q.finish();
252 }
253}
src/lib.rs +1 −0
@@ -31,6 +31,7 @@ pub mod notify;
31 31 pub mod preview;
32 32 pub mod preview2d;
33 33 pub mod preview_gl;
34 pub mod io_queue;
34 35 pub mod production;
35 36 pub mod project;
36 37 pub mod recent;
@@ -31,6 +31,7 @@ pub mod notify;
31pub mod preview;
31pub mod preview;
32pub mod preview2d;
32pub mod preview2d;
33pub mod preview_gl;
33pub mod preview_gl;
34pub mod io_queue;
34pub mod production;
35pub mod production;
35pub mod project;
36pub mod project;
36pub mod recent;
37pub mod recent;
src/production.rs +999 −469
@@ -1,14 +1,18 @@
1 1 //! Bounded orthographic production rendering. GPU work runs on the native
2 //! current context, one request per UI update; sampling/filtering/PNG I/O run
3 //! on the worker. The worker never waits for a preview acknowledgment.
2 //! current context with a PBO readback ring (several views in flight); CPU
3 //! resolve runs on the worker; PNG encode/write runs on a bounded IO queue.
4 //! Live preview pixels go through a latest-wins [`PreviewSlot`] — the worker
5 //! never waits for texture upload or UI acknowledgment (Tim t1063u/t1064u).
4 6 use crate::{
5 7 equipment,
8 io_queue::{EncodedMap, IoQueue, ViewPublish},
6 9 preview::{self, Mat4, PreviewCharacter, PreviewControls},
7 10 project::CameraFraming,
8 11 render_job::{ImageBuf, RenderEvent},
9 12 };
10 13 use glow::HasContext;
11 14 use std::{
15 collections::{BTreeMap, VecDeque},
12 16 path::{Path, PathBuf},
13 17 sync::{
14 18 atomic::{AtomicBool, Ordering},
@@ -27,6 +31,8 @@ pub const PASSES: [&str; 6] = [
27 31 "normal",
28 32 "specular",
29 33 ];
34 /// GPU readback ring depth (PBO + fence). Keep 2–3 views in flight.
35 pub const GPU_PIPELINE_DEPTH: usize = 3;
30 36 #[derive(Clone)]
31 37 pub struct Draw {
32 38 pub positions: Arc<Vec<[f32; 3]>>,
@@ -445,7 +451,29 @@ impl Scene {
445 451 }
446 452 }
447 453
448 #[derive(Default)]
454 struct PendingReadback {
455 reply: mpsc::SyncSender<Result<Buffers, String>>,
456 width: u32,
457 height: u32,
458 slot: usize,
459 fence: Option<glow::NativeFence>,
460 }
461
462 struct ReadbackSlot {
463 /// RGBA8 × 3 + depth f32 pack buffers (PIXEL_PACK).
464 pbos: [Option<glow::Buffer>; 4],
465 bytes: [usize; 4],
466 }
467
468 impl Default for ReadbackSlot {
469 fn default() -> Self {
470 Self {
471 pbos: [None, None, None, None],
472 bytes: [0; 4],
473 }
474 }
475 }
476
449 477 pub struct GpuRenderer {
450 478 program: Option<glow::Program>,
451 479 vao: Option<glow::VertexArray>,
@@ -455,11 +483,39 @@ pub struct GpuRenderer {
455 483 colors: Vec<glow::Texture>,
456 484 depth: Option<glow::Renderbuffer>,
457 485 extent: [u32; 2],
486 /// Async readback ring (Tim t1063u).
487 readback: [ReadbackSlot; GPU_PIPELINE_DEPTH],
488 pending: VecDeque<PendingReadback>,
489 next_slot: usize,
490 /// When false, fall back to synchronous read_pixels.
491 pbo_ok: bool,
458 492 }
493
494 impl Default for GpuRenderer {
495 fn default() -> Self {
496 Self {
497 program: None,
498 vao: None,
499 vbo: None,
500 ebo: None,
501 fbo: None,
502 colors: Vec::new(),
503 depth: None,
504 extent: [0, 0],
505 readback: std::array::from_fn(|_| ReadbackSlot::default()),
506 pending: VecDeque::new(),
507 next_slot: 0,
508 pbo_ok: true,
509 }
510 }
511 }
512
459 513 impl GpuRenderer {
460 514 /// # Safety
461 515 /// A native OpenGL 3.3 context must be current on this thread.
462 516 pub unsafe fn destroy(&mut self, gl: &glow::Context) {
517 let _ = self.poll_readbacks(gl, true);
518 self.destroy_readback(gl);
463 519 self.destroy_target(gl);
464 520 if let Some(x) = self.program.take() {
465 521 gl.delete_program(x);
@@ -474,6 +530,25 @@ impl GpuRenderer {
474 530 gl.delete_buffer(x);
475 531 }
476 532 }
533
534 unsafe fn destroy_readback(&mut self, gl: &glow::Context) {
535 while let Some(p) = self.pending.pop_front() {
536 if let Some(f) = p.fence {
537 gl.delete_sync(f);
538 }
539 let _ = p.reply.send(Err("GPU-renderer gestopt".into()));
540 }
541 for slot in &mut self.readback {
542 for b in slot.pbos.iter_mut() {
543 if let Some(buf) = b.take() {
544 gl.delete_buffer(buf);
545 }
546 }
547 slot.bytes = [0; 4];
548 }
549 self.next_slot = 0;
550 }
551
477 552 unsafe fn destroy_target(&mut self, gl: &glow::Context) {
478 553 for x in self.colors.drain(..) {
479 554 gl.delete_texture(x);
@@ -486,6 +561,7 @@ impl GpuRenderer {
486 561 }
487 562 self.extent = [0, 0];
488 563 }
564
489 565 unsafe fn init(&mut self, gl: &glow::Context) -> Result<(), String> {
490 566 if self.program.is_some() {
491 567 return Ok(());
@@ -531,10 +607,13 @@ impl GpuRenderer {
531 607 self.ebo = Some(gl.create_buffer()?);
532 608 Ok(())
533 609 }
610
534 611 unsafe fn target(&mut self, gl: &glow::Context, w: u32, h: u32) -> Result<(), String> {
535 612 if self.extent == [w, h] {
536 613 return Ok(());
537 614 }
615 // Size change invalidates in-flight PBOs for the old extent.
616 let _ = self.poll_readbacks(gl, true);
538 617 self.destroy_target(gl);
539 618 self.fbo = Some(gl.create_framebuffer()?);
540 619 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
@@ -597,11 +676,30 @@ impl GpuRenderer {
597 676 self.extent = [w, h];
598 677 Ok(())
599 678 }
600 /// Render and read back unlit color, view-space normals, scalar specular and
601 /// the actual depth attachment. All four use the same winning surface.
602 /// # Safety
603 /// `gl` is current on the UI thread. GL state is reset for egui painting.
604 pub unsafe fn render(&mut self, gl: &glow::Context, scene: &Scene) -> Result<Buffers, String> {
679
680 unsafe fn ensure_pbo(&mut self, gl: &glow::Context, slot: usize, w: u32, h: u32) -> Result<(), String> {
681 let pixels = w as usize * h as usize;
682 let sizes = [pixels * 4, pixels * 4, pixels * 4, pixels * 4];
683 let ring = &mut self.readback[slot];
684 for i in 0..4 {
685 if ring.bytes[i] == sizes[i] && ring.pbos[i].is_some() {
686 continue;
687 }
688 if let Some(old) = ring.pbos[i].take() {
689 gl.delete_buffer(old);
690 }
691 let buf = gl.create_buffer().map_err(|e| e.to_string())?;
692 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, Some(buf));
693 gl.buffer_data_size(glow::PIXEL_PACK_BUFFER, sizes[i] as i32, glow::STREAM_READ);
694 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None);
695 ring.pbos[i] = Some(buf);
696 ring.bytes[i] = sizes[i];
697 }
698 Ok(())
699 }
700
701 /// Draw the scene into the production FBO. Leaves the FBO bound and GL draw state set.
702 unsafe fn draw_scene(&mut self, gl: &glow::Context, scene: &Scene) -> Result<(), String> {
605 703 if scene.width == 0
606 704 || scene.height == 0
607 705 || scene.width > MAX_HIGHRES
@@ -610,259 +708,484 @@ impl GpuRenderer {
610 708 return Err("highres moet 1–2048 pixels zijn (8× target)".into());
611 709 }
612 710 scene.validate()?;
613 let previous = gl.get_parameter_i32(glow::FRAMEBUFFER_BINDING);
614 let mut vp = [0; 4];
615 gl.get_parameter_i32_slice(glow::VIEWPORT, &mut vp);
616 let result = (|| {
617 self.init(gl)?;
618 self.target(gl, scene.width, scene.height)?;
619 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
620 gl.draw_buffers(&[
621 glow::COLOR_ATTACHMENT0,
622 glow::COLOR_ATTACHMENT1,
623 glow::COLOR_ATTACHMENT2,
624 ]);
625 gl.viewport(0, 0, scene.width as i32, scene.height as i32);
626 gl.disable(glow::SCISSOR_TEST);
627 gl.disable(glow::CULL_FACE);
628 gl.disable(glow::BLEND);
629 gl.enable(glow::DEPTH_TEST);
630 gl.depth_mask(true);
631 gl.depth_func(glow::LESS);
632 // Geometry outside the fixed per-sex interval is clamped, not clipped
633 // away; production counts and reports clamped coverage.
634 gl.enable(glow::DEPTH_CLAMP);
635 gl.clear_depth_f32(1.);
636 gl.clear_color(0., 0., 0., 0.);
637 gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
638 let p = self.program.ok_or("shader ontbreekt")?;
639 gl.use_program(Some(p));
640 gl.uniform_matrix_4_f32_slice(
641 gl.get_uniform_location(p, "u_view").as_ref(),
642 false,
643 &scene.view,
644 );
645 gl.uniform_matrix_4_f32_slice(
646 gl.get_uniform_location(p, "u_projection").as_ref(),
647 false,
648 &scene.projection,
649 );
650 gl.bind_vertex_array(self.vao);
651 gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
652 gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);
653 for (slot, size, offset) in [(0, 3, 0), (1, 3, 3), (2, 2, 6), (3, 4, 8)] {
654 gl.enable_vertex_attrib_array(slot);
655 gl.vertex_attrib_pointer_f32(slot, size, glow::FLOAT, false, 12 * 4, offset * 4);
656 }
657 // glTF BLEND (design.md t1016u): opaque/mask first, then the
658 // BLEND draws blended without depth writes, then their depth only.
659 let order: Vec<(&Draw, u8)> = scene
660 .draws
661 .iter()
662 .filter(|d| !d.blend)
663 .map(|d| (d, 0))
664 .chain(scene.draws.iter().filter(|d| d.blend).map(|d| (d, 1)))
665 .chain(scene.draws.iter().filter(|d| d.blend).map(|d| (d, 2)))
666 .collect();
667 for (draw, pass) in order {
668 match pass {
669 0 => {
670 gl.disable(glow::BLEND);
671 gl.depth_mask(true);
672 gl.color_mask(true, true, true, true);
673 }
674 1 => {
675 gl.enable(glow::BLEND);
676 gl.blend_func_separate(
677 glow::SRC_ALPHA,
678 glow::ONE_MINUS_SRC_ALPHA,
679 glow::ONE,
680 glow::ONE_MINUS_SRC_ALPHA,
681 );
682 gl.depth_mask(false);
683 gl.color_mask(true, true, true, true);
684 }
685 _ => {
686 gl.disable(glow::BLEND);
687 gl.depth_mask(true);
688 gl.color_mask(false, false, false, false);
689 }
711 self.init(gl)?;
712 self.target(gl, scene.width, scene.height)?;
713 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
714 gl.draw_buffers(&[
715 glow::COLOR_ATTACHMENT0,
716 glow::COLOR_ATTACHMENT1,
717 glow::COLOR_ATTACHMENT2,
718 ]);
719 gl.viewport(0, 0, scene.width as i32, scene.height as i32);
720 gl.disable(glow::SCISSOR_TEST);
721 gl.disable(glow::CULL_FACE);
722 gl.disable(glow::BLEND);
723 gl.enable(glow::DEPTH_TEST);
724 gl.depth_mask(true);
725 gl.depth_func(glow::LESS);
726 // Geometry outside the fixed per-sex interval is clamped, not clipped
727 // away; production counts and reports clamped coverage.
728 gl.enable(glow::DEPTH_CLAMP);
729 gl.clear_depth_f32(1.);
730 gl.clear_color(0., 0., 0., 0.);
731 gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
732 let p = self.program.ok_or("shader ontbreekt")?;
733 gl.use_program(Some(p));
734 gl.uniform_matrix_4_f32_slice(
735 gl.get_uniform_location(p, "u_view").as_ref(),
736 false,
737 &scene.view,
738 );
739 gl.uniform_matrix_4_f32_slice(
740 gl.get_uniform_location(p, "u_projection").as_ref(),
741 false,
742 &scene.projection,
743 );
744 gl.bind_vertex_array(self.vao);
745 gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
746 gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);
747 for (slot, size, offset) in [(0, 3, 0), (1, 3, 3), (2, 2, 6), (3, 4, 8)] {
748 gl.enable_vertex_attrib_array(slot);
749 gl.vertex_attrib_pointer_f32(slot, size, glow::FLOAT, false, 12 * 4, offset * 4);
750 }
751 // glTF BLEND (design.md t1016u): opaque/mask first, then the
752 // BLEND draws blended without depth writes, then their depth only.
753 let order: Vec<(&Draw, u8)> = scene
754 .draws
755 .iter()
756 .filter(|d| !d.blend)
757 .map(|d| (d, 0))
758 .chain(scene.draws.iter().filter(|d| d.blend).map(|d| (d, 1)))
759 .chain(scene.draws.iter().filter(|d| d.blend).map(|d| (d, 2)))
760 .collect();
761 for (draw, pass) in order {
762 match pass {
763 0 => {
764 gl.disable(glow::BLEND);
765 gl.depth_mask(true);
766 gl.color_mask(true, true, true, true);
690 767 }
691 if draw.positions.len() != draw.normals.len()
692 || draw
693 .indices
694 .iter()
695 .any(|i| *i as usize >= draw.positions.len())
696 || draw.indices.len() > i32::MAX as usize
697 {
698 return Err("ongeldige GPU-geometrie".into());
768 1 => {
769 gl.enable(glow::BLEND);
770 gl.blend_func_separate(
771 glow::SRC_ALPHA,
772 glow::ONE_MINUS_SRC_ALPHA,
773 glow::ONE,
774 glow::ONE_MINUS_SRC_ALPHA,
775 );
776 gl.depth_mask(false);
777 gl.color_mask(true, true, true, true);
699 778 }
700 let mut data = Vec::with_capacity(draw.positions.len() * 12);
701 for (i, pos) in draw.positions.iter().enumerate() {
702 data.extend_from_slice(pos);
703 data.extend_from_slice(&draw.normals[i]);
704 data.extend_from_slice(&draw.uvs.get(i).copied().unwrap_or([0.; 2]));
705 data.extend_from_slice(&draw.vertex_colors.get(i).copied().unwrap_or([1.; 4]));
779 _ => {
780 gl.disable(glow::BLEND);
781 gl.depth_mask(true);
782 gl.color_mask(false, false, false, false);
706 783 }
707 gl.buffer_data_u8_slice(
708 glow::ARRAY_BUFFER,
709 std::slice::from_raw_parts(data.as_ptr().cast(), data.len() * 4),
710 glow::STREAM_DRAW,
711 );
712 gl.buffer_data_u8_slice(
713 glow::ELEMENT_ARRAY_BUFFER,
714 std::slice::from_raw_parts(
715 draw.indices.as_ptr().cast(),
716 draw.indices.len() * 4,
717 ),
718 glow::STREAM_DRAW,
719 );
720 gl.uniform_4_f32(
721 gl.get_uniform_location(p, "u_color").as_ref(),
722 draw.color[0],
723 draw.color[1],
724 draw.color[2],
725 draw.color[3],
726 );
727 gl.uniform_1_f32(
728 gl.get_uniform_location(p, "u_specular").as_ref(),
729 draw.specular,
730 );
731 gl.uniform_1_f32(gl.get_uniform_location(p, "u_cutoff").as_ref(), draw.cutoff);
784 }
785 if draw.positions.len() != draw.normals.len()
786 || draw
787 .indices
788 .iter()
789 .any(|i| *i as usize >= draw.positions.len())
790 || draw.indices.len() > i32::MAX as usize
791 {
792 return Err("ongeldige GPU-geometrie".into());
793 }
794 let mut data = Vec::with_capacity(draw.positions.len() * 12);
795 for (i, pos) in draw.positions.iter().enumerate() {
796 data.extend_from_slice(pos);
797 data.extend_from_slice(&draw.normals[i]);
798 data.extend_from_slice(&draw.uvs.get(i).copied().unwrap_or([0.; 2]));
799 data.extend_from_slice(&draw.vertex_colors.get(i).copied().unwrap_or([1.; 4]));
800 }
801 gl.buffer_data_u8_slice(
802 glow::ARRAY_BUFFER,
803 std::slice::from_raw_parts(data.as_ptr().cast(), data.len() * 4),
804 glow::STREAM_DRAW,
805 );
806 gl.buffer_data_u8_slice(
807 glow::ELEMENT_ARRAY_BUFFER,
808 std::slice::from_raw_parts(
809 draw.indices.as_ptr().cast(),
810 draw.indices.len() * 4,
811 ),
812 glow::STREAM_DRAW,
813 );
814 gl.uniform_4_f32(
815 gl.get_uniform_location(p, "u_color").as_ref(),
816 draw.color[0],
817 draw.color[1],
818 draw.color[2],
819 draw.color[3],
820 );
821 gl.uniform_1_f32(
822 gl.get_uniform_location(p, "u_specular").as_ref(),
823 draw.specular,
824 );
825 gl.uniform_1_f32(gl.get_uniform_location(p, "u_cutoff").as_ref(), draw.cutoff);
826 gl.uniform_1_i32(
827 gl.get_uniform_location(p, "u_flat").as_ref(),
828 i32::from(draw.flat_normals),
829 );
830 let mut temp = Vec::new();
831 for (unit, (name, tex)) in [
832 ("u_albedo", &draw.albedo),
833 ("u_shiny", &draw.shiny),
834 ]
835 .into_iter()
836 .enumerate()
837 {
838 gl.active_texture(glow::TEXTURE0 + unit as u32);
839 gl.uniform_1_i32(gl.get_uniform_location(p, name).as_ref(), unit as i32);
732 840 gl.uniform_1_i32(
733 gl.get_uniform_location(p, "u_flat").as_ref(),
734 i32::from(draw.flat_normals),
841 gl.get_uniform_location(p, &format!("{name}_enabled"))
842 .as_ref(),
843 i32::from(tex.is_some()),
735 844 );
736 let mut temp = Vec::new();
737 for (unit, name, texture) in [
738 (0, "u_albedo", draw.albedo.as_ref()),
739 (1, "u_shiny", draw.shiny.as_ref()),
740 ] {
741 gl.active_texture(glow::TEXTURE0 + unit);
742 gl.uniform_1_i32(gl.get_uniform_location(p, name).as_ref(), unit as i32);
743 gl.uniform_1_i32(
744 gl.get_uniform_location(p, &format!("{name}_enabled"))
745 .as_ref(),
746 i32::from(texture.is_some()),
845 if let Some(tex) = tex {
846 let t = gl.create_texture()?;
847 temp.push(t);
848 gl.bind_texture(glow::TEXTURE_2D, Some(t));
849 gl.tex_parameter_i32(
850 glow::TEXTURE_2D,
851 glow::TEXTURE_MIN_FILTER,
852 glow::NEAREST as i32,
747 853 );
748 if let Some(image) = texture {
749 let t = gl.create_texture()?;
750 temp.push(t);
751 gl.bind_texture(glow::TEXTURE_2D, Some(t));
752 gl.tex_parameter_i32(
753 glow::TEXTURE_2D,
754 glow::TEXTURE_MIN_FILTER,
755 glow::NEAREST as i32,
756 );
757 gl.tex_parameter_i32(
758 glow::TEXTURE_2D,
759 glow::TEXTURE_MAG_FILTER,
760 glow::NEAREST as i32,
761 );
762 gl.tex_parameter_i32(
763 glow::TEXTURE_2D,
764 glow::TEXTURE_WRAP_S,
765 glow::REPEAT as i32,
766 );
767 gl.tex_parameter_i32(
768 glow::TEXTURE_2D,
769 glow::TEXTURE_WRAP_T,
770 glow::REPEAT as i32,
771 );
772 gl.tex_image_2d(
773 glow::TEXTURE_2D,
774 0,
775 if unit == 0 {
776 glow::SRGB8_ALPHA8 as i32
777 } else {
778 glow::RGBA8 as i32
779 },
780 image.width as i32,
781 image.height as i32,
782 0,
783 glow::RGBA,
784 glow::UNSIGNED_BYTE,
785 Some(&image.rgba),
786 );
787 } else {
788 gl.bind_texture(glow::TEXTURE_2D, None);
789 }
790 }
791 gl.draw_elements(
792 glow::TRIANGLES,
793 draw.indices.len() as i32,
794 glow::UNSIGNED_INT,
795 0,
796 );
797 for t in temp {
798 gl.delete_texture(t);
854 gl.tex_parameter_i32(
855 glow::TEXTURE_2D,
856 glow::TEXTURE_MAG_FILTER,
857 glow::NEAREST as i32,
858 );
859 gl.tex_parameter_i32(
860 glow::TEXTURE_2D,
861 glow::TEXTURE_WRAP_S,
862 glow::REPEAT as i32,
863 );
864 gl.tex_parameter_i32(
865 glow::TEXTURE_2D,
866 glow::TEXTURE_WRAP_T,
867 glow::REPEAT as i32,
868 );
869 gl.tex_image_2d(
870 glow::TEXTURE_2D,
871 0,
872 glow::RGBA8 as i32,
873 tex.width as i32,
874 tex.height as i32,
875 0,
876 glow::RGBA,
877 glow::UNSIGNED_BYTE,
878 Some(&tex.rgba),
879 );
880 } else {
881 gl.bind_texture(glow::TEXTURE_2D, None);
799 882 }
800 883 }
801 gl.disable(glow::BLEND);
802 gl.depth_mask(true);
803 gl.color_mask(true, true, true, true);
804 let pixels = scene.width as usize * scene.height as usize;
805 let mut maps = Vec::new();
806 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
807 for slot in 0..3 {
808 let mut data = vec![0; pixels * 4];
809 gl.read_buffer(glow::COLOR_ATTACHMENT0 + slot);
810 gl.read_pixels(
811 0,
812 0,
813 scene.width as i32,
814 scene.height as i32,
815 glow::RGBA,
816 glow::UNSIGNED_BYTE,
817 glow::PixelPackData::Slice(&mut data),
818 );
819 flip_rows(&mut data, scene.width as usize * 4, scene.height as usize);
820 maps.push(data);
884 gl.draw_elements(
885 glow::TRIANGLES,
886 draw.indices.len() as i32,
887 glow::UNSIGNED_INT,
888 0,
889 );
890 for t in temp {
891 gl.delete_texture(t);
821 892 }
822 let mut depth = vec![1f32; pixels];
823 let bytes = std::slice::from_raw_parts_mut(depth.as_mut_ptr().cast(), pixels * 4);
893 }
894 gl.disable(glow::BLEND);
895 gl.depth_mask(true);
896 gl.color_mask(true, true, true, true);
897 Ok(())
898 }
899
900 unsafe fn readback_into_slice(
901 gl: &glow::Context,
902 w: u32,
903 h: u32,
904 ) -> Result<Buffers, String> {
905 let pixels = w as usize * h as usize;
906 let mut maps = Vec::new();
907 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
908 for slot in 0..3 {
909 let mut data = vec![0; pixels * 4];
910 gl.read_buffer(glow::COLOR_ATTACHMENT0 + slot);
824 911 gl.read_pixels(
825 912 0,
826 913 0,
827 scene.width as i32,
828 scene.height as i32,
829 glow::DEPTH_COMPONENT,
830 glow::FLOAT,
831 glow::PixelPackData::Slice(bytes),
914 w as i32,
915 h as i32,
916 glow::RGBA,
917 glow::UNSIGNED_BYTE,
918 glow::PixelPackData::Slice(&mut data),
832 919 );
833 flip_rows(&mut depth, scene.width as usize, scene.height as usize);
834 let error = gl.get_error();
835 if error != glow::NO_ERROR {
836 return Err(format!("productie-GL fout 0x{error:x}"));
920 flip_rows(&mut data, w as usize * 4, h as usize);
921 maps.push(data);
922 }
923 let mut depth = vec![1f32; pixels];
924 let bytes = std::slice::from_raw_parts_mut(depth.as_mut_ptr().cast(), pixels * 4);
925 gl.read_pixels(
926 0,
927 0,
928 w as i32,
929 h as i32,
930 glow::DEPTH_COMPONENT,
931 glow::FLOAT,
932 glow::PixelPackData::Slice(bytes),
933 );
934 flip_rows(&mut depth, w as usize, h as usize);
935 let error = gl.get_error();
936 if error != glow::NO_ERROR {
937 return Err(format!("productie-GL fout 0x{error:x}"));
938 }
939 let specular = maps.pop().unwrap();
940 let normal = maps.pop().unwrap();
941 let diffuse = maps.pop().unwrap();
942 Ok(Buffers {
943 width: w,
944 height: h,
945 diffuse,
946 normal,
947 specular,
948 depth,
949 })
950 }
951
952 unsafe fn kick_pbo_readback(
953 &mut self,
954 gl: &glow::Context,
955 w: u32,
956 h: u32,
957 reply: mpsc::SyncSender<Result<Buffers, String>>,
958 ) -> Result<(), String> {
959 let slot = self.next_slot % GPU_PIPELINE_DEPTH;
960 self.next_slot = self.next_slot.wrapping_add(1);
961 if let Err(e) = self.ensure_pbo(gl, slot, w, h) {
962 self.pbo_ok = false;
963 // Sync fallback for this view.
964 let buffers = Self::readback_into_slice(gl, w, h);
965 let _ = reply.send(buffers.map_err(|err| format!("{e}; {err}")));
966 return Ok(());
967 }
968 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
969 let ring = &self.readback[slot];
970 for i in 0..3 {
971 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, ring.pbos[i]);
972 gl.read_buffer(glow::COLOR_ATTACHMENT0 + i as u32);
973 gl.read_pixels(
974 0,
975 0,
976 w as i32,
977 h as i32,
978 glow::RGBA,
979 glow::UNSIGNED_BYTE,
980 glow::PixelPackData::BufferOffset(0),
981 );
982 }
983 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, ring.pbos[3]);
984 gl.read_pixels(
985 0,
986 0,
987 w as i32,
988 h as i32,
989 glow::DEPTH_COMPONENT,
990 glow::FLOAT,
991 glow::PixelPackData::BufferOffset(0),
992 );
993 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None);
994 let fence = match gl.fence_sync(glow::SYNC_GPU_COMMANDS_COMPLETE, 0) {
995 Ok(f) => Some(f),
996 Err(_) => {
997 self.pbo_ok = false;
998 let buffers = Self::readback_into_slice(gl, w, h);
999 let _ = reply.send(buffers);
1000 return Ok(());
837 1001 }
838 let specular = maps.pop().unwrap();
839 let normal = maps.pop().unwrap();
840 let diffuse = maps.pop().unwrap();
841 Ok(Buffers {
842 width: scene.width,
843 height: scene.height,
844 diffuse,
845 normal,
846 specular,
847 depth,
848 })
849 })();
850 gl.bind_framebuffer(
851 glow::FRAMEBUFFER,
852 std::num::NonZeroU32::new(previous as u32).map(glow::NativeFramebuffer),
1002 };
1003 self.pending.push_back(PendingReadback {
1004 reply,
1005 width: w,
1006 height: h,
1007 slot,
1008 fence,
1009 });
1010 Ok(())
1011 }
1012
1013 unsafe fn complete_oldest(&mut self, gl: &glow::Context) -> Result<(), String> {
1014 let Some(mut pending) = self.pending.pop_front() else {
1015 return Ok(());
1016 };
1017 if let Some(fence) = pending.fence.take() {
1018 // Block until this readback is done; newer PBOs stay in flight.
1019 let status = gl.client_wait_sync(fence, glow::SYNC_FLUSH_COMMANDS_BIT, i32::MAX);
1020 gl.delete_sync(fence);
1021 if status == glow::WAIT_FAILED || status == glow::TIMEOUT_EXPIRED {
1022 let _ = pending
1023 .reply
1024 .send(Err(format!("PBO-readback wachtstatus 0x{status:x}")));
1025 return Ok(());
1026 }
1027 }
1028 let w = pending.width;
1029 let h = pending.height;
1030 let pixels = w as usize * h as usize;
1031 let slot = pending.slot;
1032 let mut maps = Vec::with_capacity(3);
1033 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
1034 for i in 0..3 {
1035 let buf = self.readback[slot].pbos[i];
1036 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, buf);
1037 let ptr = gl.map_buffer_range(
1038 glow::PIXEL_PACK_BUFFER,
1039 0,
1040 (pixels * 4) as i32,
1041 glow::MAP_READ_BIT,
1042 );
1043 let mut data = vec![0u8; pixels * 4];
1044 if ptr.is_null() {
1045 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None);
1046 let _ = pending.reply.send(Err("PBO map mislukt".into()));
1047 return Ok(());
1048 }
1049 std::ptr::copy_nonoverlapping(ptr, data.as_mut_ptr(), pixels * 4);
1050 let _ = gl.unmap_buffer(glow::PIXEL_PACK_BUFFER);
1051 flip_rows(&mut data, w as usize * 4, h as usize);
1052 maps.push(data);
1053 }
1054 let buf = self.readback[slot].pbos[3];
1055 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, buf);
1056 let ptr = gl.map_buffer_range(
1057 glow::PIXEL_PACK_BUFFER,
1058 0,
1059 (pixels * 4) as i32,
1060 glow::MAP_READ_BIT,
853 1061 );
854 gl.viewport(vp[0], vp[1], vp[2], vp[3]);
855 gl.bind_vertex_array(None);
856 gl.use_program(None);
857 gl.active_texture(glow::TEXTURE0);
858 gl.disable(glow::DEPTH_TEST);
859 gl.disable(glow::DEPTH_CLAMP);
860 gl.disable(glow::SCISSOR_TEST);
861 gl.enable(glow::BLEND);
862 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 4);
863 result
1062 let mut depth = vec![1f32; pixels];
1063 if ptr.is_null() {
1064 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None);
1065 let _ = pending.reply.send(Err("PBO depth-map mislukt".into()));
1066 return Ok(());
1067 }
1068 std::ptr::copy_nonoverlapping(ptr as *const f32, depth.as_mut_ptr(), pixels);
1069 let _ = gl.unmap_buffer(glow::PIXEL_PACK_BUFFER);
1070 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None);
1071 flip_rows(&mut depth, w as usize, h as usize);
1072 let specular = maps.pop().unwrap();
1073 let normal = maps.pop().unwrap();
1074 let diffuse = maps.pop().unwrap();
1075 let _ = pending.reply.send(Ok(Buffers {
1076 width: w,
1077 height: h,
1078 diffuse,
1079 normal,
1080 specular,
1081 depth,
1082 }));
1083 Ok(())
1084 }
1085
1086 /// Complete finished (or all, when `force`) PBO readbacks and deliver replies.
1087 /// # Safety
1088 /// `gl` must be current on this thread.
1089 pub unsafe fn poll_readbacks(&mut self, gl: &glow::Context, force: bool) -> Result<(), String> {
1090 if force {
1091 while !self.pending.is_empty() {
1092 self.complete_oldest(gl)?;
1093 }
1094 return Ok(());
1095 }
1096 while let Some(front) = self.pending.front() {
1097 let ready = match front.fence {
1098 None => true,
1099 Some(fence) => {
1100 let status = gl.client_wait_sync(fence, 0, 0);
1101 status == glow::ALREADY_SIGNALED || status == glow::CONDITION_SATISFIED
1102 }
1103 };
1104 if !ready {
1105 break;
1106 }
1107 self.complete_oldest(gl)?;
1108 }
1109 Ok(())
1110 }
1111
1112 /// Submit a scene into the PBO ring; the reply arrives when the readback finishes.
1113 /// # Safety
1114 /// `gl` is current on the UI thread. GL state is reset for egui painting.
1115 pub unsafe fn submit(
1116 &mut self,
1117 gl: &glow::Context,
1118 scene: &Scene,
1119 reply: mpsc::SyncSender<Result<Buffers, String>>,
1120 ) {
1121 let previous = gl.get_parameter_i32(glow::FRAMEBUFFER_BINDING);
1122 let mut vp = [0; 4];
1123 gl.get_parameter_i32_slice(glow::VIEWPORT, &mut vp);
1124 let restore = |gl: &glow::Context| {
1125 gl.bind_framebuffer(
1126 glow::FRAMEBUFFER,
1127 std::num::NonZeroU32::new(previous as u32).map(glow::NativeFramebuffer),
1128 );
1129 gl.viewport(vp[0], vp[1], vp[2], vp[3]);
1130 gl.bind_vertex_array(None);
1131 gl.use_program(None);
1132 gl.active_texture(glow::TEXTURE0);
1133 gl.disable(glow::DEPTH_TEST);
1134 gl.disable(glow::DEPTH_CLAMP);
1135 gl.disable(glow::SCISSOR_TEST);
1136 gl.enable(glow::BLEND);
1137 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 4);
1138 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None);
1139 };
1140 if scene.width == 0
1141 || scene.height == 0
1142 || scene.width > MAX_HIGHRES
1143 || scene.height > MAX_HIGHRES
1144 {
1145 let _ = reply.send(Err("ongeldige highres-afmetingen".into()));
1146 restore(gl);
1147 return;
1148 }
1149 if let Err(e) = self.poll_readbacks(gl, false) {
1150 let _ = reply.send(Err(e));
1151 restore(gl);
1152 return;
1153 }
1154 while self.pending.len() >= GPU_PIPELINE_DEPTH {
1155 if let Err(e) = self.complete_oldest(gl) {
1156 let _ = reply.send(Err(e));
1157 restore(gl);
1158 return;
1159 }
1160 }
1161 if let Err(e) = self.draw_scene(gl, scene) {
1162 let _ = reply.send(Err(e));
1163 restore(gl);
1164 return;
1165 }
1166 if self.pbo_ok {
1167 if let Err(e) = self.kick_pbo_readback(gl, scene.width, scene.height, reply) {
1168 // kick_pbo consumes reply on success paths; on ensure failure it may have sent already.
1169 let _ = e;
1170 }
1171 } else {
1172 let buffers = Self::readback_into_slice(gl, scene.width, scene.height);
1173 let _ = reply.send(buffers);
1174 }
1175 restore(gl);
1176 }
1177
1178 /// Synchronous render+readback (probes / tests). Flushes the pipeline first.
1179 /// # Safety
1180 /// `gl` is current on the UI thread. GL state is reset for egui painting.
1181 pub unsafe fn render(&mut self, gl: &glow::Context, scene: &Scene) -> Result<Buffers, String> {
1182 let (tx, rx) = mpsc::sync_channel(1);
1183 self.submit(gl, scene, tx);
1184 let _ = self.poll_readbacks(gl, true);
1185 rx.recv().map_err(|_| "GPU-antwoord ontbreekt".to_string())?
864 1186 }
865 1187 }
1188
866 1189 /// Covered highres pixels whose depth hit the fixed interval's near or far
867 1190 /// plane (GL depth clamp), i.e. geometry outside `[depth_near, depth_far]`.
868 1191 pub fn clamped_depth_pixels(raw: &Buffers) -> usize {
@@ -1338,10 +1661,54 @@ impl GpuRequest {
1338 1661 /// # Safety
1339 1662 /// `gl` must be current on this UI thread.
1340 1663 pub unsafe fn service(self, renderer: &mut GpuRenderer, gl: &glow::Context) {
1341 let result = renderer.render(gl, &self.scene);
1342 let _ = self.reply.send(result);
1664 renderer.submit(gl, &self.scene, self.reply);
1343 1665 }
1344 1666 }
1667
1668
1669 /// Latest-wins live preview frame (Tim t1064u). The render worker overwrites this
1670 /// slot without waiting for the UI; the UI may skip intermediate frames.
1671 #[derive(Debug, Clone)]
1672 pub struct PreviewFrame {
1673 pub image: ImageBuf,
1674 pub label: String,
1675 pub revision: u64,
1676 }
1677
1678 #[derive(Clone, Default)]
1679 pub struct PreviewSlot(Arc<Mutex<Option<PreviewFrame>>>);
1680
1681 impl PreviewSlot {
1682 /// Overwrite the slot. Critical section is only the swap — never texture upload.
1683 pub fn publish(&self, image: ImageBuf, label: String) {
1684 if let Ok(mut guard) = self.0.lock() {
1685 let revision = guard
1686 .as_ref()
1687 .map(|f| f.revision.wrapping_add(1))
1688 .unwrap_or(1);
1689 *guard = Some(PreviewFrame {
1690 image,
1691 label,
1692 revision,
1693 });
1694 }
1695 }
1696
1697 /// Take the newest frame if any (UI side). Returns None when unchanged/empty.
1698 pub fn take(&self) -> Option<PreviewFrame> {
1699 self.0.lock().ok()?.take()
1700 }
1701
1702 /// Apply a taken frame into GUI state (revision/label/pixels).
1703 pub fn apply_to(&self, ui: &mut crate::render_job::RenderUiState) {
1704 if let Some(frame) = self.take() {
1705 ui.preview_label = frame.label;
1706 ui.preview_revision = frame.revision;
1707 ui.preview = Some(frame.image);
1708 }
1709 }
1710 }
1711
1345 1712 #[derive(Default)]
1346 1713 struct Pending {
1347 1714 pass: Option<RenderEvent>,
@@ -1375,6 +1742,8 @@ impl Mailbox {
1375 1742 }
1376 1743 pub struct RenderThread {
1377 1744 pub mailbox: Mailbox,
1745 /// Latest rendered preview image; independent of the progress mailbox (t1064u).
1746 pub preview: PreviewSlot,
1378 1747 pub gpu: mpsc::Receiver<GpuRequest>,
1379 1748 pub cancel: Arc<AtomicBool>,
1380 1749 pub handle: Option<thread::JoinHandle<()>>,
@@ -1412,14 +1781,39 @@ fn spawn_target(
1412 1781 ) -> RenderThread {
1413 1782 let mailbox = Mailbox::default();
1414 1783 let output = mailbox.clone();
1784 let preview = PreviewSlot::default();
1785 let preview_w = preview.clone();
1415 1786 let cancel = Arc::new(AtomicBool::new(false));
1416 1787 let flag = cancel.clone();
1417 1788 let (tx, gpu) = mpsc::channel();
1418 1789 let handle = thread::spawn(move || {
1419 let mut emit = |event| {
1420 output.publish(event);
1421 if let Some(ctx) = &ctx {
1422 ctx.request_repaint();
1790 // Progress/terminal go through the mailbox and may request a repaint.
1791 // Preview pixels go only to PreviewSlot (latest-wins) and never block
1792 // the worker on texture upload or UI ack (Tim t1064u).
1793 let mut emit = |event: RenderEvent| {
1794 match event {
1795 RenderEvent::Pass {
1796 index,
1797 total,
1798 label,
1799 image: Some(image),
1800 } => {
1801 preview_w.publish(image, label.clone());
1802 output.publish(RenderEvent::Pass {
1803 index,
1804 total,
1805 label,
1806 image: None,
1807 });
1808 // No request_repaint here: the UI pulls the slot on its own
1809 // frames (GPU service already keeps the loop alive).
1810 }
1811 other => {
1812 output.publish(other);
1813 if let Some(ctx) = &ctx {
1814 ctx.request_repaint();
1815 }
1816 }
1423 1817 }
1424 1818 };
1425 1819 let result = execute_target(job, target, &tx, &flag, ctx.as_ref(), &mut emit);
@@ -1433,6 +1827,7 @@ fn spawn_target(
1433 1827 });
1434 1828 RenderThread {
1435 1829 mailbox,
1830 preview,
1436 1831 gpu,
1437 1832 cancel,
1438 1833 handle: Some(handle),
@@ -1601,7 +1996,7 @@ pub fn planned_paths(plan: &Plan, sex: &str, set: &str) -> Result<Vec<String>, S
1601 1996 fn execute(
1602 1997 job: RenderJob,
1603 1998 gpu: &mpsc::Sender<GpuRequest>,
1604 cancel: &AtomicBool,
1999 cancel: &Arc<AtomicBool>,
1605 2000 ctx: Option<&eframe::egui::Context>,
1606 2001 emit: &mut dyn FnMut(RenderEvent),
1607 2002 ) -> Result<(), String> {
@@ -1612,7 +2007,7 @@ fn execute_target(
1612 2007 job: RenderJob,
1613 2008 target: RunTarget,
1614 2009 gpu: &mpsc::Sender<GpuRequest>,
1615 cancel: &AtomicBool,
2010 cancel: &Arc<AtomicBool>,
1616 2011 ctx: Option<&eframe::egui::Context>,
1617 2012 emit: &mut dyn FnMut(RenderEvent),
1618 2013 ) -> Result<(), String> {
@@ -1631,7 +2026,7 @@ fn execute_target(
1631 2026 label: format!("Herstel: {note}"),
1632 2027 });
1633 2028 }
1634 let mut session = match target {
2029 let session = match target {
1635 2030 RunTarget::Fresh => {
1636 2031 for name in crate::resume::discard_stale(&job.output, &job.sex, &set)? {
1637 2032 emit(RenderEvent::Step {
@@ -1684,7 +2079,6 @@ fn execute_target(
1684 2079 });
1685 2080 }
1686 2081 }
1687 let root = session.root.clone();
1688 2082 // Internal unique names select every source clip, even duplicate display names.
1689 2083 for (i, c) in job.character.clips.iter_mut().enumerate() {
1690 2084 c.name = format!("render-clip-{i}");
@@ -1692,60 +2086,38 @@ fn execute_target(
1692 2086 let mut completed = 0u32;
1693 2087 let mut clamped_views = 0u32;
1694 2088 let mut clamped_pixels = 0u64;
1695 let mut png_sha256 = std::collections::BTreeMap::<String, String>::new();
2089 let verified = session.verified.clone();
2090 let attempt = session.attempt;
2091 let root = session.root.clone();
1696 2092 let mut cache = equipment::TextureCache::default();
1697 let result = (|| {
1698 for (index, sample) in samples(&plan).enumerate() {
1699 let clip = &plan.clips[sample.clip];
1700 let frame = sample.frame;
1701 let pitch = sample.pitch;
1702 let yaw = sample.yaw;
1703 let paths = &planned[index * 4..index * 4 + 4];
1704 if cancel.load(Ordering::Relaxed) {
1705 return Err("geannuleerd".into());
1706 }
1707 let pose = format!(
1708 "{} {set} · {} f{frame}/{} · p{pitch} y{yaw}",
1709 job.sex, clip.source_name, clip.frames
1710 );
1711 if paths.iter().all(|p| session.verified.contains_key(p)) {
1712 for p in paths {
1713 png_sha256.insert(p.clone(), session.verified[p].clone());
1714 }
1715 completed += PASSES.len() as u32;
1716 emit(RenderEvent::Step {
1717 index: completed,
1718 total: plan.passes,
1719 label: format!("{pose} · al aanwezig, checksums kloppen"),
1720 });
1721 continue;
1722 }
1723 let key = if job.character.clips.is_empty() {
1724 String::new()
1725 } else {
1726 format!("render-clip-{}", sample.clip)
1727 };
1728 emit(RenderEvent::Step {
1729 index: completed,
1730 total: plan.passes,
1731 label: format!("{pose} · highres"),
1732 });
1733 let scene = scene(
1734 &job,
1735 &key,
1736 frame as f32 / job.fps as f32,
1737 pitch as f32,
1738 yaw as f32,
1739 &mut cache,
1740 )?;
1741 let (tx, rx) = mpsc::sync_channel(1);
1742 gpu.send(GpuRequest { scene, reply: tx })
1743 .map_err(|_| "GPU-service gesloten")?;
1744 if let Some(ctx) = ctx {
1745 ctx.request_repaint();
1746 }
2093
2094 struct InflightGpu {
2095 rx: mpsc::Receiver<Result<Buffers, String>>,
2096 paths: Vec<String>,
2097 pose: String,
2098 }
2099
2100 let seed_sha = verified.clone();
2101 let mut atlas_cells: BTreeMap<String, Arc<Vec<u8>>> = BTreeMap::new();
2102 let mut io = Some(IoQueue::start(session, seed_sha, Arc::clone(cancel)));
2103 let work = (|| {
2104 let io = io.as_ref().ok_or_else(|| "IO-queue ontbreekt".to_string())?;
2105 let mut inflight: VecDeque<InflightGpu> = VecDeque::new();
2106
2107 let drain_one = |item: InflightGpu,
2108 completed: &mut u32,
2109 clamped_views: &mut u32,
2110 clamped_pixels: &mut u64,
2111 atlas_cells: &mut BTreeMap<String, Arc<Vec<u8>>>,
2112 io: &IoQueue,
2113 job: &RenderJob,
2114 render_doc: &serde_json::Value,
2115 plan_passes: u32,
2116 cancel: &AtomicBool,
2117 emit: &mut dyn FnMut(RenderEvent)|
2118 -> Result<(), String> {
1747 2119 let raw = loop {
1748 match rx.recv_timeout(std::time::Duration::from_millis(100)) {
2120 match item.rx.recv_timeout(std::time::Duration::from_millis(100)) {
1749 2121 Ok(result) => break result?,
1750 2122 Err(mpsc::RecvTimeoutError::Timeout) => {
1751 2123 if cancel.load(Ordering::Relaxed) {
@@ -1755,13 +2127,15 @@ fn execute_target(
1755 2127 Err(_) => return Err("GPU-service gestopt".into()),
1756 2128 }
1757 2129 };
2130 let pose = &item.pose;
2131 let paths = &item.paths;
1758 2132 let clamped = clamped_depth_pixels(&raw);
1759 2133 if clamped > 0 {
1760 clamped_views += 1;
1761 clamped_pixels += clamped as u64;
2134 *clamped_views += 1;
2135 *clamped_pixels += clamped as u64;
1762 2136 emit(RenderEvent::Step {
1763 index: completed,
1764 total: plan.passes,
2137 index: *completed,
2138 total: plan_passes,
1765 2139 label: format!(
1766 2140 "Waarschuwing: {pose} · {clamped} highres-pixels buiten dieptebereik {:.2}–{:.2} m (geklemd)",
1767 2141 render_doc["depth_near"].as_f64().unwrap_or(0.),
@@ -1769,10 +2143,10 @@ fn execute_target(
1769 2143 ),
1770 2144 });
1771 2145 }
1772 completed += 1;
2146 *completed += 1;
1773 2147 emit(RenderEvent::Pass {
1774 index: completed - 1,
1775 total: plan.passes,
2148 index: *completed - 1,
2149 total: plan_passes,
1776 2150 label: format!("{pose} · highres"),
1777 2151 image: Some(image(raw.width, raw.height, raw.diffuse.clone())),
1778 2152 });
@@ -1780,15 +2154,15 @@ fn execute_target(
1780 2154 return Err("geannuleerd".into());
1781 2155 }
1782 2156 emit(RenderEvent::Step {
1783 index: completed,
1784 total: plan.passes,
2157 index: *completed,
2158 total: plan_passes,
1785 2159 label: format!("{pose} · detail"),
1786 2160 });
1787 2161 let detail = detail_map(&raw)?;
1788 completed += 1;
2162 *completed += 1;
1789 2163 emit(RenderEvent::Pass {
1790 index: completed - 1,
1791 total: plan.passes,
2164 index: *completed - 1,
2165 total: plan_passes,
1792 2166 label: format!("{pose} · detail"),
1793 2167 image: Some(detail_image(&raw, &detail)),
1794 2168 });
@@ -1796,8 +2170,8 @@ fn execute_target(
1796 2170 return Err("geannuleerd".into());
1797 2171 }
1798 2172 emit(RenderEvent::Step {
1799 index: completed,
1800 total: plan.passes,
2173 index: *completed,
2174 total: plan_passes,
1801 2175 label: format!("{pose} · target-res / diffuse"),
1802 2176 });
1803 2177 let mut target = resolve(&raw, &detail, job.target_width, job.target_height)?;
@@ -1812,181 +2186,281 @@ fn execute_target(
1812 2186 ("specular", target.specular.clone()),
1813 2187 ]
1814 2188 .into_iter()
1815 .zip(paths)
2189 .zip(paths.iter())
1816 2190 {
1817 2191 if cancel.load(Ordering::Relaxed) {
1818 2192 return Err("geannuleerd".into());
1819 2193 }
1820 2194 emit(RenderEvent::Step {
1821 index: completed,
1822 total: plan.passes,
2195 index: *completed,
2196 total: plan_passes,
1823 2197 label: format!("{pose} · {map}"),
1824 2198 });
1825 2199 debug_assert!(rel.ends_with(&format!("_{map}.png")));
1826 let kept = session.verified.contains_key(rel);
1827 let digest = if kept {
1828 if map == "depth" {
1829 let stored = std::fs::read(session.root.join(rel))
1830 .map_err(|e| format!("{rel}: {e}"))?;
1831 preview = stored_depth_preview(
1832 &stored,
1833 job.target_width,
1834 job.target_height,
1835 job.depth_format,
1836 )?;
1837 }
1838 session.verified[rel].clone()
2200 let bytes = if map == "depth" {
2201 let pixels: Vec<(f64, f64)> = target
2202 .depth_t
2203 .iter()
2204 .enumerate()
2205 .map(|(i, t)| {
2206 (f64::from(*t), f64::from(target.depth[i * 2 + 1]) / 65535.)
2207 })
2208 .collect();
2209 let bytes = encode_depth_png_fmt(
2210 job.target_width,
2211 job.target_height,
2212 &pixels,
2213 job.depth_format,
2214 )?;
2215 preview = stored_depth_preview(
2216 &bytes,
2217 job.target_width,
2218 job.target_height,
2219 job.depth_format,
2220 )?;
2221 bytes
1839 2222 } else {
1840 let bytes = if map == "depth" {
1841 let pixels: Vec<(f64, f64)> = target
1842 .depth_t
1843 .iter()
1844 .enumerate()
1845 .map(|(i, t)| {
1846 (f64::from(*t), f64::from(target.depth[i * 2 + 1]) / 65535.)
1847 })
1848 .collect();
1849 let bytes = encode_depth_png_fmt(
1850 job.target_width,
1851 job.target_height,
1852 &pixels,
1853 job.depth_format,
1854 )?;
1855 preview = stored_depth_preview(
1856 &bytes,
1857 job.target_width,
1858 job.target_height,
1859 job.depth_format,
1860 )?;
1861 bytes
1862 } else {
1863 encode_rgba_png(&preview, map == "diffuse")?
1864 };
1865 session.publish(rel, &bytes)?
2223 encode_rgba_png(&preview, map == "diffuse")?
1866 2224 };
1867 png_sha256.insert(rel.clone(), digest);
1868 completed += 1;
2225 atlas_cells.insert(rel.clone(), Arc::new(bytes.clone()));
2226 // Publish per map (ordered) so cancel mid-view still leaves
2227 // already-finished maps on disk for Resume, like before.
2228 io.submit(ViewPublish {
2229 maps: vec![EncodedMap {
2230 rel: rel.clone(),
2231 bytes,
2232 }],
2233 })?;
2234 *completed += 1;
1869 2235 let pass = if map == "diffuse" {
1870 2236 "target-res / diffuse"
1871 2237 } else {
1872 2238 map
1873 2239 };
1874 2240 emit(RenderEvent::Pass {
1875 index: completed - 1,
1876 total: plan.passes,
1877 label: if kept {
1878 format!("{pose} · {pass} (bestaand bestand behouden)")
1879 } else {
1880 format!("{pose} · {pass}")
1881 },
2241 index: *completed - 1,
2242 total: plan_passes,
2243 label: format!("{pose} · {pass}"),
1882 2244 image: Some(preview),
1883 2245 });
1884 2246 }
1885 }
1886 if png_sha256.len() as u32 != plan.files {
1887 return Err(format!(
1888 "checksumtelling {} wijkt af van geplande {} PNGs",
1889 png_sha256.len(),
1890 plan.files
1891 ));
1892 }
1893 // Atlases + .ppda only after every loose map is published and verified
1894 // (also after a resume); complete.json stays the final marker.
1895 crate::atlas::write_atlases(
1896 &crate::atlas::AtlasJob {
1897 root: &root,
1898 render_doc: &render_doc,
1899 plan: &plan,
1900 sex: &job.sex,
1901 set: &set,
1902 cell_width: job.target_width,
1903 cell_height: job.target_height,
1904 png_sha256: &png_sha256,
1905 attempt: session.attempt,
1906 cancel,
1907 trim: job.atlas_trim,
1908 },
1909 &mut |label| {
1910 emit(RenderEvent::Step {
1911 index: completed,
1912 total: plan.passes,
1913 label,
1914 })
1915 },
1916 )
1917 })();
1918 match result {
1919 Ok(atlas) => {
1920 let identity = crate::export_set::complete_identity_json(&render_doc)?;
1921 // In-progress completion marker only; removed again by
1922 // strip_bookkeeping once the .ppda (which embeds the export
1923 // contract and per-PNG checksums) is verified (Tim t993u/t994u).
1924 write_json(
1925 &root,
1926 crate::resume::COMPLETE_MARKER,
1927 serde_json::json!({
1928 "format_version": crate::export_set::OUTPUT_META_VERSION,
1929 "status": "complete",
1930 "identity": identity,
1931 "passes": completed,
1932 "png_files": plan.files,
1933 "png_sha256": png_sha256,
1934 "atlas": atlas.to_json(),
1935 "depth_clamped": {
1936 "views": clamped_views,
1937 "highres_pixels": clamped_pixels,
1938 "note": "rendered this attempt; kept files from a resumed run are not re-counted",
1939 },
1940 }),
1941 )?;
1942 if clamped_views > 0 {
2247 Ok(())
2248 };
2249
2250 for (index, sample) in samples(&plan).enumerate() {
2251 let clip = &plan.clips[sample.clip];
2252 let frame = sample.frame;
2253 let pitch = sample.pitch;
2254 let yaw = sample.yaw;
2255 let paths = planned[index * 4..index * 4 + 4].to_vec();
2256 if cancel.load(Ordering::Relaxed) {
2257 return Err("geannuleerd".into());
2258 }
2259 let pose = format!(
2260 "{} {set} · {} f{frame}/{} · p{pitch} y{yaw}",
2261 job.sex, clip.source_name, clip.frames
2262 );
2263 if paths.iter().all(|p| verified.contains_key(p)) {
2264 completed += PASSES.len() as u32;
1943 2265 emit(RenderEvent::Step {
1944 2266 index: completed,
1945 2267 total: plan.passes,
1946 label: format!(
1947 "Waarschuwing: diepte geklemd in {clamped_views} beelden ({clamped_pixels} highres-pixels buiten het vaste bereik)"
1948 ),
2268 label: format!("{pose} · al aanwezig, checksums kloppen"),
1949 2269 });
2270 continue;
1950 2271 }
1951 // Verify the finished build before it replaces the last good result.
1952 crate::atlas::AtlasSet::open_run(&root)
1953 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
1954 crate::export_set::ExportRun::open(&root)
1955 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
1956 drop(session);
1957 // Lock, journal and resume bookkeeping stay out of the result folder.
1958 crate::resume::strip_bookkeeping(&root)?;
1959 let final_dir = crate::resume::promote(
1960 &job.output,
1961 &root,
1962 &crate::resume::result_dir_name(&job.sex, &set),
1963 )?;
1964 emit(RenderEvent::Finished {
1965 saved: Some(final_dir.display().to_string()),
1966 });
1967 Ok(())
1968 }
1969 Err(error) => {
1970 let name = if cancel.load(Ordering::Relaxed) {
1971 "cancelled.json"
2272 // Backpressure: keep at most GPU_PIPELINE_DEPTH views waiting on the GPU.
2273 while inflight.len() >= GPU_PIPELINE_DEPTH {
2274 let item = inflight.pop_front().unwrap();
2275 drain_one(
2276 item,
2277 &mut completed,
2278 &mut clamped_views,
2279 &mut clamped_pixels,
2280 &mut atlas_cells,
2281 &io,
2282 &job,
2283 &render_doc,
2284 plan.passes,
2285 cancel.as_ref(),
2286 emit,
2287 )?;
2288 }
2289 let key = if job.character.clips.is_empty() {
2290 String::new()
1972 2291 } else {
1973 "failed.json"
2292 format!("render-clip-{}", sample.clip)
1974 2293 };
1975 let _ = write_json(
1976 &root,
1977 name,
1978 serde_json::json!({
1979 "error": error,
1980 "completed_passes": completed,
1981 "total_passes": plan.passes,
1982 "png_files_done": png_sha256.len(),
1983 "png_files": plan.files,
1984 "attempt": session.attempt,
1985 }),
1986 );
1987 Err(error)
2294 emit(RenderEvent::Step {
2295 index: completed,
2296 total: plan.passes,
2297 label: format!("{pose} · highres"),
2298 });
2299 let scene = scene(
2300 &job,
2301 &key,
2302 frame as f32 / job.fps as f32,
2303 pitch as f32,
2304 yaw as f32,
2305 &mut cache,
2306 )?;
2307 let (tx, rx) = mpsc::sync_channel(1);
2308 gpu.send(GpuRequest { scene, reply: tx })
2309 .map_err(|_| "GPU-service gesloten")?;
2310 if let Some(ctx) = ctx {
2311 ctx.request_repaint();
2312 }
2313 inflight.push_back(InflightGpu { rx, paths, pose });
2314 }
2315 while let Some(item) = inflight.pop_front() {
2316 drain_one(
2317 item,
2318 &mut completed,
2319 &mut clamped_views,
2320 &mut clamped_pixels,
2321 &mut atlas_cells,
2322 &io,
2323 &job,
2324 &render_doc,
2325 plan.passes,
2326 cancel.as_ref(),
2327 emit,
2328 )?;
1988 2329 }
2330 Ok(())
2331 })();
2332 // Always drain the IO queue so cancel keeps journaled PNGs (Resume).
2333 let io_finish = io.take().map(|q| q.finish());
2334 let (png_sha256, io_error) = match io_finish {
2335 Some(Ok((_session, digests))) => (digests, None),
2336 Some(Err(e)) => (BTreeMap::new(), Some(e)),
2337 None => (BTreeMap::new(), Some("IO-queue ontbreekt".into())),
2338 };
2339 if let Err(error) = work {
2340 let name = if cancel.load(Ordering::Relaxed) {
2341 "cancelled.json"
2342 } else {
2343 "failed.json"
2344 };
2345 let _ = write_json(
2346 &root,
2347 name,
2348 serde_json::json!({
2349 "error": error,
2350 "completed_passes": completed,
2351 "total_passes": plan.passes,
2352 "png_files_done": png_sha256.len(),
2353 "png_files": plan.files,
2354 "attempt": attempt,
2355 }),
2356 );
2357 return Err(error);
2358 }
2359 if let Some(e) = io_error {
2360 let _ = write_json(
2361 &root,
2362 "failed.json",
2363 serde_json::json!({
2364 "error": e,
2365 "completed_passes": completed,
2366 "total_passes": plan.passes,
2367 "png_files_done": png_sha256.len(),
2368 "png_files": plan.files,
2369 "attempt": attempt,
2370 }),
2371 );
2372 return Err(e);
2373 }
2374 if png_sha256.len() as u32 != plan.files {
2375 let error = format!(
2376 "checksumtelling {} wijkt af van geplande {} PNGs",
2377 png_sha256.len(),
2378 plan.files
2379 );
2380 let _ = write_json(
2381 &root,
2382 "failed.json",
2383 serde_json::json!({
2384 "error": error,
2385 "completed_passes": completed,
2386 "total_passes": plan.passes,
2387 "png_files_done": png_sha256.len(),
2388 "png_files": plan.files,
2389 "attempt": attempt,
2390 }),
2391 );
2392 return Err(error);
2393 }
2394 let atlas = crate::atlas::write_atlases(
2395 &crate::atlas::AtlasJob {
2396 root: &root,
2397 render_doc: &render_doc,
2398 plan: &plan,
2399 sex: &job.sex,
2400 set: &set,
2401 cell_width: job.target_width,
2402 cell_height: job.target_height,
2403 png_sha256: &png_sha256,
2404 attempt,
2405 cancel: cancel.as_ref(),
2406 trim: job.atlas_trim,
2407 cells: Some(&atlas_cells),
2408 },
2409 &mut |label| {
2410 emit(RenderEvent::Step {
2411 index: completed,
2412 total: plan.passes,
2413 label,
2414 })
2415 },
2416 )?;
2417 let identity = crate::export_set::complete_identity_json(&render_doc)?;
2418 // In-progress completion marker only; removed again by
2419 // strip_bookkeeping once the .ppda (which embeds the export
2420 // contract and per-PNG checksums) is verified (Tim t993u/t994u).
2421 write_json(
2422 &root,
2423 crate::resume::COMPLETE_MARKER,
2424 serde_json::json!({
2425 "format_version": crate::export_set::OUTPUT_META_VERSION,
2426 "status": "complete",
2427 "identity": identity,
2428 "passes": completed,
2429 "png_files": plan.files,
2430 "png_sha256": png_sha256,
2431 "atlas": atlas.to_json(),
2432 "depth_clamped": {
2433 "views": clamped_views,
2434 "highres_pixels": clamped_pixels,
2435 "note": "rendered this attempt; kept files from a resumed run are not re-counted",
2436 },
2437 }),
2438 )?;
2439 if clamped_views > 0 {
2440 emit(RenderEvent::Step {
2441 index: completed,
2442 total: plan.passes,
2443 label: format!(
2444 "Waarschuwing: diepte geklemd in {clamped_views} beelden ({clamped_pixels} highres-pixels buiten het vaste bereik)"
2445 ),
2446 });
1989 2447 }
2448 // Verify the finished build before it replaces the last good result.
2449 crate::atlas::AtlasSet::open_run(&root)
2450 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
2451 crate::export_set::ExportRun::open(&root)
2452 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
2453 // Lock, journal and resume bookkeeping stay out of the result folder.
2454 crate::resume::strip_bookkeeping(&root)?;
2455 let final_dir = crate::resume::promote(
2456 &job.output,
2457 &root,
2458 &crate::resume::result_dir_name(&job.sex, &set),
2459 )?;
2460 emit(RenderEvent::Finished {
2461 saved: Some(final_dir.display().to_string()),
2462 });
2463 Ok(())
1990 2464 }
1991 2465
1992 2466 /// Real readback regression: nearest-surface depth/normal/specular, preserved
@@ -2402,6 +2876,62 @@ mod tests {
2402 2876 .is_err());
2403 2877 assert!(!outside.0.join("sub").exists());
2404 2878 }
2879 #[test]
2880 fn preview_slot_latest_wins_without_blocking_on_slow_consumer() {
2881 use std::time::{Duration, Instant};
2882 let slot = PreviewSlot::default();
2883 let slot_c = slot.clone();
2884 // Slow consumer: takes frames then sleeps *outside* the slot lock.
2885 let consumer = std::thread::spawn(move || {
2886 let mut seen = 0u64;
2887 let mut last_rev = 0u64;
2888 let end = Instant::now() + Duration::from_millis(200);
2889 while Instant::now() < end {
2890 if let Some(frame) = slot_c.take() {
2891 seen += 1;
2892 last_rev = frame.revision;
2893 std::thread::sleep(Duration::from_millis(5));
2894 } else {
2895 std::thread::sleep(Duration::from_millis(1));
2896 }
2897 }
2898 (seen, last_rev)
2899 });
2900 let t0 = Instant::now();
2901 let n = 2_000u64;
2902 for i in 0..n {
2903 slot.publish(
2904 ImageBuf {
2905 width: 1,
2906 height: 1,
2907 rgba: vec![i as u8, 0, 0, 255],
2908 },
2909 format!("frame-{i}"),
2910 );
2911 }
2912 let producer = t0.elapsed();
2913 let (seen, last_rev) = consumer.join().unwrap();
2914 // Producer must stay well under the consumer's cumulative sleep budget
2915 // (2000 * 5ms would be 10s if it waited on each frame).
2916 assert!(
2917 producer < Duration::from_millis(500),
2918 "producer blocked on preview consumer: {producer:?}"
2919 );
2920 assert!(seen >= 1, "consumer saw no frames");
2921 assert!(
2922 seen < n,
2923 "consumer must be allowed to skip frames (saw {seen}/{n})"
2924 );
2925 // Latest-wins: if a frame remains, it must be the final publish. The
2926 // slow consumer may exit before taking it, or may have taken it already.
2927 if let Some(frame) = slot.take() {
2928 assert_eq!(frame.revision, n);
2929 assert_eq!(frame.label, format!("frame-{}", n - 1));
2930 }
2931 assert!(last_rev >= 1 && last_rev <= n, "last_rev={last_rev}");
2932 let _ = last_rev;
2933 }
2934
2405 2935 #[test]
2406 2936 fn latest_mailbox_is_bounded_and_retains_terminal() {
2407 2937 let m = Mailbox::default();
@@ -2619,7 +3149,7 @@ mod tests {
2619 3149 },
2620 3150 preset_sha256: None,
2621 3151 };
2622 let flag = AtomicBool::new(false);
3152 let flag = Arc::new(AtomicBool::new(false));
2623 3153 let (tx, rx) = mpsc::channel::<GpuRequest>();
2624 3154 let gpu = std::thread::spawn(move || {
2625 3155 for request in rx {
@@ -2815,7 +3345,7 @@ mod tests {
2815 3345 target: RunTarget,
2816 3346 stop: Option<usize>,
2817 3347 ) -> (Result<(), String>, Vec<RenderEvent>) {
2818 let flag = AtomicBool::new(false);
3348 let flag = Arc::new(AtomicBool::new(false));
2819 3349 let (tx, rx) = mpsc::channel::<GpuRequest>();
2820 3350 let gpu = std::thread::spawn(move || {
2821 3351 for request in rx {
@@ -1,14 +1,18 @@
1//! Bounded orthographic production rendering. GPU work runs on the native
1//! Bounded orthographic production rendering. GPU work runs on the native
2//! current context, one request per UI update; sampling/filtering/PNG I/O run
2//! current context with a PBO readback ring (several views in flight); CPU
3//! on the worker. The worker never waits for a preview acknowledgment.
3//! resolve runs on the worker; PNG encode/write runs on a bounded IO queue.
4//! Live preview pixels go through a latest-wins [`PreviewSlot`] — the worker
5//! never waits for texture upload or UI acknowledgment (Tim t1063u/t1064u).
4use crate::{
6use crate::{
5 equipment,
7 equipment,
8 io_queue::{EncodedMap, IoQueue, ViewPublish},
6 preview::{self, Mat4, PreviewCharacter, PreviewControls},
9 preview::{self, Mat4, PreviewCharacter, PreviewControls},
7 project::CameraFraming,
10 project::CameraFraming,
8 render_job::{ImageBuf, RenderEvent},
11 render_job::{ImageBuf, RenderEvent},
9};
12};
10use glow::HasContext;
13use glow::HasContext;
11use std::{
14use std::{
15 collections::{BTreeMap, VecDeque},
12 path::{Path, PathBuf},
16 path::{Path, PathBuf},
13 sync::{
17 sync::{
14 atomic::{AtomicBool, Ordering},
18 atomic::{AtomicBool, Ordering},
@@ -27,6 +31,8 @@ pub const PASSES: [&str; 6] = [
27 "normal",
31 "normal",
28 "specular",
32 "specular",
29];
33];
34/// GPU readback ring depth (PBO + fence). Keep 2–3 views in flight.
35pub const GPU_PIPELINE_DEPTH: usize = 3;
30#[derive(Clone)]
36#[derive(Clone)]
31pub struct Draw {
37pub struct Draw {
32 pub positions: Arc<Vec<[f32; 3]>>,
38 pub positions: Arc<Vec<[f32; 3]>>,
@@ -445,7 +451,29 @@ impl Scene {
445 }
451 }
446}
452}
447
453
448#[derive(Default)]
454struct PendingReadback {
455 reply: mpsc::SyncSender<Result<Buffers, String>>,
456 width: u32,
457 height: u32,
458 slot: usize,
459 fence: Option<glow::NativeFence>,
460}
461
462struct ReadbackSlot {
463 /// RGBA8 × 3 + depth f32 pack buffers (PIXEL_PACK).
464 pbos: [Option<glow::Buffer>; 4],
465 bytes: [usize; 4],
466}
467
468impl Default for ReadbackSlot {
469 fn default() -> Self {
470 Self {
471 pbos: [None, None, None, None],
472 bytes: [0; 4],
473 }
474 }
475}
476
449pub struct GpuRenderer {
477pub struct GpuRenderer {
450 program: Option<glow::Program>,
478 program: Option<glow::Program>,
451 vao: Option<glow::VertexArray>,
479 vao: Option<glow::VertexArray>,
@@ -455,11 +483,39 @@ pub struct GpuRenderer {
455 colors: Vec<glow::Texture>,
483 colors: Vec<glow::Texture>,
456 depth: Option<glow::Renderbuffer>,
484 depth: Option<glow::Renderbuffer>,
457 extent: [u32; 2],
485 extent: [u32; 2],
486 /// Async readback ring (Tim t1063u).
487 readback: [ReadbackSlot; GPU_PIPELINE_DEPTH],
488 pending: VecDeque<PendingReadback>,
489 next_slot: usize,
490 /// When false, fall back to synchronous read_pixels.
491 pbo_ok: bool,
458}
492}
493
494impl Default for GpuRenderer {
495 fn default() -> Self {
496 Self {
497 program: None,
498 vao: None,
499 vbo: None,
500 ebo: None,
501 fbo: None,
502 colors: Vec::new(),
503 depth: None,
504 extent: [0, 0],
505 readback: std::array::from_fn(|_| ReadbackSlot::default()),
506 pending: VecDeque::new(),
507 next_slot: 0,
508 pbo_ok: true,
509 }
510 }
511}
512
459impl GpuRenderer {
513impl GpuRenderer {
460 /// # Safety
514 /// # Safety
461 /// A native OpenGL 3.3 context must be current on this thread.
515 /// A native OpenGL 3.3 context must be current on this thread.
462 pub unsafe fn destroy(&mut self, gl: &glow::Context) {
516 pub unsafe fn destroy(&mut self, gl: &glow::Context) {
517 let _ = self.poll_readbacks(gl, true);
518 self.destroy_readback(gl);
463 self.destroy_target(gl);
519 self.destroy_target(gl);
464 if let Some(x) = self.program.take() {
520 if let Some(x) = self.program.take() {
465 gl.delete_program(x);
521 gl.delete_program(x);
@@ -474,6 +530,25 @@ impl GpuRenderer {
474 gl.delete_buffer(x);
530 gl.delete_buffer(x);
475 }
531 }
476 }
532 }
533
534 unsafe fn destroy_readback(&mut self, gl: &glow::Context) {
535 while let Some(p) = self.pending.pop_front() {
536 if let Some(f) = p.fence {
537 gl.delete_sync(f);
538 }
539 let _ = p.reply.send(Err("GPU-renderer gestopt".into()));
540 }
541 for slot in &mut self.readback {
542 for b in slot.pbos.iter_mut() {
543 if let Some(buf) = b.take() {
544 gl.delete_buffer(buf);
545 }
546 }
547 slot.bytes = [0; 4];
548 }
549 self.next_slot = 0;
550 }
551
477 unsafe fn destroy_target(&mut self, gl: &glow::Context) {
552 unsafe fn destroy_target(&mut self, gl: &glow::Context) {
478 for x in self.colors.drain(..) {
553 for x in self.colors.drain(..) {
479 gl.delete_texture(x);
554 gl.delete_texture(x);
@@ -486,6 +561,7 @@ impl GpuRenderer {
486 }
561 }
487 self.extent = [0, 0];
562 self.extent = [0, 0];
488 }
563 }
564
489 unsafe fn init(&mut self, gl: &glow::Context) -> Result<(), String> {
565 unsafe fn init(&mut self, gl: &glow::Context) -> Result<(), String> {
490 if self.program.is_some() {
566 if self.program.is_some() {
491 return Ok(());
567 return Ok(());
@@ -531,10 +607,13 @@ impl GpuRenderer {
531 self.ebo = Some(gl.create_buffer()?);
607 self.ebo = Some(gl.create_buffer()?);
532 Ok(())
608 Ok(())
533 }
609 }
610
534 unsafe fn target(&mut self, gl: &glow::Context, w: u32, h: u32) -> Result<(), String> {
611 unsafe fn target(&mut self, gl: &glow::Context, w: u32, h: u32) -> Result<(), String> {
535 if self.extent == [w, h] {
612 if self.extent == [w, h] {
536 return Ok(());
613 return Ok(());
537 }
614 }
615 // Size change invalidates in-flight PBOs for the old extent.
616 let _ = self.poll_readbacks(gl, true);
538 self.destroy_target(gl);
617 self.destroy_target(gl);
539 self.fbo = Some(gl.create_framebuffer()?);
618 self.fbo = Some(gl.create_framebuffer()?);
540 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
619 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
@@ -597,11 +676,30 @@ impl GpuRenderer {
597 self.extent = [w, h];
676 self.extent = [w, h];
598 Ok(())
677 Ok(())
599 }
678 }
600 /// Render and read back unlit color, view-space normals, scalar specular and
679
601 /// the actual depth attachment. All four use the same winning surface.
680 unsafe fn ensure_pbo(&mut self, gl: &glow::Context, slot: usize, w: u32, h: u32) -> Result<(), String> {
602 /// # Safety
681 let pixels = w as usize * h as usize;
603 /// `gl` is current on the UI thread. GL state is reset for egui painting.
682 let sizes = [pixels * 4, pixels * 4, pixels * 4, pixels * 4];
604 pub unsafe fn render(&mut self, gl: &glow::Context, scene: &Scene) -> Result<Buffers, String> {
683 let ring = &mut self.readback[slot];
684 for i in 0..4 {
685 if ring.bytes[i] == sizes[i] && ring.pbos[i].is_some() {
686 continue;
687 }
688 if let Some(old) = ring.pbos[i].take() {
689 gl.delete_buffer(old);
690 }
691 let buf = gl.create_buffer().map_err(|e| e.to_string())?;
692 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, Some(buf));
693 gl.buffer_data_size(glow::PIXEL_PACK_BUFFER, sizes[i] as i32, glow::STREAM_READ);
694 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None);
695 ring.pbos[i] = Some(buf);
696 ring.bytes[i] = sizes[i];
697 }
698 Ok(())
699 }
700
701 /// Draw the scene into the production FBO. Leaves the FBO bound and GL draw state set.
702 unsafe fn draw_scene(&mut self, gl: &glow::Context, scene: &Scene) -> Result<(), String> {
605 if scene.width == 0
703 if scene.width == 0
606 || scene.height == 0
704 || scene.height == 0
607 || scene.width > MAX_HIGHRES
705 || scene.width > MAX_HIGHRES
@@ -610,259 +708,484 @@ impl GpuRenderer {
610 return Err("highres moet 1–2048 pixels zijn (8× target)".into());
708 return Err("highres moet 1–2048 pixels zijn (8× target)".into());
611 }
709 }
612 scene.validate()?;
710 scene.validate()?;
613 let previous = gl.get_parameter_i32(glow::FRAMEBUFFER_BINDING);
711 self.init(gl)?;
614 let mut vp = [0; 4];
712 self.target(gl, scene.width, scene.height)?;
615 gl.get_parameter_i32_slice(glow::VIEWPORT, &mut vp);
713 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
616 let result = (|| {
714 gl.draw_buffers(&[
617 self.init(gl)?;
715 glow::COLOR_ATTACHMENT0,
618 self.target(gl, scene.width, scene.height)?;
716 glow::COLOR_ATTACHMENT1,
619 gl.bind_framebuffer(glow::FRAMEBUFFER, self.fbo);
717 glow::COLOR_ATTACHMENT2,
620 gl.draw_buffers(&[
718 ]);
621 glow::COLOR_ATTACHMENT0,
719 gl.viewport(0, 0, scene.width as i32, scene.height as i32);
622 glow::COLOR_ATTACHMENT1,
720 gl.disable(glow::SCISSOR_TEST);
623 glow::COLOR_ATTACHMENT2,
721 gl.disable(glow::CULL_FACE);
624 ]);
722 gl.disable(glow::BLEND);
625 gl.viewport(0, 0, scene.width as i32, scene.height as i32);
723 gl.enable(glow::DEPTH_TEST);
626 gl.disable(glow::SCISSOR_TEST);
724 gl.depth_mask(true);
627 gl.disable(glow::CULL_FACE);
725 gl.depth_func(glow::LESS);
628 gl.disable(glow::BLEND);
726 // Geometry outside the fixed per-sex interval is clamped, not clipped
629 gl.enable(glow::DEPTH_TEST);
727 // away; production counts and reports clamped coverage.
630 gl.depth_mask(true);
728 gl.enable(glow::DEPTH_CLAMP);
631 gl.depth_func(glow::LESS);
729 gl.clear_depth_f32(1.);
632 // Geometry outside the fixed per-sex interval is clamped, not clipped
730 gl.clear_color(0., 0., 0., 0.);
633 // away; production counts and reports clamped coverage.
731 gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
634 gl.enable(glow::DEPTH_CLAMP);
732 let p = self.program.ok_or("shader ontbreekt")?;
635 gl.clear_depth_f32(1.);
733 gl.use_program(Some(p));
636 gl.clear_color(0., 0., 0., 0.);
734 gl.uniform_matrix_4_f32_slice(
637 gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
735 gl.get_uniform_location(p, "u_view").as_ref(),
638 let p = self.program.ok_or("shader ontbreekt")?;
736 false,
639 gl.use_program(Some(p));
737 &scene.view,
640 gl.uniform_matrix_4_f32_slice(
738 );
641 gl.get_uniform_location(p, "u_view").as_ref(),
739 gl.uniform_matrix_4_f32_slice(
642 false,
740 gl.get_uniform_location(p, "u_projection").as_ref(),
643 &scene.view,
741 false,
644 );
742 &scene.projection,
645 gl.uniform_matrix_4_f32_slice(
743 );
646 gl.get_uniform_location(p, "u_projection").as_ref(),
744 gl.bind_vertex_array(self.vao);
647 false,
745 gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
648 &scene.projection,
746 gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);
649 );
747 for (slot, size, offset) in [(0, 3, 0), (1, 3, 3), (2, 2, 6), (3, 4, 8)] {
650 gl.bind_vertex_array(self.vao);
748 gl.enable_vertex_attrib_array(slot);
651 gl.bind_buffer(glow::ARRAY_BUFFER, self.vbo);
749 gl.vertex_attrib_pointer_f32(slot, size, glow::FLOAT, false, 12 * 4, offset * 4);
652 gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, self.ebo);
750 }
653 for (slot, size, offset) in [(0, 3, 0), (1, 3, 3), (2, 2, 6), (3, 4, 8)] {
751 // glTF BLEND (design.md t1016u): opaque/mask first, then the
654 gl.enable_vertex_attrib_array(slot);
752 // BLEND draws blended without depth writes, then their depth only.
655 gl.vertex_attrib_pointer_f32(slot, size, glow::FLOAT, false, 12 * 4, offset * 4);
753 let order: Vec<(&Draw, u8)> = scene
656 }
754 .draws
657 // glTF BLEND (design.md t1016u): opaque/mask first, then the
755 .iter()
658 // BLEND draws blended without depth writes, then their depth only.
756 .filter(|d| !d.blend)
659 let order: Vec<(&Draw, u8)> = scene
757 .map(|d| (d, 0))
660 .draws
758 .chain(scene.draws.iter().filter(|d| d.blend).map(|d| (d, 1)))
661 .iter()
759 .chain(scene.draws.iter().filter(|d| d.blend).map(|d| (d, 2)))
662 .filter(|d| !d.blend)
760 .collect();
663 .map(|d| (d, 0))
761 for (draw, pass) in order {
664 .chain(scene.draws.iter().filter(|d| d.blend).map(|d| (d, 1)))
762 match pass {
665 .chain(scene.draws.iter().filter(|d| d.blend).map(|d| (d, 2)))
763 0 => {
666 .collect();
764 gl.disable(glow::BLEND);
667 for (draw, pass) in order {
765 gl.depth_mask(true);
668 match pass {
766 gl.color_mask(true, true, true, true);
669 0 => {
670 gl.disable(glow::BLEND);
671 gl.depth_mask(true);
672 gl.color_mask(true, true, true, true);
673 }
674 1 => {
675 gl.enable(glow::BLEND);
676 gl.blend_func_separate(
677 glow::SRC_ALPHA,
678 glow::ONE_MINUS_SRC_ALPHA,
679 glow::ONE,
680 glow::ONE_MINUS_SRC_ALPHA,
681 );
682 gl.depth_mask(false);
683 gl.color_mask(true, true, true, true);
684 }
685 _ => {
686 gl.disable(glow::BLEND);
687 gl.depth_mask(true);
688 gl.color_mask(false, false, false, false);
689 }
690 }
767 }
691 if draw.positions.len() != draw.normals.len()
768 1 => {
692 || draw
769 gl.enable(glow::BLEND);
693 .indices
770 gl.blend_func_separate(
694 .iter()
771 glow::SRC_ALPHA,
695 .any(|i| *i as usize >= draw.positions.len())
772 glow::ONE_MINUS_SRC_ALPHA,
696 || draw.indices.len() > i32::MAX as usize
773 glow::ONE,
697 {
774 glow::ONE_MINUS_SRC_ALPHA,
698 return Err("ongeldige GPU-geometrie".into());
775 );
776 gl.depth_mask(false);
777 gl.color_mask(true, true, true, true);
699 }
778 }
700 let mut data = Vec::with_capacity(draw.positions.len() * 12);
779 _ => {
701 for (i, pos) in draw.positions.iter().enumerate() {
780 gl.disable(glow::BLEND);
702 data.extend_from_slice(pos);
781 gl.depth_mask(true);
703 data.extend_from_slice(&draw.normals[i]);
782 gl.color_mask(false, false, false, false);
704 data.extend_from_slice(&draw.uvs.get(i).copied().unwrap_or([0.; 2]));
705 data.extend_from_slice(&draw.vertex_colors.get(i).copied().unwrap_or([1.; 4]));
706 }
783 }
707 gl.buffer_data_u8_slice(
784 }
708 glow::ARRAY_BUFFER,
785 if draw.positions.len() != draw.normals.len()
709 std::slice::from_raw_parts(data.as_ptr().cast(), data.len() * 4),
786 || draw
710 glow::STREAM_DRAW,
787 .indices
711 );
788 .iter()
712 gl.buffer_data_u8_slice(
789 .any(|i| *i as usize >= draw.positions.len())
713 glow::ELEMENT_ARRAY_BUFFER,
790 || draw.indices.len() > i32::MAX as usize
714 std::slice::from_raw_parts(
791 {
715 draw.indices.as_ptr().cast(),
792 return Err("ongeldige GPU-geometrie".into());
716 draw.indices.len() * 4,
793 }
717 ),
794 let mut data = Vec::with_capacity(draw.positions.len() * 12);
718 glow::STREAM_DRAW,
795 for (i, pos) in draw.positions.iter().enumerate() {
719 );
796 data.extend_from_slice(pos);
720 gl.uniform_4_f32(
797 data.extend_from_slice(&draw.normals[i]);
721 gl.get_uniform_location(p, "u_color").as_ref(),
798 data.extend_from_slice(&draw.uvs.get(i).copied().unwrap_or([0.; 2]));
722 draw.color[0],
799 data.extend_from_slice(&draw.vertex_colors.get(i).copied().unwrap_or([1.; 4]));
723 draw.color[1],
800 }
724 draw.color[2],
801 gl.buffer_data_u8_slice(
725 draw.color[3],
802 glow::ARRAY_BUFFER,
726 );
803 std::slice::from_raw_parts(data.as_ptr().cast(), data.len() * 4),
727 gl.uniform_1_f32(
804 glow::STREAM_DRAW,
728 gl.get_uniform_location(p, "u_specular").as_ref(),
805 );
729 draw.specular,
806 gl.buffer_data_u8_slice(
730 );
807 glow::ELEMENT_ARRAY_BUFFER,
731 gl.uniform_1_f32(gl.get_uniform_location(p, "u_cutoff").as_ref(), draw.cutoff);
808 std::slice::from_raw_parts(
809 draw.indices.as_ptr().cast(),
810 draw.indices.len() * 4,
811 ),
812 glow::STREAM_DRAW,
813 );
814 gl.uniform_4_f32(
815 gl.get_uniform_location(p, "u_color").as_ref(),
816 draw.color[0],
817 draw.color[1],
818 draw.color[2],
819 draw.color[3],
820 );
821 gl.uniform_1_f32(
822 gl.get_uniform_location(p, "u_specular").as_ref(),
823 draw.specular,
824 );
825 gl.uniform_1_f32(gl.get_uniform_location(p, "u_cutoff").as_ref(), draw.cutoff);
826 gl.uniform_1_i32(
827 gl.get_uniform_location(p, "u_flat").as_ref(),
828 i32::from(draw.flat_normals),
829 );
830 let mut temp = Vec::new();
831 for (unit, (name, tex)) in [
832 ("u_albedo", &draw.albedo),
833 ("u_shiny", &draw.shiny),
834 ]
835 .into_iter()
836 .enumerate()
837 {
838 gl.active_texture(glow::TEXTURE0 + unit as u32);
839 gl.uniform_1_i32(gl.get_uniform_location(p, name).as_ref(), unit as i32);
732 gl.uniform_1_i32(
840 gl.uniform_1_i32(
733 gl.get_uniform_location(p, "u_flat").as_ref(),
841 gl.get_uniform_location(p, &format!("{name}_enabled"))
734 i32::from(draw.flat_normals),
842 .as_ref(),
843 i32::from(tex.is_some()),
735 );
844 );
736 let mut temp = Vec::new();
845 if let Some(tex) = tex {
737 for (unit, name, texture) in [
846 let t = gl.create_texture()?;
738 (0, "u_albedo", draw.albedo.as_ref()),
847 temp.push(t);
739 (1, "u_shiny", draw.shiny.as_ref()),
848 gl.bind_texture(glow::TEXTURE_2D, Some(t));
740 ] {
849 gl.tex_parameter_i32(
741 gl.active_texture(glow::TEXTURE0 + unit);
850 glow::TEXTURE_2D,
742 gl.uniform_1_i32(gl.get_uniform_location(p, name).as_ref(), unit as i32);
851 glow::TEXTURE_MIN_FILTER,
743 gl.uniform_1_i32(
852 glow::NEAREST as i32,
744 gl.get_uniform_location(p, &format!("{name}_enabled"))
745 .as_ref(),
746 i32::from(texture.is_some()),
747 );
853 );
748 if let Some(image) = texture {
854 gl.tex_parameter_i32(
749 let t = gl.create_texture()?;
855 glow::TEXTURE_2D,
750 temp.push(t);
856 glow::TEXTURE_MAG_FILTER,
751 gl.bind_texture(glow::TEXTURE_2D, Some(t));
857 glow::NEAREST as i32,
752 gl.tex_parameter_i32(
858 );
753 glow::TEXTURE_2D,
859 gl.tex_parameter_i32(
754 glow::TEXTURE_MIN_FILTER,
860 glow::TEXTURE_2D,
755 glow::NEAREST as i32,
861 glow::TEXTURE_WRAP_S,
756 );
862 glow::REPEAT as i32,
757 gl.tex_parameter_i32(
863 );
758 glow::TEXTURE_2D,
864 gl.tex_parameter_i32(
759 glow::TEXTURE_MAG_FILTER,
865 glow::TEXTURE_2D,
760 glow::NEAREST as i32,
866 glow::TEXTURE_WRAP_T,
761 );
867 glow::REPEAT as i32,
762 gl.tex_parameter_i32(
868 );
763 glow::TEXTURE_2D,
869 gl.tex_image_2d(
764 glow::TEXTURE_WRAP_S,
870 glow::TEXTURE_2D,
765 glow::REPEAT as i32,
871 0,
766 );
872 glow::RGBA8 as i32,
767 gl.tex_parameter_i32(
873 tex.width as i32,
768 glow::TEXTURE_2D,
874 tex.height as i32,
769 glow::TEXTURE_WRAP_T,
875 0,
770 glow::REPEAT as i32,
876 glow::RGBA,
771 );
877 glow::UNSIGNED_BYTE,
772 gl.tex_image_2d(
878 Some(&tex.rgba),
773 glow::TEXTURE_2D,
879 );
774 0,
880 } else {
775 if unit == 0 {
881 gl.bind_texture(glow::TEXTURE_2D, None);
776 glow::SRGB8_ALPHA8 as i32
777 } else {
778 glow::RGBA8 as i32
779 },
780 image.width as i32,
781 image.height as i32,
782 0,
783 glow::RGBA,
784 glow::UNSIGNED_BYTE,
785 Some(&image.rgba),
786 );
787 } else {
788 gl.bind_texture(glow::TEXTURE_2D, None);
789 }
790 }
791 gl.draw_elements(
792 glow::TRIANGLES,
793 draw.indices.len() as i32,
794 glow::UNSIGNED_INT,
795 0,
796 );
797 for t in temp {
798 gl.delete_texture(t);
799 }
882 }
800 }
883 }
801 gl.disable(glow::BLEND);
884 gl.draw_elements(
802 gl.depth_mask(true);
885 glow::TRIANGLES,
803 gl.color_mask(true, true, true, true);
886 draw.indices.len() as i32,
804 let pixels = scene.width as usize * scene.height as usize;
887 glow::UNSIGNED_INT,
805 let mut maps = Vec::new();
888 0,
806 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
889 );
807 for slot in 0..3 {
890 for t in temp {
808 let mut data = vec![0; pixels * 4];
891 gl.delete_texture(t);
809 gl.read_buffer(glow::COLOR_ATTACHMENT0 + slot);
810 gl.read_pixels(
811 0,
812 0,
813 scene.width as i32,
814 scene.height as i32,
815 glow::RGBA,
816 glow::UNSIGNED_BYTE,
817 glow::PixelPackData::Slice(&mut data),
818 );
819 flip_rows(&mut data, scene.width as usize * 4, scene.height as usize);
820 maps.push(data);
821 }
892 }
822 let mut depth = vec![1f32; pixels];
893 }
823 let bytes = std::slice::from_raw_parts_mut(depth.as_mut_ptr().cast(), pixels * 4);
894 gl.disable(glow::BLEND);
895 gl.depth_mask(true);
896 gl.color_mask(true, true, true, true);
897 Ok(())
898 }
899
900 unsafe fn readback_into_slice(
901 gl: &glow::Context,
902 w: u32,
903 h: u32,
904 ) -> Result<Buffers, String> {
905 let pixels = w as usize * h as usize;
906 let mut maps = Vec::new();
907 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
908 for slot in 0..3 {
909 let mut data = vec![0; pixels * 4];
910 gl.read_buffer(glow::COLOR_ATTACHMENT0 + slot);
824 gl.read_pixels(
911 gl.read_pixels(
825 0,
912 0,
826 0,
913 0,
827 scene.width as i32,
914 w as i32,
828 scene.height as i32,
915 h as i32,
829 glow::DEPTH_COMPONENT,
916 glow::RGBA,
830 glow::FLOAT,
917 glow::UNSIGNED_BYTE,
831 glow::PixelPackData::Slice(bytes),
918 glow::PixelPackData::Slice(&mut data),
832 );
919 );
833 flip_rows(&mut depth, scene.width as usize, scene.height as usize);
920 flip_rows(&mut data, w as usize * 4, h as usize);
834 let error = gl.get_error();
921 maps.push(data);
835 if error != glow::NO_ERROR {
922 }
836 return Err(format!("productie-GL fout 0x{error:x}"));
923 let mut depth = vec![1f32; pixels];
924 let bytes = std::slice::from_raw_parts_mut(depth.as_mut_ptr().cast(), pixels * 4);
925 gl.read_pixels(
926 0,
927 0,
928 w as i32,
929 h as i32,
930 glow::DEPTH_COMPONENT,
931 glow::FLOAT,
932 glow::PixelPackData::Slice(bytes),
933 );
934 flip_rows(&mut depth, w as usize, h as usize);
935 let error = gl.get_error();
936 if error != glow::NO_ERROR {
937 return Err(format!("productie-GL fout 0x{error:x}"));
938 }
939 let specular = maps.pop().unwrap();
940 let normal = maps.pop().unwrap();
941 let diffuse = maps.pop().unwrap();
942 Ok(Buffers {
943 width: w,
944 height: h,
945 diffuse,
946 normal,
947 specular,
948 depth,
949 })
950 }
951
952 unsafe fn kick_pbo_readback(
953 &mut self,
954 gl: &glow::Context,
955 w: u32,
956 h: u32,
957 reply: mpsc::SyncSender<Result<Buffers, String>>,
958 ) -> Result<(), String> {
959 let slot = self.next_slot % GPU_PIPELINE_DEPTH;
960 self.next_slot = self.next_slot.wrapping_add(1);
961 if let Err(e) = self.ensure_pbo(gl, slot, w, h) {
962 self.pbo_ok = false;
963 // Sync fallback for this view.
964 let buffers = Self::readback_into_slice(gl, w, h);
965 let _ = reply.send(buffers.map_err(|err| format!("{e}; {err}")));
966 return Ok(());
967 }
968 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
969 let ring = &self.readback[slot];
970 for i in 0..3 {
971 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, ring.pbos[i]);
972 gl.read_buffer(glow::COLOR_ATTACHMENT0 + i as u32);
973 gl.read_pixels(
974 0,
975 0,
976 w as i32,
977 h as i32,
978 glow::RGBA,
979 glow::UNSIGNED_BYTE,
980 glow::PixelPackData::BufferOffset(0),
981 );
982 }
983 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, ring.pbos[3]);
984 gl.read_pixels(
985 0,
986 0,
987 w as i32,
988 h as i32,
989 glow::DEPTH_COMPONENT,
990 glow::FLOAT,
991 glow::PixelPackData::BufferOffset(0),
992 );
993 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None);
994 let fence = match gl.fence_sync(glow::SYNC_GPU_COMMANDS_COMPLETE, 0) {
995 Ok(f) => Some(f),
996 Err(_) => {
997 self.pbo_ok = false;
998 let buffers = Self::readback_into_slice(gl, w, h);
999 let _ = reply.send(buffers);
1000 return Ok(());
837 }
1001 }
838 let specular = maps.pop().unwrap();
1002 };
839 let normal = maps.pop().unwrap();
1003 self.pending.push_back(PendingReadback {
840 let diffuse = maps.pop().unwrap();
1004 reply,
841 Ok(Buffers {
1005 width: w,
842 width: scene.width,
1006 height: h,
843 height: scene.height,
1007 slot,
844 diffuse,
1008 fence,
845 normal,
1009 });
846 specular,
1010 Ok(())
847 depth,
1011 }
848 })
1012
849 })();
1013 unsafe fn complete_oldest(&mut self, gl: &glow::Context) -> Result<(), String> {
850 gl.bind_framebuffer(
1014 let Some(mut pending) = self.pending.pop_front() else {
851 glow::FRAMEBUFFER,
1015 return Ok(());
852 std::num::NonZeroU32::new(previous as u32).map(glow::NativeFramebuffer),
1016 };
1017 if let Some(fence) = pending.fence.take() {
1018 // Block until this readback is done; newer PBOs stay in flight.
1019 let status = gl.client_wait_sync(fence, glow::SYNC_FLUSH_COMMANDS_BIT, i32::MAX);
1020 gl.delete_sync(fence);
1021 if status == glow::WAIT_FAILED || status == glow::TIMEOUT_EXPIRED {
1022 let _ = pending
1023 .reply
1024 .send(Err(format!("PBO-readback wachtstatus 0x{status:x}")));
1025 return Ok(());
1026 }
1027 }
1028 let w = pending.width;
1029 let h = pending.height;
1030 let pixels = w as usize * h as usize;
1031 let slot = pending.slot;
1032 let mut maps = Vec::with_capacity(3);
1033 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 1);
1034 for i in 0..3 {
1035 let buf = self.readback[slot].pbos[i];
1036 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, buf);
1037 let ptr = gl.map_buffer_range(
1038 glow::PIXEL_PACK_BUFFER,
1039 0,
1040 (pixels * 4) as i32,
1041 glow::MAP_READ_BIT,
1042 );
1043 let mut data = vec![0u8; pixels * 4];
1044 if ptr.is_null() {
1045 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None);
1046 let _ = pending.reply.send(Err("PBO map mislukt".into()));
1047 return Ok(());
1048 }
1049 std::ptr::copy_nonoverlapping(ptr, data.as_mut_ptr(), pixels * 4);
1050 let _ = gl.unmap_buffer(glow::PIXEL_PACK_BUFFER);
1051 flip_rows(&mut data, w as usize * 4, h as usize);
1052 maps.push(data);
1053 }
1054 let buf = self.readback[slot].pbos[3];
1055 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, buf);
1056 let ptr = gl.map_buffer_range(
1057 glow::PIXEL_PACK_BUFFER,
1058 0,
1059 (pixels * 4) as i32,
1060 glow::MAP_READ_BIT,
853 );
1061 );
854 gl.viewport(vp[0], vp[1], vp[2], vp[3]);
1062 let mut depth = vec![1f32; pixels];
855 gl.bind_vertex_array(None);
1063 if ptr.is_null() {
856 gl.use_program(None);
1064 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None);
857 gl.active_texture(glow::TEXTURE0);
1065 let _ = pending.reply.send(Err("PBO depth-map mislukt".into()));
858 gl.disable(glow::DEPTH_TEST);
1066 return Ok(());
859 gl.disable(glow::DEPTH_CLAMP);
1067 }
860 gl.disable(glow::SCISSOR_TEST);
1068 std::ptr::copy_nonoverlapping(ptr as *const f32, depth.as_mut_ptr(), pixels);
861 gl.enable(glow::BLEND);
1069 let _ = gl.unmap_buffer(glow::PIXEL_PACK_BUFFER);
862 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 4);
1070 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None);
863 result
1071 flip_rows(&mut depth, w as usize, h as usize);
1072 let specular = maps.pop().unwrap();
1073 let normal = maps.pop().unwrap();
1074 let diffuse = maps.pop().unwrap();
1075 let _ = pending.reply.send(Ok(Buffers {
1076 width: w,
1077 height: h,
1078 diffuse,
1079 normal,
1080 specular,
1081 depth,
1082 }));
1083 Ok(())
1084 }
1085
1086 /// Complete finished (or all, when `force`) PBO readbacks and deliver replies.
1087 /// # Safety
1088 /// `gl` must be current on this thread.
1089 pub unsafe fn poll_readbacks(&mut self, gl: &glow::Context, force: bool) -> Result<(), String> {
1090 if force {
1091 while !self.pending.is_empty() {
1092 self.complete_oldest(gl)?;
1093 }
1094 return Ok(());
1095 }
1096 while let Some(front) = self.pending.front() {
1097 let ready = match front.fence {
1098 None => true,
1099 Some(fence) => {
1100 let status = gl.client_wait_sync(fence, 0, 0);
1101 status == glow::ALREADY_SIGNALED || status == glow::CONDITION_SATISFIED
1102 }
1103 };
1104 if !ready {
1105 break;
1106 }
1107 self.complete_oldest(gl)?;
1108 }
1109 Ok(())
1110 }
1111
1112 /// Submit a scene into the PBO ring; the reply arrives when the readback finishes.
1113 /// # Safety
1114 /// `gl` is current on the UI thread. GL state is reset for egui painting.
1115 pub unsafe fn submit(
1116 &mut self,
1117 gl: &glow::Context,
1118 scene: &Scene,
1119 reply: mpsc::SyncSender<Result<Buffers, String>>,
1120 ) {
1121 let previous = gl.get_parameter_i32(glow::FRAMEBUFFER_BINDING);
1122 let mut vp = [0; 4];
1123 gl.get_parameter_i32_slice(glow::VIEWPORT, &mut vp);
1124 let restore = |gl: &glow::Context| {
1125 gl.bind_framebuffer(
1126 glow::FRAMEBUFFER,
1127 std::num::NonZeroU32::new(previous as u32).map(glow::NativeFramebuffer),
1128 );
1129 gl.viewport(vp[0], vp[1], vp[2], vp[3]);
1130 gl.bind_vertex_array(None);
1131 gl.use_program(None);
1132 gl.active_texture(glow::TEXTURE0);
1133 gl.disable(glow::DEPTH_TEST);
1134 gl.disable(glow::DEPTH_CLAMP);
1135 gl.disable(glow::SCISSOR_TEST);
1136 gl.enable(glow::BLEND);
1137 gl.pixel_store_i32(glow::PACK_ALIGNMENT, 4);
1138 gl.bind_buffer(glow::PIXEL_PACK_BUFFER, None);
1139 };
1140 if scene.width == 0
1141 || scene.height == 0
1142 || scene.width > MAX_HIGHRES
1143 || scene.height > MAX_HIGHRES
1144 {
1145 let _ = reply.send(Err("ongeldige highres-afmetingen".into()));
1146 restore(gl);
1147 return;
1148 }
1149 if let Err(e) = self.poll_readbacks(gl, false) {
1150 let _ = reply.send(Err(e));
1151 restore(gl);
1152 return;
1153 }
1154 while self.pending.len() >= GPU_PIPELINE_DEPTH {
1155 if let Err(e) = self.complete_oldest(gl) {
1156 let _ = reply.send(Err(e));
1157 restore(gl);
1158 return;
1159 }
1160 }
1161 if let Err(e) = self.draw_scene(gl, scene) {
1162 let _ = reply.send(Err(e));
1163 restore(gl);
1164 return;
1165 }
1166 if self.pbo_ok {
1167 if let Err(e) = self.kick_pbo_readback(gl, scene.width, scene.height, reply) {
1168 // kick_pbo consumes reply on success paths; on ensure failure it may have sent already.
1169 let _ = e;
1170 }
1171 } else {
1172 let buffers = Self::readback_into_slice(gl, scene.width, scene.height);
1173 let _ = reply.send(buffers);
1174 }
1175 restore(gl);
1176 }
1177
1178 /// Synchronous render+readback (probes / tests). Flushes the pipeline first.
1179 /// # Safety
1180 /// `gl` is current on the UI thread. GL state is reset for egui painting.
1181 pub unsafe fn render(&mut self, gl: &glow::Context, scene: &Scene) -> Result<Buffers, String> {
1182 let (tx, rx) = mpsc::sync_channel(1);
1183 self.submit(gl, scene, tx);
1184 let _ = self.poll_readbacks(gl, true);
1185 rx.recv().map_err(|_| "GPU-antwoord ontbreekt".to_string())?
864 }
1186 }
865}
1187}
1188
866/// Covered highres pixels whose depth hit the fixed interval's near or far
1189/// Covered highres pixels whose depth hit the fixed interval's near or far
867/// plane (GL depth clamp), i.e. geometry outside `[depth_near, depth_far]`.
1190/// plane (GL depth clamp), i.e. geometry outside `[depth_near, depth_far]`.
868pub fn clamped_depth_pixels(raw: &Buffers) -> usize {
1191pub fn clamped_depth_pixels(raw: &Buffers) -> usize {
@@ -1338,10 +1661,54 @@ impl GpuRequest {
1338 /// # Safety
1661 /// # Safety
1339 /// `gl` must be current on this UI thread.
1662 /// `gl` must be current on this UI thread.
1340 pub unsafe fn service(self, renderer: &mut GpuRenderer, gl: &glow::Context) {
1663 pub unsafe fn service(self, renderer: &mut GpuRenderer, gl: &glow::Context) {
1341 let result = renderer.render(gl, &self.scene);
1664 renderer.submit(gl, &self.scene, self.reply);
1342 let _ = self.reply.send(result);
1343 }
1665 }
1344}
1666}
1667
1668
1669/// Latest-wins live preview frame (Tim t1064u). The render worker overwrites this
1670/// slot without waiting for the UI; the UI may skip intermediate frames.
1671#[derive(Debug, Clone)]
1672pub struct PreviewFrame {
1673 pub image: ImageBuf,
1674 pub label: String,
1675 pub revision: u64,
1676}
1677
1678#[derive(Clone, Default)]
1679pub struct PreviewSlot(Arc<Mutex<Option<PreviewFrame>>>);
1680
1681impl PreviewSlot {
1682 /// Overwrite the slot. Critical section is only the swap — never texture upload.
1683 pub fn publish(&self, image: ImageBuf, label: String) {
1684 if let Ok(mut guard) = self.0.lock() {
1685 let revision = guard
1686 .as_ref()
1687 .map(|f| f.revision.wrapping_add(1))
1688 .unwrap_or(1);
1689 *guard = Some(PreviewFrame {
1690 image,
1691 label,
1692 revision,
1693 });
1694 }
1695 }
1696
1697 /// Take the newest frame if any (UI side). Returns None when unchanged/empty.
1698 pub fn take(&self) -> Option<PreviewFrame> {
1699 self.0.lock().ok()?.take()
1700 }
1701
1702 /// Apply a taken frame into GUI state (revision/label/pixels).
1703 pub fn apply_to(&self, ui: &mut crate::render_job::RenderUiState) {
1704 if let Some(frame) = self.take() {
1705 ui.preview_label = frame.label;
1706 ui.preview_revision = frame.revision;
1707 ui.preview = Some(frame.image);
1708 }
1709 }
1710}
1711
1345#[derive(Default)]
1712#[derive(Default)]
1346struct Pending {
1713struct Pending {
1347 pass: Option<RenderEvent>,
1714 pass: Option<RenderEvent>,
@@ -1375,6 +1742,8 @@ impl Mailbox {
1375}
1742}
1376pub struct RenderThread {
1743pub struct RenderThread {
1377 pub mailbox: Mailbox,
1744 pub mailbox: Mailbox,
1745 /// Latest rendered preview image; independent of the progress mailbox (t1064u).
1746 pub preview: PreviewSlot,
1378 pub gpu: mpsc::Receiver<GpuRequest>,
1747 pub gpu: mpsc::Receiver<GpuRequest>,
1379 pub cancel: Arc<AtomicBool>,
1748 pub cancel: Arc<AtomicBool>,
1380 pub handle: Option<thread::JoinHandle<()>>,
1749 pub handle: Option<thread::JoinHandle<()>>,
@@ -1412,14 +1781,39 @@ fn spawn_target(
1412) -> RenderThread {
1781) -> RenderThread {
1413 let mailbox = Mailbox::default();
1782 let mailbox = Mailbox::default();
1414 let output = mailbox.clone();
1783 let output = mailbox.clone();
1784 let preview = PreviewSlot::default();
1785 let preview_w = preview.clone();
1415 let cancel = Arc::new(AtomicBool::new(false));
1786 let cancel = Arc::new(AtomicBool::new(false));
1416 let flag = cancel.clone();
1787 let flag = cancel.clone();
1417 let (tx, gpu) = mpsc::channel();
1788 let (tx, gpu) = mpsc::channel();
1418 let handle = thread::spawn(move || {
1789 let handle = thread::spawn(move || {
1419 let mut emit = |event| {
1790 // Progress/terminal go through the mailbox and may request a repaint.
1420 output.publish(event);
1791 // Preview pixels go only to PreviewSlot (latest-wins) and never block
1421 if let Some(ctx) = &ctx {
1792 // the worker on texture upload or UI ack (Tim t1064u).
1422 ctx.request_repaint();
1793 let mut emit = |event: RenderEvent| {
1794 match event {
1795 RenderEvent::Pass {
1796 index,
1797 total,
1798 label,
1799 image: Some(image),
1800 } => {
1801 preview_w.publish(image, label.clone());
1802 output.publish(RenderEvent::Pass {
1803 index,
1804 total,
1805 label,
1806 image: None,
1807 });
1808 // No request_repaint here: the UI pulls the slot on its own
1809 // frames (GPU service already keeps the loop alive).
1810 }
1811 other => {
1812 output.publish(other);
1813 if let Some(ctx) = &ctx {
1814 ctx.request_repaint();
1815 }
1816 }
1423 }
1817 }
1424 };
1818 };
1425 let result = execute_target(job, target, &tx, &flag, ctx.as_ref(), &mut emit);
1819 let result = execute_target(job, target, &tx, &flag, ctx.as_ref(), &mut emit);
@@ -1433,6 +1827,7 @@ fn spawn_target(
1433 });
1827 });
1434 RenderThread {
1828 RenderThread {
1435 mailbox,
1829 mailbox,
1830 preview,
1436 gpu,
1831 gpu,
1437 cancel,
1832 cancel,
1438 handle: Some(handle),
1833 handle: Some(handle),
@@ -1601,7 +1996,7 @@ pub fn planned_paths(plan: &Plan, sex: &str, set: &str) -> Result<Vec<String>, S
1601fn execute(
1996fn execute(
1602 job: RenderJob,
1997 job: RenderJob,
1603 gpu: &mpsc::Sender<GpuRequest>,
1998 gpu: &mpsc::Sender<GpuRequest>,
1604 cancel: &AtomicBool,
1999 cancel: &Arc<AtomicBool>,
1605 ctx: Option<&eframe::egui::Context>,
2000 ctx: Option<&eframe::egui::Context>,
1606 emit: &mut dyn FnMut(RenderEvent),
2001 emit: &mut dyn FnMut(RenderEvent),
1607) -> Result<(), String> {
2002) -> Result<(), String> {
@@ -1612,7 +2007,7 @@ fn execute_target(
1612 job: RenderJob,
2007 job: RenderJob,
1613 target: RunTarget,
2008 target: RunTarget,
1614 gpu: &mpsc::Sender<GpuRequest>,
2009 gpu: &mpsc::Sender<GpuRequest>,
1615 cancel: &AtomicBool,
2010 cancel: &Arc<AtomicBool>,
1616 ctx: Option<&eframe::egui::Context>,
2011 ctx: Option<&eframe::egui::Context>,
1617 emit: &mut dyn FnMut(RenderEvent),
2012 emit: &mut dyn FnMut(RenderEvent),
1618) -> Result<(), String> {
2013) -> Result<(), String> {
@@ -1631,7 +2026,7 @@ fn execute_target(
1631 label: format!("Herstel: {note}"),
2026 label: format!("Herstel: {note}"),
1632 });
2027 });
1633 }
2028 }
1634 let mut session = match target {
2029 let session = match target {
1635 RunTarget::Fresh => {
2030 RunTarget::Fresh => {
1636 for name in crate::resume::discard_stale(&job.output, &job.sex, &set)? {
2031 for name in crate::resume::discard_stale(&job.output, &job.sex, &set)? {
1637 emit(RenderEvent::Step {
2032 emit(RenderEvent::Step {
@@ -1684,7 +2079,6 @@ fn execute_target(
1684 });
2079 });
1685 }
2080 }
1686 }
2081 }
1687 let root = session.root.clone();
1688 // Internal unique names select every source clip, even duplicate display names.
2082 // Internal unique names select every source clip, even duplicate display names.
1689 for (i, c) in job.character.clips.iter_mut().enumerate() {
2083 for (i, c) in job.character.clips.iter_mut().enumerate() {
1690 c.name = format!("render-clip-{i}");
2084 c.name = format!("render-clip-{i}");
@@ -1692,60 +2086,38 @@ fn execute_target(
1692 let mut completed = 0u32;
2086 let mut completed = 0u32;
1693 let mut clamped_views = 0u32;
2087 let mut clamped_views = 0u32;
1694 let mut clamped_pixels = 0u64;
2088 let mut clamped_pixels = 0u64;
1695 let mut png_sha256 = std::collections::BTreeMap::<String, String>::new();
2089 let verified = session.verified.clone();
2090 let attempt = session.attempt;
2091 let root = session.root.clone();
1696 let mut cache = equipment::TextureCache::default();
2092 let mut cache = equipment::TextureCache::default();
1697 let result = (|| {
2093
1698 for (index, sample) in samples(&plan).enumerate() {
2094 struct InflightGpu {
1699 let clip = &plan.clips[sample.clip];
2095 rx: mpsc::Receiver<Result<Buffers, String>>,
1700 let frame = sample.frame;
2096 paths: Vec<String>,
1701 let pitch = sample.pitch;
2097 pose: String,
1702 let yaw = sample.yaw;
2098 }
1703 let paths = &planned[index * 4..index * 4 + 4];
2099
1704 if cancel.load(Ordering::Relaxed) {
2100 let seed_sha = verified.clone();
1705 return Err("geannuleerd".into());
2101 let mut atlas_cells: BTreeMap<String, Arc<Vec<u8>>> = BTreeMap::new();
1706 }
2102 let mut io = Some(IoQueue::start(session, seed_sha, Arc::clone(cancel)));
1707 let pose = format!(
2103 let work = (|| {
1708 "{} {set} · {} f{frame}/{} · p{pitch} y{yaw}",
2104 let io = io.as_ref().ok_or_else(|| "IO-queue ontbreekt".to_string())?;
1709 job.sex, clip.source_name, clip.frames
2105 let mut inflight: VecDeque<InflightGpu> = VecDeque::new();
1710 );
2106
1711 if paths.iter().all(|p| session.verified.contains_key(p)) {
2107 let drain_one = |item: InflightGpu,
1712 for p in paths {
2108 completed: &mut u32,
1713 png_sha256.insert(p.clone(), session.verified[p].clone());
2109 clamped_views: &mut u32,
1714 }
2110 clamped_pixels: &mut u64,
1715 completed += PASSES.len() as u32;
2111 atlas_cells: &mut BTreeMap<String, Arc<Vec<u8>>>,
1716 emit(RenderEvent::Step {
2112 io: &IoQueue,
1717 index: completed,
2113 job: &RenderJob,
1718 total: plan.passes,
2114 render_doc: &serde_json::Value,
1719 label: format!("{pose} · al aanwezig, checksums kloppen"),
2115 plan_passes: u32,
1720 });
2116 cancel: &AtomicBool,
1721 continue;
2117 emit: &mut dyn FnMut(RenderEvent)|
1722 }
2118 -> Result<(), String> {
1723 let key = if job.character.clips.is_empty() {
1724 String::new()
1725 } else {
1726 format!("render-clip-{}", sample.clip)
1727 };
1728 emit(RenderEvent::Step {
1729 index: completed,
1730 total: plan.passes,
1731 label: format!("{pose} · highres"),
1732 });
1733 let scene = scene(
1734 &job,
1735 &key,
1736 frame as f32 / job.fps as f32,
1737 pitch as f32,
1738 yaw as f32,
1739 &mut cache,
1740 )?;
1741 let (tx, rx) = mpsc::sync_channel(1);
1742 gpu.send(GpuRequest { scene, reply: tx })
1743 .map_err(|_| "GPU-service gesloten")?;
1744 if let Some(ctx) = ctx {
1745 ctx.request_repaint();
1746 }
1747 let raw = loop {
2119 let raw = loop {
1748 match rx.recv_timeout(std::time::Duration::from_millis(100)) {
2120 match item.rx.recv_timeout(std::time::Duration::from_millis(100)) {
1749 Ok(result) => break result?,
2121 Ok(result) => break result?,
1750 Err(mpsc::RecvTimeoutError::Timeout) => {
2122 Err(mpsc::RecvTimeoutError::Timeout) => {
1751 if cancel.load(Ordering::Relaxed) {
2123 if cancel.load(Ordering::Relaxed) {
@@ -1755,13 +2127,15 @@ fn execute_target(
1755 Err(_) => return Err("GPU-service gestopt".into()),
2127 Err(_) => return Err("GPU-service gestopt".into()),
1756 }
2128 }
1757 };
2129 };
2130 let pose = &item.pose;
2131 let paths = &item.paths;
1758 let clamped = clamped_depth_pixels(&raw);
2132 let clamped = clamped_depth_pixels(&raw);
1759 if clamped > 0 {
2133 if clamped > 0 {
1760 clamped_views += 1;
2134 *clamped_views += 1;
1761 clamped_pixels += clamped as u64;
2135 *clamped_pixels += clamped as u64;
1762 emit(RenderEvent::Step {
2136 emit(RenderEvent::Step {
1763 index: completed,
2137 index: *completed,
1764 total: plan.passes,
2138 total: plan_passes,
1765 label: format!(
2139 label: format!(
1766 "Waarschuwing: {pose} · {clamped} highres-pixels buiten dieptebereik {:.2}–{:.2} m (geklemd)",
2140 "Waarschuwing: {pose} · {clamped} highres-pixels buiten dieptebereik {:.2}–{:.2} m (geklemd)",
1767 render_doc["depth_near"].as_f64().unwrap_or(0.),
2141 render_doc["depth_near"].as_f64().unwrap_or(0.),
@@ -1769,10 +2143,10 @@ fn execute_target(
1769 ),
2143 ),
1770 });
2144 });
1771 }
2145 }
1772 completed += 1;
2146 *completed += 1;
1773 emit(RenderEvent::Pass {
2147 emit(RenderEvent::Pass {
1774 index: completed - 1,
2148 index: *completed - 1,
1775 total: plan.passes,
2149 total: plan_passes,
1776 label: format!("{pose} · highres"),
2150 label: format!("{pose} · highres"),
1777 image: Some(image(raw.width, raw.height, raw.diffuse.clone())),
2151 image: Some(image(raw.width, raw.height, raw.diffuse.clone())),
1778 });
2152 });
@@ -1780,15 +2154,15 @@ fn execute_target(
1780 return Err("geannuleerd".into());
2154 return Err("geannuleerd".into());
1781 }
2155 }
1782 emit(RenderEvent::Step {
2156 emit(RenderEvent::Step {
1783 index: completed,
2157 index: *completed,
1784 total: plan.passes,
2158 total: plan_passes,
1785 label: format!("{pose} · detail"),
2159 label: format!("{pose} · detail"),
1786 });
2160 });
1787 let detail = detail_map(&raw)?;
2161 let detail = detail_map(&raw)?;
1788 completed += 1;
2162 *completed += 1;
1789 emit(RenderEvent::Pass {
2163 emit(RenderEvent::Pass {
1790 index: completed - 1,
2164 index: *completed - 1,
1791 total: plan.passes,
2165 total: plan_passes,
1792 label: format!("{pose} · detail"),
2166 label: format!("{pose} · detail"),
1793 image: Some(detail_image(&raw, &detail)),
2167 image: Some(detail_image(&raw, &detail)),
1794 });
2168 });
@@ -1796,8 +2170,8 @@ fn execute_target(
1796 return Err("geannuleerd".into());
2170 return Err("geannuleerd".into());
1797 }
2171 }
1798 emit(RenderEvent::Step {
2172 emit(RenderEvent::Step {
1799 index: completed,
2173 index: *completed,
1800 total: plan.passes,
2174 total: plan_passes,
1801 label: format!("{pose} · target-res / diffuse"),
2175 label: format!("{pose} · target-res / diffuse"),
1802 });
2176 });
1803 let mut target = resolve(&raw, &detail, job.target_width, job.target_height)?;
2177 let mut target = resolve(&raw, &detail, job.target_width, job.target_height)?;
@@ -1812,181 +2186,281 @@ fn execute_target(
1812 ("specular", target.specular.clone()),
2186 ("specular", target.specular.clone()),
1813 ]
2187 ]
1814 .into_iter()
2188 .into_iter()
1815 .zip(paths)
2189 .zip(paths.iter())
1816 {
2190 {
1817 if cancel.load(Ordering::Relaxed) {
2191 if cancel.load(Ordering::Relaxed) {
1818 return Err("geannuleerd".into());
2192 return Err("geannuleerd".into());
1819 }
2193 }
1820 emit(RenderEvent::Step {
2194 emit(RenderEvent::Step {
1821 index: completed,
2195 index: *completed,
1822 total: plan.passes,
2196 total: plan_passes,
1823 label: format!("{pose} · {map}"),
2197 label: format!("{pose} · {map}"),
1824 });
2198 });
1825 debug_assert!(rel.ends_with(&format!("_{map}.png")));
2199 debug_assert!(rel.ends_with(&format!("_{map}.png")));
1826 let kept = session.verified.contains_key(rel);
2200 let bytes = if map == "depth" {
1827 let digest = if kept {
2201 let pixels: Vec<(f64, f64)> = target
1828 if map == "depth" {
2202 .depth_t
1829 let stored = std::fs::read(session.root.join(rel))
2203 .iter()
1830 .map_err(|e| format!("{rel}: {e}"))?;
2204 .enumerate()
1831 preview = stored_depth_preview(
2205 .map(|(i, t)| {
1832 &stored,
2206 (f64::from(*t), f64::from(target.depth[i * 2 + 1]) / 65535.)
1833 job.target_width,
2207 })
1834 job.target_height,
2208 .collect();
1835 job.depth_format,
2209 let bytes = encode_depth_png_fmt(
1836 )?;
2210 job.target_width,
1837 }
2211 job.target_height,
1838 session.verified[rel].clone()
2212 &pixels,
2213 job.depth_format,
2214 )?;
2215 preview = stored_depth_preview(
2216 &bytes,
2217 job.target_width,
2218 job.target_height,
2219 job.depth_format,
2220 )?;
2221 bytes
1839 } else {
2222 } else {
1840 let bytes = if map == "depth" {
2223 encode_rgba_png(&preview, map == "diffuse")?
1841 let pixels: Vec<(f64, f64)> = target
1842 .depth_t
1843 .iter()
1844 .enumerate()
1845 .map(|(i, t)| {
1846 (f64::from(*t), f64::from(target.depth[i * 2 + 1]) / 65535.)
1847 })
1848 .collect();
1849 let bytes = encode_depth_png_fmt(
1850 job.target_width,
1851 job.target_height,
1852 &pixels,
1853 job.depth_format,
1854 )?;
1855 preview = stored_depth_preview(
1856 &bytes,
1857 job.target_width,
1858 job.target_height,
1859 job.depth_format,
1860 )?;
1861 bytes
1862 } else {
1863 encode_rgba_png(&preview, map == "diffuse")?
1864 };
1865 session.publish(rel, &bytes)?
1866 };
2224 };
1867 png_sha256.insert(rel.clone(), digest);
2225 atlas_cells.insert(rel.clone(), Arc::new(bytes.clone()));
1868 completed += 1;
2226 // Publish per map (ordered) so cancel mid-view still leaves
2227 // already-finished maps on disk for Resume, like before.
2228 io.submit(ViewPublish {
2229 maps: vec![EncodedMap {
2230 rel: rel.clone(),
2231 bytes,
2232 }],
2233 })?;
2234 *completed += 1;
1869 let pass = if map == "diffuse" {
2235 let pass = if map == "diffuse" {
1870 "target-res / diffuse"
2236 "target-res / diffuse"
1871 } else {
2237 } else {
1872 map
2238 map
1873 };
2239 };
1874 emit(RenderEvent::Pass {
2240 emit(RenderEvent::Pass {
1875 index: completed - 1,
2241 index: *completed - 1,
1876 total: plan.passes,
2242 total: plan_passes,
1877 label: if kept {
2243 label: format!("{pose} · {pass}"),
1878 format!("{pose} · {pass} (bestaand bestand behouden)")
1879 } else {
1880 format!("{pose} · {pass}")
1881 },
1882 image: Some(preview),
2244 image: Some(preview),
1883 });
2245 });
1884 }
2246 }
1885 }
2247 Ok(())
1886 if png_sha256.len() as u32 != plan.files {
2248 };
1887 return Err(format!(
2249
1888 "checksumtelling {} wijkt af van geplande {} PNGs",
2250 for (index, sample) in samples(&plan).enumerate() {
1889 png_sha256.len(),
2251 let clip = &plan.clips[sample.clip];
1890 plan.files
2252 let frame = sample.frame;
1891 ));
2253 let pitch = sample.pitch;
1892 }
2254 let yaw = sample.yaw;
1893 // Atlases + .ppda only after every loose map is published and verified
2255 let paths = planned[index * 4..index * 4 + 4].to_vec();
1894 // (also after a resume); complete.json stays the final marker.
2256 if cancel.load(Ordering::Relaxed) {
1895 crate::atlas::write_atlases(
2257 return Err("geannuleerd".into());
1896 &crate::atlas::AtlasJob {
2258 }
1897 root: &root,
2259 let pose = format!(
1898 render_doc: &render_doc,
2260 "{} {set} · {} f{frame}/{} · p{pitch} y{yaw}",
1899 plan: &plan,
2261 job.sex, clip.source_name, clip.frames
1900 sex: &job.sex,
2262 );
1901 set: &set,
2263 if paths.iter().all(|p| verified.contains_key(p)) {
1902 cell_width: job.target_width,
2264 completed += PASSES.len() as u32;
1903 cell_height: job.target_height,
1904 png_sha256: &png_sha256,
1905 attempt: session.attempt,
1906 cancel,
1907 trim: job.atlas_trim,
1908 },
1909 &mut |label| {
1910 emit(RenderEvent::Step {
1911 index: completed,
1912 total: plan.passes,
1913 label,
1914 })
1915 },
1916 )
1917 })();
1918 match result {
1919 Ok(atlas) => {
1920 let identity = crate::export_set::complete_identity_json(&render_doc)?;
1921 // In-progress completion marker only; removed again by
1922 // strip_bookkeeping once the .ppda (which embeds the export
1923 // contract and per-PNG checksums) is verified (Tim t993u/t994u).
1924 write_json(
1925 &root,
1926 crate::resume::COMPLETE_MARKER,
1927 serde_json::json!({
1928 "format_version": crate::export_set::OUTPUT_META_VERSION,
1929 "status": "complete",
1930 "identity": identity,
1931 "passes": completed,
1932 "png_files": plan.files,
1933 "png_sha256": png_sha256,
1934 "atlas": atlas.to_json(),
1935 "depth_clamped": {
1936 "views": clamped_views,
1937 "highres_pixels": clamped_pixels,
1938 "note": "rendered this attempt; kept files from a resumed run are not re-counted",
1939 },
1940 }),
1941 )?;
1942 if clamped_views > 0 {
1943 emit(RenderEvent::Step {
2265 emit(RenderEvent::Step {
1944 index: completed,
2266 index: completed,
1945 total: plan.passes,
2267 total: plan.passes,
1946 label: format!(
2268 label: format!("{pose} · al aanwezig, checksums kloppen"),
1947 "Waarschuwing: diepte geklemd in {clamped_views} beelden ({clamped_pixels} highres-pixels buiten het vaste bereik)"
1948 ),
1949 });
2269 });
2270 continue;
1950 }
2271 }
1951 // Verify the finished build before it replaces the last good result.
2272 // Backpressure: keep at most GPU_PIPELINE_DEPTH views waiting on the GPU.
1952 crate::atlas::AtlasSet::open_run(&root)
2273 while inflight.len() >= GPU_PIPELINE_DEPTH {
1953 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
2274 let item = inflight.pop_front().unwrap();
1954 crate::export_set::ExportRun::open(&root)
2275 drain_one(
1955 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
2276 item,
1956 drop(session);
2277 &mut completed,
1957 // Lock, journal and resume bookkeeping stay out of the result folder.
2278 &mut clamped_views,
1958 crate::resume::strip_bookkeeping(&root)?;
2279 &mut clamped_pixels,
1959 let final_dir = crate::resume::promote(
2280 &mut atlas_cells,
1960 &job.output,
2281 &io,
1961 &root,
2282 &job,
1962 &crate::resume::result_dir_name(&job.sex, &set),
2283 &render_doc,
1963 )?;
2284 plan.passes,
1964 emit(RenderEvent::Finished {
2285 cancel.as_ref(),
1965 saved: Some(final_dir.display().to_string()),
2286 emit,
1966 });
2287 )?;
1967 Ok(())
2288 }
1968 }
2289 let key = if job.character.clips.is_empty() {
1969 Err(error) => {
2290 String::new()
1970 let name = if cancel.load(Ordering::Relaxed) {
1971 "cancelled.json"
1972 } else {
2291 } else {
1973 "failed.json"
2292 format!("render-clip-{}", sample.clip)
1974 };
2293 };
1975 let _ = write_json(
2294 emit(RenderEvent::Step {
1976 &root,
2295 index: completed,
1977 name,
2296 total: plan.passes,
1978 serde_json::json!({
2297 label: format!("{pose} · highres"),
1979 "error": error,
2298 });
1980 "completed_passes": completed,
2299 let scene = scene(
1981 "total_passes": plan.passes,
2300 &job,
1982 "png_files_done": png_sha256.len(),
2301 &key,
1983 "png_files": plan.files,
2302 frame as f32 / job.fps as f32,
1984 "attempt": session.attempt,
2303 pitch as f32,
1985 }),
2304 yaw as f32,
1986 );
2305 &mut cache,
1987 Err(error)
2306 )?;
2307 let (tx, rx) = mpsc::sync_channel(1);
2308 gpu.send(GpuRequest { scene, reply: tx })
2309 .map_err(|_| "GPU-service gesloten")?;
2310 if let Some(ctx) = ctx {
2311 ctx.request_repaint();
2312 }
2313 inflight.push_back(InflightGpu { rx, paths, pose });
2314 }
2315 while let Some(item) = inflight.pop_front() {
2316 drain_one(
2317 item,
2318 &mut completed,
2319 &mut clamped_views,
2320 &mut clamped_pixels,
2321 &mut atlas_cells,
2322 &io,
2323 &job,
2324 &render_doc,
2325 plan.passes,
2326 cancel.as_ref(),
2327 emit,
2328 )?;
1988 }
2329 }
2330 Ok(())
2331 })();
2332 // Always drain the IO queue so cancel keeps journaled PNGs (Resume).
2333 let io_finish = io.take().map(|q| q.finish());
2334 let (png_sha256, io_error) = match io_finish {
2335 Some(Ok((_session, digests))) => (digests, None),
2336 Some(Err(e)) => (BTreeMap::new(), Some(e)),
2337 None => (BTreeMap::new(), Some("IO-queue ontbreekt".into())),
2338 };
2339 if let Err(error) = work {
2340 let name = if cancel.load(Ordering::Relaxed) {
2341 "cancelled.json"
2342 } else {
2343 "failed.json"
2344 };
2345 let _ = write_json(
2346 &root,
2347 name,
2348 serde_json::json!({
2349 "error": error,
2350 "completed_passes": completed,
2351 "total_passes": plan.passes,
2352 "png_files_done": png_sha256.len(),
2353 "png_files": plan.files,
2354 "attempt": attempt,
2355 }),
2356 );
2357 return Err(error);
2358 }
2359 if let Some(e) = io_error {
2360 let _ = write_json(
2361 &root,
2362 "failed.json",
2363 serde_json::json!({
2364 "error": e,
2365 "completed_passes": completed,
2366 "total_passes": plan.passes,
2367 "png_files_done": png_sha256.len(),
2368 "png_files": plan.files,
2369 "attempt": attempt,
2370 }),
2371 );
2372 return Err(e);
2373 }
2374 if png_sha256.len() as u32 != plan.files {
2375 let error = format!(
2376 "checksumtelling {} wijkt af van geplande {} PNGs",
2377 png_sha256.len(),
2378 plan.files
2379 );
2380 let _ = write_json(
2381 &root,
2382 "failed.json",
2383 serde_json::json!({
2384 "error": error,
2385 "completed_passes": completed,
2386 "total_passes": plan.passes,
2387 "png_files_done": png_sha256.len(),
2388 "png_files": plan.files,
2389 "attempt": attempt,
2390 }),
2391 );
2392 return Err(error);
2393 }
2394 let atlas = crate::atlas::write_atlases(
2395 &crate::atlas::AtlasJob {
2396 root: &root,
2397 render_doc: &render_doc,
2398 plan: &plan,
2399 sex: &job.sex,
2400 set: &set,
2401 cell_width: job.target_width,
2402 cell_height: job.target_height,
2403 png_sha256: &png_sha256,
2404 attempt,
2405 cancel: cancel.as_ref(),
2406 trim: job.atlas_trim,
2407 cells: Some(&atlas_cells),
2408 },
2409 &mut |label| {
2410 emit(RenderEvent::Step {
2411 index: completed,
2412 total: plan.passes,
2413 label,
2414 })
2415 },
2416 )?;
2417 let identity = crate::export_set::complete_identity_json(&render_doc)?;
2418 // In-progress completion marker only; removed again by
2419 // strip_bookkeeping once the .ppda (which embeds the export
2420 // contract and per-PNG checksums) is verified (Tim t993u/t994u).
2421 write_json(
2422 &root,
2423 crate::resume::COMPLETE_MARKER,
2424 serde_json::json!({
2425 "format_version": crate::export_set::OUTPUT_META_VERSION,
2426 "status": "complete",
2427 "identity": identity,
2428 "passes": completed,
2429 "png_files": plan.files,
2430 "png_sha256": png_sha256,
2431 "atlas": atlas.to_json(),
2432 "depth_clamped": {
2433 "views": clamped_views,
2434 "highres_pixels": clamped_pixels,
2435 "note": "rendered this attempt; kept files from a resumed run are not re-counted",
2436 },
2437 }),
2438 )?;
2439 if clamped_views > 0 {
2440 emit(RenderEvent::Step {
2441 index: completed,
2442 total: plan.passes,
2443 label: format!(
2444 "Waarschuwing: diepte geklemd in {clamped_views} beelden ({clamped_pixels} highres-pixels buiten het vaste bereik)"
2445 ),
2446 });
1989 }
2447 }
2448 // Verify the finished build before it replaces the last good result.
2449 crate::atlas::AtlasSet::open_run(&root)
2450 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
2451 crate::export_set::ExportRun::open(&root)
2452 .map_err(|e| format!("voltooide render faalt controle: {e}"))?;
2453 // Lock, journal and resume bookkeeping stay out of the result folder.
2454 crate::resume::strip_bookkeeping(&root)?;
2455 let final_dir = crate::resume::promote(
2456 &job.output,
2457 &root,
2458 &crate::resume::result_dir_name(&job.sex, &set),
2459 )?;
2460 emit(RenderEvent::Finished {
2461 saved: Some(final_dir.display().to_string()),
2462 });
2463 Ok(())
1990}
2464}
1991
2465
1992/// Real readback regression: nearest-surface depth/normal/specular, preserved
2466/// Real readback regression: nearest-surface depth/normal/specular, preserved
@@ -2402,6 +2876,62 @@ mod tests {
2402 .is_err());
2876 .is_err());
2403 assert!(!outside.0.join("sub").exists());
2877 assert!(!outside.0.join("sub").exists());
2404 }
2878 }
2879 #[test]
2880 fn preview_slot_latest_wins_without_blocking_on_slow_consumer() {
2881 use std::time::{Duration, Instant};
2882 let slot = PreviewSlot::default();
2883 let slot_c = slot.clone();
2884 // Slow consumer: takes frames then sleeps *outside* the slot lock.
2885 let consumer = std::thread::spawn(move || {
2886 let mut seen = 0u64;
2887 let mut last_rev = 0u64;
2888 let end = Instant::now() + Duration::from_millis(200);
2889 while Instant::now() < end {
2890 if let Some(frame) = slot_c.take() {
2891 seen += 1;
2892 last_rev = frame.revision;
2893 std::thread::sleep(Duration::from_millis(5));
2894 } else {
2895 std::thread::sleep(Duration::from_millis(1));
2896 }
2897 }
2898 (seen, last_rev)
2899 });
2900 let t0 = Instant::now();
2901 let n = 2_000u64;
2902 for i in 0..n {
2903 slot.publish(
2904 ImageBuf {
2905 width: 1,
2906 height: 1,
2907 rgba: vec![i as u8, 0, 0, 255],
2908 },
2909 format!("frame-{i}"),
2910 );
2911 }
2912 let producer = t0.elapsed();
2913 let (seen, last_rev) = consumer.join().unwrap();
2914 // Producer must stay well under the consumer's cumulative sleep budget
2915 // (2000 * 5ms would be 10s if it waited on each frame).
2916 assert!(
2917 producer < Duration::from_millis(500),
2918 "producer blocked on preview consumer: {producer:?}"
2919 );
2920 assert!(seen >= 1, "consumer saw no frames");
2921 assert!(
2922 seen < n,
2923 "consumer must be allowed to skip frames (saw {seen}/{n})"
2924 );
2925 // Latest-wins: if a frame remains, it must be the final publish. The
2926 // slow consumer may exit before taking it, or may have taken it already.
2927 if let Some(frame) = slot.take() {
2928 assert_eq!(frame.revision, n);
2929 assert_eq!(frame.label, format!("frame-{}", n - 1));
2930 }
2931 assert!(last_rev >= 1 && last_rev <= n, "last_rev={last_rev}");
2932 let _ = last_rev;
2933 }
2934
2405 #[test]
2935 #[test]
2406 fn latest_mailbox_is_bounded_and_retains_terminal() {
2936 fn latest_mailbox_is_bounded_and_retains_terminal() {
2407 let m = Mailbox::default();
2937 let m = Mailbox::default();
@@ -2619,7 +3149,7 @@ mod tests {
2619 },
3149 },
2620 preset_sha256: None,
3150 preset_sha256: None,
2621 };
3151 };
2622 let flag = AtomicBool::new(false);
3152 let flag = Arc::new(AtomicBool::new(false));
2623 let (tx, rx) = mpsc::channel::<GpuRequest>();
3153 let (tx, rx) = mpsc::channel::<GpuRequest>();
2624 let gpu = std::thread::spawn(move || {
3154 let gpu = std::thread::spawn(move || {
2625 for request in rx {
3155 for request in rx {
@@ -2815,7 +3345,7 @@ mod tests {
2815 target: RunTarget,
3345 target: RunTarget,
2816 stop: Option<usize>,
3346 stop: Option<usize>,
2817 ) -> (Result<(), String>, Vec<RenderEvent>) {
3347 ) -> (Result<(), String>, Vec<RenderEvent>) {
2818 let flag = AtomicBool::new(false);
3348 let flag = Arc::new(AtomicBool::new(false));
2819 let (tx, rx) = mpsc::channel::<GpuRequest>();
3349 let (tx, rx) = mpsc::channel::<GpuRequest>();
2820 let gpu = std::thread::spawn(move || {
3350 let gpu = std::thread::spawn(move || {
2821 for request in rx {
3351 for request in rx {