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.
@@ -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** selects28
- 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
@@ -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
355
fn rows_of(plan: &Plan) -> Vec<(usize, u32)> {357
fn rows_of(plan: &Plan) -> Vec<(usize, u32)> {@@ -376,12 +378,22 @@ fn load_cell(376
.png_sha256378
.png_sha256377
.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
bytes387
} 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
bytes396
};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();@@ -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 = self1032
// Fill the PBO pipeline: accept several GPU requests per UI frame, then1033
.render_worker1033
// 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 = self1037
if let Some(gl) = gl {1037
.render_worker1038
.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) = self1052
.render_worker1053
.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 = worker1066
let ended = worker1051
.handle1067
.handle1052
.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,@@ -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
)?;@@ -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
];@@ -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 a4
//! single IO thread that publishes them with [`crate::resume::RunSession`] in5
//! deterministic journal order. A bounded channel provides backpressure so only6
//! 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 already57
/// 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 new72
// 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.tx107
.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 = self125
.tx126
.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_rx133
.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 = q242
.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
}@@ -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;31
pub mod preview;31
pub mod preview;32
pub mod preview2d;32
pub mod preview2d;33
pub mod preview_gl;33
pub mod preview_gl;34
pub mod io_queue;34
pub mod production;35
pub mod production;35
pub mod project;36
pub mod project;36
pub mod recent;37
pub mod recent;@@ -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 native1
//! Bounded orthographic production rendering. GPU work runs on the native2
//! current context, one request per UI update; sampling/filtering/PNG I/O run2
//! current context with a PBO readback ring (several views in flight); CPU3
//! 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 worker5
//! never waits for texture upload or UI acknowledgment (Tim t1063u/t1064u).4
use crate::{6
use 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
};10
use glow::HasContext;13
use glow::HasContext;11
use std::{14
use 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.35
pub const GPU_PIPELINE_DEPTH: usize = 3;30
#[derive(Clone)]36
#[derive(Clone)]31
pub struct Draw {37
pub 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)]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
pub struct GpuRenderer {477
pub 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
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
impl GpuRenderer {513
impl GpuRenderer {460
/// # Safety514
/// # Safety461
/// 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 and679
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
/// # Safety681
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 == 0703
if scene.width == 0606
|| scene.height == 0704
|| scene.height == 0607
|| scene.width > MAX_HIGHRES705
|| 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 clipped629
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 clipped730
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 the654
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)> = scene656
}754
.draws657
// glTF BLEND (design.md t1016u): opaque/mask first, then the755
.iter()658
// BLEND draws blended without depth writes, then their depth only.756
.filter(|d| !d.blend)659
let order: Vec<(&Draw, u8)> = scene757
.map(|d| (d, 0))660
.draws758
.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
|| draw769
gl.enable(glow::BLEND);693
.indices770
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 usize773
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
|| draw710
glow::STREAM_DRAW,787
.indices711
);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 usize714
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 i32777
} else {778
glow::RGBA8 as i32779
},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 _ = pending1023
.reply1024
.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
result1071
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
/// # Safety1088
/// `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_SATISFIED1102
}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
/// # Safety1114
/// `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 == 01141
|| scene.height == 01142
|| scene.width > MAX_HIGHRES1143
|| scene.height > MAX_HIGHRES1144
{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
/// # Safety1180
/// `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 far1189
/// Covered highres pixels whose depth hit the fixed interval's near or far867
/// 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]`.868
pub fn clamped_depth_pixels(raw: &Buffers) -> usize {1191
pub fn clamped_depth_pixels(raw: &Buffers) -> usize {@@ -1338,10 +1661,54 @@ impl GpuRequest {1338
/// # Safety1661
/// # Safety1339
/// `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 this1670
/// 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 = guard1686
.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)]1346
struct Pending {1713
struct Pending {1347
pass: Option<RenderEvent>,1714
pass: Option<RenderEvent>,@@ -1375,6 +1742,8 @@ impl Mailbox {1375
}1742
}1376
pub struct RenderThread {1743
pub 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 block1421
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 own1809
// 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>, S1601
fn execute(1996
fn 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.frames2105
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)> = target1828
if map == "depth" {2202
.depth_t1829
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
bytes1839
} else {2222
} else {1840
let bytes = if map == "depth" {2223
encode_rgba_png(&preview, map == "diffuse")?1841
let pixels: Vec<(f64, f64)> = target1842
.depth_t1843
.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
bytes1862
} 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 leaves2227
// 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
map2238
map1873
};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.files2252
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 verified2255
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.frames1900
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 by1922
// strip_bookkeeping once the .ppda (which embeds the export1923
// 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.files2379
);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 by2419
// strip_bookkeeping once the .ppda (which embeds the export2420
// 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, preserved2466
/// 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 budget2915
// (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. The2926
// 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 {