Show the develop frame itself, instead of a photocopy of it
The oldest open item in the project (ARCH §6.1, spike S1, AC-8). Every frame
in develop was read off the GPU into a `SharedPixelBuffer` and handed back to
Slint to upload again: ~7 ms at 4K against a 0.28 ms compute pass, 96% of the
frame spent carrying pixels to the CPU and back so they could be drawn where
they already were.
Slint 1.17 will adopt a `wgpu::Texture` directly, and the whole of what that
needs is arrangement rather than code.
**One device, made before the window.** A texture belongs to the device that
allocated it, so the compute passes and the compositor cannot each open their
own. `GpuContext::new_shared` opens one and hands back the instance and
adapter alongside it; `dr_ui::shared_gpu` gives all four to
`BackendSelector::require_wgpu_29(WGPUConfiguration::Manual { .. })`. That
call has to come before the first window, because creating one selects a
backend for you — which is why the GPU is now opened at the top of `run`
rather than two hundred lines down beside the other controllers.
dr-gpu still names no UI type. It hands out raw wgpu and does not ask who is
compositing (ARCH §6.5a).
**Vulkan only on the shared path**, where headless keeps its GL fallback.
wgpu's GL backend reaches its display through EGL at instance creation, and
before a window exists there is no display handle to give it — so a GL
instance cannot later produce the window surface Slint needs from it. A
machine with no Vulkan gets no shared device and browses without develop,
which is the same degradation as no adapter at all.
**`renderer-femtovg` becomes `renderer-femtovg-wgpu`.** The old one is FemtoVG
over OpenGL and cannot be handed a wgpu texture at all. It is not kept
alongside as a fallback: FemtoVG-over-GL has no branch for an imported
texture, falls through to "render this image into a buffer", gets nothing, and
draws nothing — a blank canvas with no error, which is worse than the failure
it would be papering over. The consequence is stated plainly in the manifest:
the desktop app now needs a working wgpu adapter to open a window.
**Two output textures, not one, and this is the part that is not obvious.**
Slint repaints when the image property *changes*, and it decides that with
`PartialEq` — which for two images over the same `wgpu::Texture` says
"unchanged". A pass that reused a single target would have rendered every
slider move correctly on the GPU and shown none of them: right, and invisible.
`AdjustPass` alternates between two targets, so consecutive frames are
genuinely different values. It also settles the read-while-write question that
one queue was already answering.
`RENDER_ATTACHMENT` is added to both render targets. Neither pass uses it;
Slint rejects an imported texture without it, on the reasoning that a
compositor handed a texture may need to draw into it.
**`AdjustPass::read_output` is deleted rather than gated.** It and
`export_pixels` were the same transfer under two names, and the comments
explaining why they were separate are the point of the whole criterion:
reading pixels back to *display* them is the defect, reading them back to
*encode a file* is the only way a file is made. The display twin is now gone
outright, which is stronger than a feature flag — it cannot be turned back on.
`export_pixels` is untouched and still ungated. The `readback` feature comes
off dr-ui, darkroom-desktop and darkroom-android; it stays in dr-gpu, where it
still gates `RenderTarget::read_pixels` and the segmentation field readback.
`examples/develop` moves to `export_pixels`, which is honest — it writes a
PPM — and so no longer needs the feature.
Four tests, each named for what it protects and each of which fails without a
screen if the property it guards breaks:
- the adjust target satisfies every condition Slint's import checks, asserted
in the crate that owns the descriptor, because a descriptor that drifts
fails at runtime on a real display and nothing else would notice;
- consecutive renders are different textures, and the third is the first
again, so the alternation is a rotation and not an allocation per frame;
- the develop canvas has no CPU pixel buffer and does have a wgpu texture —
AC-8 itself, in the terms Slint uses;
- consecutive frames compare unequal as `slint::Image`, which is the property
the repaint actually depends on.
The zoom test's readback moves into the test module. It has to: there is no
library function that copies a displayed frame to the CPU any more, and that
is the point — the round-trip now exists in the test binary and nowhere a
shipping build can reach.
**What is not proven.** No GUI was run. What is verified is that the texture
satisfies the import contract, that the import succeeds, that the canvas is a
texture rather than a buffer, and that consecutive frames are distinguishable.
What is unverified is everything that needs a display: that Slint's FemtoVG
wgpu renderer adopts the Manual configuration on a real surface, that the
picture appears the right way up and the right colour, and the frame timing
that motivated the whole exercise. Android is untouched by testing — the
android backend routes a WGPU29 request to Skia, whose wgpu surface does
handle imported textures, but that is read from the source, not observed.
56 dr-gpu tests and 255 dr-ui tests pass, clippy clean under `-D warnings`,
fmt clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+134
-44
@@ -38,9 +38,9 @@ const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
|
||||
/// surfacing the error. Set far above any plausible completion — the copy this
|
||||
/// waits on is milliseconds — so it is reached only when something is wrong.
|
||||
///
|
||||
/// Ungated along with `export_pixels`: an export reads pixels back in a
|
||||
/// shipping build, and the bound that stops a lost device hanging the app
|
||||
/// applies at least as much there as it does to the display bridge.
|
||||
/// Ungated along with [`AdjustPass::export_pixels`], the one readback that
|
||||
/// survives S1: an export reads pixels back in a shipping build, and a lost
|
||||
/// device mid-export must surface as an error rather than a hung interface.
|
||||
const READBACK_POLL_LIMIT: u32 = 100_000;
|
||||
|
||||
/// Runs composed operation chains against demosaiced images.
|
||||
@@ -50,8 +50,27 @@ pub struct AdjustPass {
|
||||
pipeline_layout: wgpu::PipelineLayout,
|
||||
/// Compiled pipelines by structure hash (ARCH §5.6).
|
||||
cache: HashMap<u64, wgpu::ComputePipeline>,
|
||||
/// Output texture, reallocated only when the size changes.
|
||||
target: Option<Target>,
|
||||
/// TRACES: FR-DSP-1 | AC-8
|
||||
/// Output textures, written alternately, each reallocated only when the
|
||||
/// size changes.
|
||||
///
|
||||
/// **Two, and the second one is not an optimisation — it is what makes the
|
||||
/// zero-copy path visible.** Since S1 the compositor is handed this
|
||||
/// texture rather than a copy of its pixels, and Slint decides whether to
|
||||
/// repaint by comparing the image property against its previous value. Two
|
||||
/// images wrapping the *same* `wgpu::Texture` compare equal, so a pass
|
||||
/// that always wrote one texture would recompute every frame on the GPU
|
||||
/// and never once be asked to show it. Alternating makes each frame a
|
||||
/// genuinely different value, which is the only thing that makes it a
|
||||
/// different picture as far as the property system is concerned.
|
||||
///
|
||||
/// It also settles the question of whether the compositor is still
|
||||
/// sampling last frame while this frame's dispatch overwrites it. Both go
|
||||
/// through one queue, so submission order already answers that — but not
|
||||
/// having to rely on it is worth a texture.
|
||||
targets: [Option<Target>; 2],
|
||||
/// Which of [`Self::targets`] the last render wrote.
|
||||
current: usize,
|
||||
}
|
||||
|
||||
struct Target {
|
||||
@@ -117,7 +136,8 @@ impl AdjustPass {
|
||||
bind_group_layout,
|
||||
pipeline_layout,
|
||||
cache: HashMap::new(),
|
||||
target: None,
|
||||
targets: [None, None],
|
||||
current: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,13 +196,20 @@ impl AdjustPass {
|
||||
.expect("just inserted"))
|
||||
}
|
||||
|
||||
/// Ensure the output texture matches the requested size.
|
||||
/// Move to the other output texture and make sure it is the right size.
|
||||
///
|
||||
/// The rotation is unconditional; the reallocation is not. Steady-state
|
||||
/// rendering at one viewport size therefore allocates nothing and simply
|
||||
/// ping-pongs between two textures — see [`Self::targets`] for why there
|
||||
/// are two. A resize reallocates whichever one comes up next, so the two
|
||||
/// converge on the new size over two frames rather than in one lump.
|
||||
fn ensure_target(&mut self, width: u32, height: u32) {
|
||||
let matches = self
|
||||
.target
|
||||
self.current ^= 1;
|
||||
let slot = &mut self.targets[self.current];
|
||||
if slot
|
||||
.as_ref()
|
||||
.is_some_and(|t| t.width == width && t.height == height);
|
||||
if matches {
|
||||
.is_some_and(|t| t.width == width && t.height == height)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -197,13 +224,24 @@ impl AdjustPass {
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: Self::FORMAT,
|
||||
// STORAGE_BINDING to write from compute, TEXTURE_BINDING so the
|
||||
// compositor can sample it, COPY_SRC for `export_pixels`.
|
||||
//
|
||||
// RENDER_ATTACHMENT is never used by this pass and is required
|
||||
// anyway: Slint rejects an imported texture that lacks it
|
||||
// (`TextureImportError::InvalidUsage`), because a compositor
|
||||
// handed a texture has to assume it may need to draw into it. The
|
||||
// format is likewise not a free choice — `Rgba8Unorm` and
|
||||
// `Rgba8UnormSrgb` are the only two the import accepts, which is
|
||||
// why `FORMAT` is what it is.
|
||||
usage: wgpu::TextureUsages::STORAGE_BINDING
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
self.target = Some(Target {
|
||||
self.targets[self.current] = Some(Target {
|
||||
texture,
|
||||
view,
|
||||
width,
|
||||
@@ -262,7 +300,7 @@ impl AdjustPass {
|
||||
.cache
|
||||
.get(&shader.structure_hash)
|
||||
.expect("compiled above");
|
||||
let target = self.target.as_ref().expect("ensured above");
|
||||
let target = self.targets[self.current].as_ref().expect("ensured above");
|
||||
|
||||
let bind_group = self
|
||||
.ctx
|
||||
@@ -303,7 +341,10 @@ impl AdjustPass {
|
||||
}
|
||||
self.ctx.queue.submit(Some(enc.finish()));
|
||||
|
||||
Ok(&self.target.as_ref().expect("ensured above").texture)
|
||||
Ok(&self.targets[self.current]
|
||||
.as_ref()
|
||||
.expect("ensured above")
|
||||
.texture)
|
||||
}
|
||||
|
||||
/// How many distinct pipelines are compiled. Exposed for tests asserting
|
||||
@@ -312,48 +353,37 @@ impl AdjustPass {
|
||||
self.cache.len()
|
||||
}
|
||||
|
||||
/// The texture the last render wrote, if there has been one.
|
||||
pub fn output(&self) -> Option<&wgpu::Texture> {
|
||||
self.target.as_ref().map(|t| &t.texture)
|
||||
self.targets[self.current].as_ref().map(|t| &t.texture)
|
||||
}
|
||||
|
||||
/// Copy the output to the CPU as tightly packed RGBA8.
|
||||
///
|
||||
/// **A temporary bridge, not the display path.** ARCH §6.1 forbids this
|
||||
/// round-trip in production and AC-8 asserts it does not happen; it
|
||||
/// exists only because Slint's texture-import path is unwired until
|
||||
/// spike S1. Measured cost at 4K is ~7 ms against a 0.28 ms compute pass
|
||||
/// — 96% of the frame — so this must go, and the `readback` feature gate
|
||||
/// keeps it out of a shipping build.
|
||||
#[cfg(any(test, feature = "readback"))]
|
||||
pub fn read_output(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
||||
self.copy_output()
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-9
|
||||
/// TRACES: FR-EXP-9 | AC-8
|
||||
/// Copy the output to the CPU **for export**.
|
||||
///
|
||||
/// The same transfer as [`Self::read_output`] and deliberately not the
|
||||
/// same method, because the two are opposites in intent and only one of
|
||||
/// them is a defect.
|
||||
/// This method had a twin, `read_output`, which performed exactly the same
|
||||
/// transfer for the display path. Spike S1 deleted the twin and left this
|
||||
/// one, and the difference between them is worth writing down because it
|
||||
/// is the whole of AC-8.
|
||||
///
|
||||
/// Reading pixels back to display them is what ARCH §6.1 forbids and AC-8
|
||||
/// asserts against: the compositor could have sampled that texture where
|
||||
/// it stood, and the round-trip costs 96% of the frame at 4K. Reading them
|
||||
/// back to *encode a JPEG* is not a shortcut around anything — a file is
|
||||
/// made of bytes on the CPU, and there is no path to one that does not
|
||||
/// pass through here.
|
||||
/// Reading pixels back to *display* them is what ARCH §6.1 forbids: the
|
||||
/// compositor could have sampled that texture where it stood, and the
|
||||
/// round-trip cost 96% of the frame at 4K — ~7 ms against a 0.28 ms
|
||||
/// compute pass. There is now no method that does it, which is a stronger
|
||||
/// guarantee than a feature gate: the display readback cannot be called
|
||||
/// back into existence by turning something on.
|
||||
///
|
||||
/// So this is ungated where `read_output` is behind a feature: an export
|
||||
/// must work in a shipping build, and the gate exists to keep the display
|
||||
/// bridge out of one. Keeping them separate also means the instrumentation
|
||||
/// AC-8 calls for can count display readbacks without counting exports.
|
||||
/// Reading them back to *encode a file* is not a shortcut around anything.
|
||||
/// A JPEG is made of bytes on the CPU and there is no path to one that
|
||||
/// does not pass through here, so this is ungated and belongs in a
|
||||
/// shipping build.
|
||||
pub fn export_pixels(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
||||
self.copy_output()
|
||||
}
|
||||
|
||||
/// The transfer itself, shared by both readers above.
|
||||
/// The transfer itself.
|
||||
fn copy_output(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
|
||||
let Some(target) = self.target.as_ref() else {
|
||||
let Some(target) = self.targets[self.current].as_ref() else {
|
||||
return Err(GpuError::Readback("nothing rendered yet".into()));
|
||||
};
|
||||
let (w, h) = (target.width, target.height);
|
||||
@@ -1141,6 +1171,66 @@ mod tests {
|
||||
assert_eq!(read_centre(&ctx, t)[3], 255);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-1 | AC-8
|
||||
#[test]
|
||||
fn the_output_is_importable_by_a_compositor() {
|
||||
// Every condition Slint checks before it will adopt a texture
|
||||
// (`slint::wgpu_29`: `TextureImportError`). They are asserted here,
|
||||
// in the crate that owns the descriptor, because failing them does not
|
||||
// fail a build or a shader — it fails at runtime, on the frame the
|
||||
// image is handed over, and only where there is a screen to hand it
|
||||
// to. Nothing else in the test suite would notice.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let img = grey_image(&ctx, 4000);
|
||||
let shader = EditGraph::default_chain().compose();
|
||||
let t = pass.render(&img, &shader, 16, 16).expect("render");
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
t.format(),
|
||||
wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb
|
||||
),
|
||||
"import accepts only the two 8-bit RGBA formats, not {:?}",
|
||||
t.format()
|
||||
);
|
||||
assert!(
|
||||
t.usage().contains(wgpu::TextureUsages::TEXTURE_BINDING),
|
||||
"the compositor has to sample it"
|
||||
);
|
||||
assert!(
|
||||
t.usage().contains(wgpu::TextureUsages::RENDER_ATTACHMENT),
|
||||
"Slint requires this even though the adjust pass never uses it"
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DSP-1 | AC-8
|
||||
#[test]
|
||||
fn consecutive_frames_are_different_textures() {
|
||||
// Not a detail: the compositor is handed this texture rather than a
|
||||
// copy of its pixels, and Slint repaints only when the image property
|
||||
// *changes*. Two images over one texture compare equal, so writing the
|
||||
// same texture every frame would leave a slider moving the pixels on
|
||||
// the GPU and nothing at all on screen — the frame would be correct
|
||||
// and invisible, which is the worst kind of wrong.
|
||||
//
|
||||
// No display is needed to catch it, because the equality Slint tests
|
||||
// is the equality asserted here.
|
||||
let Some(ctx) = ctx() else { return };
|
||||
let mut pass = AdjustPass::new(&ctx);
|
||||
let img = grey_image(&ctx, 4000);
|
||||
let shader = EditGraph::default_chain().compose();
|
||||
|
||||
let first = pass.render(&img, &shader, 16, 16).expect("render").clone();
|
||||
let second = pass.render(&img, &shader, 16, 16).expect("render").clone();
|
||||
assert_ne!(first, second, "the compositor cannot tell these two apart");
|
||||
|
||||
// And back again, so the alternation is a rotation between two rather
|
||||
// than an allocation per frame — which at 4K would be 33 MB a frame.
|
||||
let third = pass.render(&img, &shader, 16, 16).expect("render").clone();
|
||||
assert_eq!(first, third, "a third texture was allocated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_output_resizes_with_the_viewport() {
|
||||
let Some(ctx) = ctx() else { return };
|
||||
|
||||
Reference in New Issue
Block a user