Render: pictures drawn tile by tile into any pixel window (#5624, W17b S1)

The CPU product renderer, reshaped so drawings can use it: one rasteriser (`Raster`) prepares a
scene once (appearances, projected triangles, glass sorted back to front), then draws each tile of
the picture on a canvas of its own. A sample's position comes from its whole-picture index, so tiles
put side by side are the picture drawn whole, to the byte.

- `ImageTask` (render.image) is that rasteriser over one whole-picture tile from the product camera,
  then its PNG: the same output, pinned by the previous commit's rows.
- `PictureTask` draws a `PictureRequest`: a `Projection` (the product camera, or an `OrthoFrame`,
  W17a's view frame as plain data: a `Plane` basis, a scale and a paper shift, on a
  `geom::pixel_grid::PixelGrid`), any list of `PixelWindow` tiles, `Supersample::One` (pixel centres)
  or `Two` (i + 1/4 and i + 3/4), a `BodyRole` per body (`DepthOnly` hides what lies behind it and
  draws nothing itself) and an optional background (none: transparent). Each tile comes back as
  straight-alpha sRGB RGBA rows, blended in linear light.
- The orthographic map is the frame's paper map, onto the grid, so lines drawn from the same frame
  register with the pixels (to 1e-9 mm in the test).
- Limits: 65,536 px a side, no total cap; each tile under the resolver's `RenderLimits`, as the
  product image is; at most 65,536 tiles; the prepared lists as a product image's; pixel tests up to
  a fixed 400 M a picture (128 M for a product image, as before), counted at admission by the one
  projection the raster uses, as main counts a product image's, and only where a triangle meets the
  tiles asked for.

Not here: the drawing light, stored per-tile bins (S2, measured), the paper scene's picture item
(S3), 4x sampling, and `raster::render`, the snapshot rasteriser, which stays as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cameron Wight
2026-10-26 07:02:42 +00:00
parent 016894e609
commit bf93e890e2
3 changed files with 976 additions and 178 deletions
+576 -173
View File
@@ -1,12 +1,18 @@
//! Incremental product rasterization. Every phase yields between bounded work units.
//!
//! One rasteriser draws both the product image ([`ImageTask`]: the product camera, one tile, then
//! a PNG) and pictures ([`PictureTask`]: the product camera or an orthographic view frame, any
//! pixel windows, each handed back as straight-alpha sRGB rows). It prepares once (appearances,
//! projected triangles, glass sorted back to front) and then draws each tile.
use std::collections::BTreeMap;
use serde::Serialize;
use solidcraft_geom::Vec3;
use solidcraft_geom::pixel_grid::{MAX_SIDE_PX, PixelGrid};
use solidcraft_geom::{Plane, Vec2, Vec3};
use super::{CancelToken, ErrorCode, ImageRequest, JobError, Phase, Progress, Result};
use crate::{FaceAppearance, Mat4, ProductScene, RenderFormat, RenderMode, RenderOperation, RenderRequest};
use crate::{Camera, FaceAppearance, Mat4, ProductScene, RenderFormat, RenderMode, RenderOperation, RenderRequest};
#[cfg(test)]
#[path = "image_tests.rs"]
@@ -16,8 +22,9 @@ pub const MAX_TRIANGLES: u64 = 262_144;
pub const MAX_GEOMETRY_BYTES: u64 = 128 * 1024 * 1024;
pub const MAX_JOB_BYTES: u64 = 384 * 1024 * 1024;
pub const MAX_PIXEL_TESTS: u64 = 128_000_000;
/// A picture's, however large: a fixed bound on its time, about a minute at worst on one thread.
pub const MAX_PICTURE_PIXEL_TESTS: u64 = 400_000_000;
const MAX_STEP: usize = 16_384;
const SS: usize = 2;
#[derive(Clone, Copy, Debug, Serialize)]
pub struct Admission {
@@ -83,7 +90,10 @@ pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission>
pub(crate) fn admit_base(scene: &ProductScene, request: &ImageRequest) -> Result<Admission> {
let mut admission = admit_raster(admit_source(scene, request, MAX_TRIANGLES)?)?;
admission.projected_pixel_tests = Some(projected_pixel_tests(scene, request)?);
let projection = Projection::Camera { camera: request.camera, width: request.width, height: request.height };
let view = projection.view(scene, Supersample::Two.factor())?;
let whole = tile_samples(&[PixelWindow { x0: 0, y0: 0, x1: request.width, y1: request.height }], Supersample::Two.factor());
admission.projected_pixel_tests = Some(projected_pixel_tests(scene, &view, &[], whole, MAX_PIXEL_TESTS)?);
Ok(admission)
}
@@ -130,8 +140,16 @@ pub(crate) fn admit_raytrace_source(scene: &ProductScene, request: &ImageRequest
}
fn admit_source_with_budget(scene: &ProductScene, request: &ImageRequest, max_triangles: u64, budget: super::CpuBudget) -> Result<Source> {
validate_request(request)?;
let limits = budget.limits();
admit_source_with_limits(scene, request, max_triangles, limits.max_bodies, limits.max_geometry_bytes, budget.image_limits(false))
admit_source_with_limits(
scene,
(request.width, request.height),
max_triangles,
limits.max_bodies,
limits.max_geometry_bytes,
budget.image_limits(false),
)
}
/// A registered executor may request a larger finite source envelope without
@@ -156,17 +174,12 @@ pub fn admit_geometry_with_limits(
{
return Err(limit("registered source envelope exceeds implementation bounds"));
}
Ok(admit_source_with_limits(scene, request, max_triangles, max_bodies, max_geometry_bytes, image_limits)?.admission)
validate_request(request)?;
Ok(admit_source_with_limits(scene, (request.width, request.height), max_triangles, max_bodies, max_geometry_bytes, image_limits)?.admission)
}
fn admit_source_with_limits(
scene: &ProductScene,
request: &ImageRequest,
max_triangles: u64,
max_bodies: usize,
max_geometry_bytes: u64,
image_limits: crate::RenderLimits,
) -> Result<Source> {
/// What an image request must be, whichever executor runs it.
fn validate_request(request: &ImageRequest) -> Result<()> {
if !request.camera.is_valid() {
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid image camera"));
}
@@ -176,6 +189,18 @@ fn admit_source_with_limits(
if request.integrator == super::Integrator::Raytrace && request.studio_weights.is_some() {
return Err(JobError::new(ErrorCode::InvalidRequest, "CPU ray tracing uses explicit lights rather than studio_weights"));
}
Ok(())
}
/// `(width, height)`: the image (or tile) size whose pixel scratch is estimated.
fn admit_source_with_limits(
scene: &ProductScene,
(width, height): (u32, u32),
max_triangles: u64,
max_bodies: usize,
max_geometry_bytes: u64,
image_limits: crate::RenderLimits,
) -> Result<Source> {
if scene.meshes.len() > max_bodies {
return Err(limit("too many product bodies"));
}
@@ -189,8 +214,8 @@ fn admit_source_with_limits(
backend: None,
mode: RenderMode::Preview,
operation: RenderOperation::Image,
width: u64::from(request.width),
height: u64::from(request.height),
width: u64::from(width),
height: u64::from(height),
format: Some(RenderFormat::Png),
features: Vec::new(),
policy: Default::default(),
@@ -231,7 +256,7 @@ fn admit_source_with_limits(
return Err(invalid("invalid body opacity"));
}
}
let encoded_limit = u64::from(request.width) * u64::from(request.height) * 8 + 65_536;
let encoded_limit = u64::from(width) * u64::from(height) * 8 + 65_536;
Ok(Source {
admission: Admission {
triangles,
@@ -272,26 +297,278 @@ fn admit_raster(source: Source) -> Result<Admission> {
Ok(a)
}
/// The same clipping, supersampling and bounds govern admission and raster work.
struct Projection {
fn add_pixel_tests(sum: &mut u64, triangle: &Triangle, within: [usize; 4], max: u64) -> Result<()> {
let Some([x0, x1, y0, y1]) = narrowed(triangle.bounds, within) else {
return Ok(());
};
let tests = ((x1 - x0 + 1) as u64).checked_mul((y1 - y0 + 1) as u64).ok_or_else(|| limit("pixel work overflow"))?;
*sum = sum.checked_add(tests).ok_or_else(|| limit("pixel work overflow"))?;
if *sum > max {
return Err(limit(&format!("projected product exceeds {max} pixel tests; reduce image dimensions or visible geometry")));
}
Ok(())
}
fn validate_face(face: &FaceAppearance) -> Result<()> {
if !face.opacity.is_finite() || !(0.0..=1.0).contains(&face.opacity) {
return Err(invalid("invalid face opacity"));
}
Ok(())
}
/// Where sample bounds `[x0, x1, y0, y1]` (inclusive) meet `within`, if they do.
fn narrowed([x0, x1, y0, y1]: [usize; 4], [from_x, to_x, from_y, to_y]: [usize; 4]) -> Option<[usize; 4]> {
let bounds = [x0.max(from_x), x1.min(to_x), y0.max(from_y), y1.min(to_y)];
(bounds[0] <= bounds[1] && bounds[2] <= bounds[3]).then_some(bounds)
}
/// The sample bounds round `tiles`, each non-empty, at `ss` samples a pixel each way: only these are
/// drawn, so only these are counted, however large the canvas is.
fn tile_samples(tiles: &[PixelWindow], ss: usize) -> [usize; 4] {
let (x0, x1) = (tiles.iter().map(|t| t.x0).min().unwrap_or(0), tiles.iter().map(|t| t.x1).max().unwrap_or(1));
let (y0, y1) = (tiles.iter().map(|t| t.y0).min().unwrap_or(0), tiles.iter().map(|t| t.y1).max().unwrap_or(1));
[x0 as usize * ss, x1 as usize * ss - 1, y0 as usize * ss, y1 as usize * ss - 1]
}
fn projected_pixel_tests(scene: &ProductScene, view: &View, roles: &[BodyRole], within: [usize; 4], max: u64) -> Result<u64> {
let mut tests = 0;
for (role, body) in scene.meshes.iter().enumerate() {
let mut looks = BTreeMap::new();
for face in &body.faces {
validate_face(face)?;
// Match the document and task policy: the last assignment wins.
looks.insert(face.face, face);
}
for index in 0..body.mesh.triangles.len() {
let look = body.mesh.tri_face.get(index).and_then(|face| looks.get(face)).copied();
if let Some(triangle) = view.project(body, index, look, paints(roles, role))? {
add_pixel_tests(&mut tests, &triangle, within, max)?;
}
}
}
Ok(tests)
}
fn reserve<T>(count: usize) -> Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "could not reserve render work memory"))?;
Ok(v)
}
/// How a picture's pixels are sampled.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Supersample {
/// One sample, at the pixel's centre.
One,
/// 2 × 2 samples, a quarter and three quarters of the way across the pixel, averaged in
/// linear light (the product image's).
#[default]
Two,
}
impl Supersample {
fn factor(self) -> usize {
match self {
Self::One => 1,
Self::Two => 2,
}
}
}
/// What a body is in a picture.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum BodyRole {
/// Drawn in its own colours.
#[default]
Painted,
/// Hides what lies behind it and draws nothing itself (a reference part that hides the model
/// while only its lines are drawn).
DepthOnly,
}
/// An orthographic view frame: a point `p` lies on paper at `basis.to_local(p) · scale + shift`
/// (millimetres, paper y up), seen from the side `basis`'s normal (x × y) points to.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OrthoFrame {
pub basis: Plane,
/// Paper millimetres per model millimetre.
pub scale: f64,
/// Where the basis's own origin lies on paper.
pub shift: Vec2,
}
impl OrthoFrame {
/// Where `p` lies on paper.
pub fn to_paper(&self, p: Vec3) -> Vec2 {
self.basis.to_local(p) * self.scale + self.shift
}
}
/// What a picture shows and how many pixels it has.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Projection {
/// The product camera's view, `width × height` pixels: what [`ImageTask`] draws.
Camera { camera: Camera, width: u32, height: u32 },
/// An orthographic view frame, on the pixel grid of a paper rectangle.
Ortho { frame: OrthoFrame, grid: PixelGrid },
}
impl Projection {
fn size(&self) -> (u32, u32) {
match self {
Self::Camera { width, height, .. } => (*width, *height),
Self::Ortho { grid, .. } => (grid.width, grid.height),
}
}
/// The clip-space map onto a canvas of `ss` samples per pixel each way, and the eye.
fn view(&self, scene: &ProductScene, ss: usize) -> Result<View> {
let (width, height) = self.size();
if width == 0 || height == 0 || width > MAX_SIDE_PX || height > MAX_SIDE_PX {
return Err(limit(&format!("a picture is 1 to {MAX_SIDE_PX} px on a side")));
}
let (sw, sh) = (width as usize * ss, height as usize * ss);
match *self {
Self::Camera { camera, .. } => {
if !camera.is_valid() {
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid image camera"));
}
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(camera.target);
if !radius.is_finite() {
return Err(invalid("invalid product bounds"));
}
Ok(View {
vp: camera.view_proj(sw as f64 / sh as f64, radius),
back: camera.back(),
eye: (camera.fov > 0.0).then(|| camera.eye()),
reference: camera.target,
sw,
sh,
})
}
Self::Ortho { frame, grid } => {
let request = |message: &str| JobError::new(ErrorCode::InvalidRequest, message);
solidcraft_geom::section::validate_plane(&frame.basis).map_err(|_| request("an orthographic frame needs an orthonormal basis"))?;
if !frame.scale.is_finite() || frame.scale <= 0.0 || !frame.shift.is_finite() {
return Err(request("an orthographic frame needs a finite scale above 0 and a finite shift"));
}
if ![grid.x0, grid.y0, grid.pitch].iter().all(|v| v.is_finite()) || grid.pitch <= 0.0 {
return Err(request("a picture's pixel grid needs a finite position and pixel size"));
}
// Depth runs from the nearest corner of the design's box (0) to the farthest (1).
let (o, n) = (frame.basis.origin, frame.basis.normal());
let b = &scene.bounds;
let (mut near, mut far) = (f64::INFINITY, f64::NEG_INFINITY);
for corner in 0..8 {
let pick = |bit: usize, lo: f64, hi: f64| if corner & bit == 0 { lo } else { hi };
let p = Vec3::new(pick(1, b.min.x, b.max.x), pick(2, b.min.y, b.max.y), pick(4, b.min.z, b.max.z));
let depth = -(p - o).dot(n);
(near, far) = (near.min(depth), far.max(depth));
}
let range = if far - near > 1e-9 { far - near } else { 1.0 };
let near = if near.is_finite() { near } else { 0.0 };
if !range.is_finite() {
return Err(invalid("invalid product bounds"));
}
// Paper x and y onto -1..1 across the grid (y up, as clip space), depth onto 0..1.
let (bx, by) = (frame.basis.x, frame.basis.y);
let (kx, ky) = (2.0 * frame.scale / (f64::from(width) * grid.pitch), 2.0 * frame.scale / (f64::from(height) * grid.pitch));
let cx = kx * -o.dot(bx) + 2.0 * (frame.shift.x - grid.x0) / (f64::from(width) * grid.pitch) - 1.0;
let cy = ky * -o.dot(by) + 2.0 * (frame.shift.y - grid.y0) / (f64::from(height) * grid.pitch) - 1.0;
let (bx, by, nv) = ([bx.x, bx.y, bx.z], [by.x, by.y, by.z], [n.x, n.y, n.z]);
let column = |i: usize| [kx * bx[i], ky * by[i], -nv[i] / range, 0.0];
let vp = Mat4([column(0), column(1), column(2), [cx, cy, (o.dot(n) - near) / range, 1.0]]);
if vp.0.iter().flatten().any(|v| !v.is_finite()) {
return Err(request("an orthographic frame too large for its pixel grid"));
}
Ok(View { vp, back: n, eye: None, reference: o, sw, sh })
}
}
}
}
/// A window of a picture's pixels: columns `x0..x1` and rows `y0..y1`, row 0 at the top.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PixelWindow {
pub x0: u32,
pub y0: u32,
pub x1: u32,
pub y1: u32,
}
impl PixelWindow {
fn width(&self) -> usize {
self.x1.saturating_sub(self.x0) as usize
}
fn height(&self) -> usize {
self.y1.saturating_sub(self.y0) as usize
}
}
/// At most this many tiles in one picture.
pub const MAX_TILES: usize = 65_536;
/// A picture of a product scene, drawn tile by tile.
#[derive(Clone, Debug, PartialEq)]
pub struct PictureRequest {
pub projection: Projection,
/// The windows drawn, in order: each is handed back as its rows ([`PicturePoll::Tile`]).
pub tiles: Vec<PixelWindow>,
pub supersample: Supersample,
/// One role per body of the scene, in order; empty paints every body.
pub roles: Vec<BodyRole>,
/// `None`: a transparent background.
pub background: Option<[u8; 3]>,
}
/// The checks a picture passes before anything is allocated, and what it costs: the prepared
/// lists as a product image's, the pixel scratch of its largest tile.
pub fn admit_picture(scene: &ProductScene, request: &PictureRequest) -> Result<Admission> {
let (width, height) = request.projection.size();
if request.tiles.is_empty() || request.tiles.len() > MAX_TILES {
return Err(JobError::new(ErrorCode::InvalidRequest, "a picture is drawn in 1 to 65536 tiles"));
}
if request.tiles.iter().any(|t| t.x0 >= t.x1 || t.y0 >= t.y1 || t.x1 > width || t.y1 > height) {
return Err(JobError::new(ErrorCode::InvalidRequest, "a tile is an empty window or reaches outside its picture"));
}
if !request.roles.is_empty() && request.roles.len() != scene.meshes.len() {
return Err(JobError::new(ErrorCode::InvalidRequest, "a picture gives every body a role, or none"));
}
let widest = request.tiles.iter().map(|t| t.x1 - t.x0).max().unwrap_or(1);
let tallest = request.tiles.iter().map(|t| t.y1 - t.y0).max().unwrap_or(1);
let budget = super::CpuBudget::Compact;
let limits = budget.limits();
let mut admission = admit_raster(admit_source_with_limits(
scene,
(widest, tallest),
MAX_TRIANGLES,
limits.max_bodies,
limits.max_geometry_bytes,
budget.image_limits(false),
)?)?;
let view = request.projection.view(scene, request.supersample.factor())?;
let within = tile_samples(&request.tiles, request.supersample.factor());
admission.projected_pixel_tests = Some(projected_pixel_tests(scene, &view, &request.roles, within, MAX_PICTURE_PIXEL_TESTS)?);
Ok(admission)
}
/// The map onto the canvas, and where the eye is.
struct View {
vp: Mat4,
/// Toward the eye (an orthographic view's direction).
back: Vec3,
target: Vec3,
/// A perspective view's eye.
eye: Option<Vec3>,
/// A point the glass's back-to-front depth is measured from.
reference: Vec3,
/// The canvas's samples each way.
sw: usize,
sh: usize,
}
impl Projection {
fn new(scene: &ProductScene, request: &ImageRequest) -> Result<Self> {
let (sw, sh) = (request.width as usize * SS, request.height as usize * SS);
let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(request.camera.target);
if !radius.is_finite() {
return Err(invalid("invalid product bounds"));
}
Ok(Self { vp: request.camera.view_proj(sw as f64 / sh as f64, radius), back: request.camera.back(), target: request.camera.target, sw, sh })
}
fn project(&self, body: &crate::ProductMesh, triangle: usize, look: Option<&FaceAppearance>) -> Result<Option<Triangle>> {
/// The same clipping, supersampling and bounds govern admission and raster work.
impl View {
fn project(&self, body: &crate::ProductMesh, triangle: usize, look: Option<&FaceAppearance>, paint: bool) -> Result<Option<Triangle>> {
let mesh = &body.mesh;
let indices = mesh.triangles.get(triangle).ok_or_else(|| invalid("missing product triangle"))?;
let vertices = indices.map(|i| mesh.positions.get(i as usize).copied());
@@ -306,9 +583,14 @@ impl Projection {
}
// Transmission is physical optical data, never a request to change CPU preview.
// Keep the existing scalar sorted-alpha shading for glass previews.
let (colour, opacity, surface) = look.map_or((body.color, body.opacity, body.surface.filter(|_| body.transmission.is_none())), |look| {
(look.color, look.opacity, look.surface.filter(|_| look.transmission.is_none()))
});
let (colour, opacity, surface) = if !paint {
// A depth-only body is solid, whatever it is made of, and is never shaded.
(body.color, 1.0, None)
} else {
look.map_or((body.color, body.opacity, body.surface.filter(|_| body.transmission.is_none())), |look| {
(look.color, look.opacity, look.surface.filter(|_| look.transmission.is_none()))
})
};
if surface.is_some() && opacity != 1.0 {
return Err(invalid("opaque PBR requires opacity 1"));
}
@@ -345,7 +627,7 @@ impl Projection {
if !area.is_finite() {
return Err(invalid("invalid projected area"));
}
let centre_depth = (world[0] / 3.0 + world[1] / 3.0 + world[2] / 3.0 - self.target).dot(self.back);
let centre_depth = (world[0] / 3.0 + world[1] / 3.0 + world[2] / 3.0 - self.reference).dot(self.back);
if !centre_depth.is_finite() {
return Err(invalid("invalid triangle depth"));
}
@@ -357,6 +639,7 @@ impl Projection {
colour: colour.map(decode),
opacity,
surface,
paint,
centre_depth,
area,
bounds: [x0 as usize, x1 as usize, y0 as usize, y1 as usize],
@@ -366,47 +649,9 @@ impl Projection {
}
}
fn add_pixel_tests(sum: &mut u64, triangle: &Triangle) -> Result<()> {
let [x0, x1, y0, y1] = triangle.bounds;
let tests = ((x1 - x0 + 1) as u64).checked_mul((y1 - y0 + 1) as u64).ok_or_else(|| limit("pixel work overflow"))?;
*sum = sum.checked_add(tests).ok_or_else(|| limit("pixel work overflow"))?;
if *sum > MAX_PIXEL_TESTS {
return Err(limit("projected product exceeds 128000000 pixel tests; reduce image dimensions or visible geometry"));
}
Ok(())
}
fn validate_face(face: &FaceAppearance) -> Result<()> {
if !face.opacity.is_finite() || !(0.0..=1.0).contains(&face.opacity) {
return Err(invalid("invalid face opacity"));
}
Ok(())
}
fn projected_pixel_tests(scene: &ProductScene, request: &ImageRequest) -> Result<u64> {
let projection = Projection::new(scene, request)?;
let mut tests = 0;
for body in &scene.meshes {
let mut looks = BTreeMap::new();
for face in &body.faces {
validate_face(face)?;
// Match the document and task policy: the last assignment wins.
looks.insert(face.face, face);
}
for index in 0..body.mesh.triangles.len() {
let look = body.mesh.tri_face.get(index).and_then(|face| looks.get(face)).copied();
if let Some(triangle) = projection.project(body, index, look)? {
add_pixel_tests(&mut tests, &triangle)?;
}
}
}
Ok(tests)
}
fn reserve<T>(count: usize) -> Result<Vec<T>> {
let mut v = Vec::new();
v.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "could not reserve render work memory"))?;
Ok(v)
/// Whether the body at `index` is painted: roles are one per body, or empty for all painted.
fn paints(roles: &[BodyRole], index: usize) -> bool {
roles.get(index).is_none_or(|role| *role == BodyRole::Painted)
}
#[derive(Clone, Copy, Default)]
@@ -430,14 +675,17 @@ struct Triangle {
colour: [f64; 3],
opacity: f64,
surface: Option<crate::surface::OpaqueSurface>,
/// Off for a depth-only body.
paint: bool,
centre_depth: f64,
area: f64,
/// Whole-canvas sample bounds `[x0, x1, y0, y1]` (inclusive), narrowed to the tile drawn.
bounds: [usize; 4],
x: usize,
y: usize,
}
#[derive(Clone, Copy, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Stage {
Looks,
Prepare,
@@ -455,15 +703,32 @@ pub enum TaskPoll {
Complete(Vec<u8>),
}
/// CPU product renderer with cooperative preparation, sorting, raster and PNG encoding.
/// A step of a [`PictureTask`].
pub enum PicturePoll {
Pending(Progress),
/// One tile, in the order asked: its straight-alpha sRGB RGBA rows, top row first.
Tile(PixelWindow, Vec<u8>),
/// Every tile has been handed back.
Complete,
}
/// The CPU rasteriser both the product image and pictures run: it prepares the scene once
/// (appearances, projected triangles, glass sorted back to front), then draws each tile on a
/// canvas of its own, sampling at the same whole-picture positions whichever tile a sample falls
/// in, so tiles put side by side are the picture drawn whole.
/// Transparency sorts triangle centres and blends in linear space; it is not refraction.
pub struct ImageTask {
struct Raster {
scene: ProductScene,
request: ImageRequest,
admission: Admission,
cancel: CancelToken,
stage: Stage,
projection: Projection,
view: View,
ss: usize,
roles: Vec<BodyRole>,
background: Option<[u8; 3]>,
tiles: Vec<PixelWindow>,
tile: usize,
studio_weights: Option<[f64; 3]>,
looks: Vec<BTreeMap<u32, FaceAppearance>>,
body: usize,
item: usize,
@@ -481,40 +746,60 @@ pub struct ImageTask {
pixels: Vec<[u8; 4]>,
depth: Vec<f32>,
pixel_tests: u64,
max_pixel_tests: u64,
/// The sample bounds round the tiles ([`tile_samples`]).
within: [usize; 4],
pixel_done: u64,
draw: usize,
triangle: Option<Triangle>,
output: Vec<u8>,
encoder: Option<super::encoding::PngEncoding>,
}
impl ImageTask {
pub fn new(scene: ProductScene, request: ImageRequest, cancel: CancelToken) -> Result<Self> {
cancel.check()?;
if request.integrator != super::Integrator::Raster {
return Err(JobError::new(ErrorCode::InvalidRequest, "raster task cannot execute a raytrace request"));
}
let admission = admit(&scene, &request)?;
let projection = Projection::new(&scene, &request)?;
let (sw, sh) = (projection.sw, projection.sh);
enum RasterPoll {
Pending,
Tile(PixelWindow, Vec<u8>),
}
impl Raster {
fn new(
scene: ProductScene,
request: PictureRequest,
studio_weights: Option<[f64; 3]>,
admission: Admission,
max_pixel_tests: u64,
cancel: CancelToken,
) -> Result<Self> {
let PictureRequest { projection, tiles, supersample, roles, background } = request;
let ss = supersample.factor();
let view = projection.view(&scene, ss)?;
let within = tile_samples(&tiles, ss);
let samples = tiles.iter().map(|t| t.width() * t.height()).max().unwrap_or(0) * ss * ss;
let pixels = tiles.iter().map(|t| t.width() * t.height()).max().unwrap_or(0);
let looks = reserve(scene.meshes.len())?;
let (opaque, glass, scratch) =
(reserve(admission.triangles as usize)?, reserve(admission.triangles as usize)?, reserve(admission.triangles as usize)?);
let (pixels, depth, output) = (reserve(sw * sh)?, reserve(sw * sh)?, reserve(request.width as usize * request.height as usize * 4)?);
Ok(Self {
scene,
request,
admission,
cancel,
stage: Stage::Looks,
projection,
looks,
body: 0,
item: 0,
prepare_done: 0,
opaque,
glass,
scratch,
pixels: reserve(samples)?,
depth: reserve(samples)?,
output: reserve(pixels * 4)?,
scene,
admission,
cancel,
stage: Stage::Looks,
view,
ss,
roles,
background,
tiles,
tile: 0,
studio_weights,
body: 0,
item: 0,
prepare_done: 0,
sort_width: 1,
sort_block: 0,
sort_left: 0,
@@ -522,65 +807,62 @@ impl ImageTask {
sort_mid: 0,
sort_end: 0,
sort_dst: 0,
pixels,
depth,
pixel_tests: 0,
max_pixel_tests,
within,
pixel_done: 0,
draw: 0,
triangle: None,
output,
encoder: None,
})
}
pub fn progress(&self) -> Progress {
/// A sample nothing has been drawn on.
fn clear(&self) -> [u8; 4] {
self.background.map_or([0; 4], |[r, g, b]| [r, g, b, 255])
}
fn window(&self) -> Result<PixelWindow> {
self.tiles.get(self.tile).copied().ok_or_else(|| invalid("missing picture tile"))
}
fn progress(&self) -> Progress {
match self.stage {
Stage::Looks | Stage::Prepare | Stage::Sort => Progress {
phase: Phase::Preparing,
done: self.prepare_done,
total: self.admission.face_appearances + self.admission.triangles + self.scene.meshes.len() as u64 + self.glass.len() as u64 * 19,
},
Stage::Clear | Stage::Opaque | Stage::Glass | Stage::Downsample => Progress {
phase: Phase::Running,
done: self.pixel_done,
total: self.pixel_tests
+ (self.projection.sw * self.projection.sh) as u64
+ u64::from(self.request.width) * u64::from(self.request.height),
},
Stage::Encode => Progress {
phase: Phase::Encoding,
done: self.encoder.as_ref().map_or(0, |encoder| encoder.row() as u64),
total: u64::from(self.request.height),
},
Stage::Clear | Stage::Opaque | Stage::Glass | Stage::Downsample | Stage::Encode => {
// In u64: a large picture's canvas outgrows a 32-bit usize.
let [x0, x1, y0, y1] = self.within;
let samples = (x1 - x0 + 1) as u64 * (y1 - y0 + 1) as u64;
let total = self.pixel_tests + samples + samples / (self.ss * self.ss) as u64;
// Overlapping tiles can draw more than the picture once.
Progress { phase: Phase::Running, done: self.pixel_done.min(total), total }
}
Stage::Done => Progress { phase: Phase::Completed, done: 1, total: 1 },
}
}
/// At most 16384 simple units; encoding additionally yields after at most four rows.
pub fn step(&mut self, units: usize) -> Result<TaskPoll> {
let mut rows = 0;
/// At most `units` simple units, or up to the end of a tile.
fn step(&mut self, units: usize) -> Result<RasterPoll> {
for _ in 0..units.clamp(1, MAX_STEP) {
self.cancel.check()?;
match self.stage {
Stage::Looks => self.look_step()?,
Stage::Prepare => self.prepare_step()?,
Stage::Sort => self.sort_step()?,
Stage::Clear => self.clear_step(),
Stage::Clear => self.clear_step()?,
Stage::Opaque | Stage::Glass => self.draw_step()?,
Stage::Downsample => self.downsample_step()?,
Stage::Encode => {
if let Some(png) = self.encode_step()? {
return Ok(TaskPoll::Complete(png));
}
rows += 1;
if rows >= 4 {
break;
Stage::Downsample => {
if let Some(tile) = self.downsample_step()? {
return Ok(tile);
}
}
Stage::Done => return Err(JobError::new(ErrorCode::NotCompleted, "render result already consumed")),
Stage::Encode | Stage::Done => return Err(JobError::new(ErrorCode::NotCompleted, "render result already consumed")),
}
}
Ok(TaskPoll::Pending(self.progress()))
Ok(RasterPoll::Pending)
}
fn look_step(&mut self) -> Result<()> {
@@ -611,7 +893,7 @@ impl ImageTask {
let body = self.scene.meshes.get(draw.body as usize).ok_or_else(|| invalid("missing product body"))?;
let look =
self.looks.get(draw.body as usize).and_then(|looks| body.mesh.tri_face.get(draw.triangle as usize).and_then(|face| looks.get(face)));
self.projection.project(body, draw.triangle as usize, look)
self.view.project(body, draw.triangle as usize, look, paints(&self.roles, draw.body as usize))
}
fn prepare_step(&mut self) -> Result<()> {
@@ -628,7 +910,7 @@ impl ImageTask {
}
let mut draw = Draw { body: self.body as u32, triangle: self.item as u32, depth: 0.0 };
if let Some(triangle) = self.project(draw)? {
add_pixel_tests(&mut self.pixel_tests, &triangle)?;
add_pixel_tests(&mut self.pixel_tests, &triangle, self.within, self.max_pixel_tests)?;
draw.depth = triangle.centre_depth;
if triangle.opacity >= 1.0 {
self.opaque.push(draw);
@@ -688,19 +970,24 @@ impl ImageTask {
Ok(())
}
fn clear_step(&mut self) {
if self.pixels.len() >= self.projection.sw * self.projection.sh {
fn clear_step(&mut self) -> Result<()> {
let window = self.window()?;
if self.pixels.len() >= window.width() * window.height() * self.ss * self.ss {
self.stage = Stage::Opaque;
self.draw = 0;
return;
return Ok(());
}
let [r, g, b] = self.request.background;
self.pixels.push(if self.request.transparent_background { [0; 4] } else { [r, g, b, 255] });
self.pixels.push(self.clear());
self.depth.push(f32::INFINITY);
self.pixel_done += 1;
Ok(())
}
fn draw_step(&mut self) -> Result<()> {
let window = self.window()?;
// The tile's canvas samples, in whole-canvas coordinates.
let (cx0, cx1) = (window.x0 as usize * self.ss, window.x1 as usize * self.ss - 1);
let (cy0, cy1) = (window.y0 as usize * self.ss, window.y1 as usize * self.ss - 1);
if self.triangle.is_none() {
let list = if self.stage == Stage::Opaque { &self.opaque } else { &self.glass };
let Some(draw) = list.get(self.draw).copied() else {
@@ -708,15 +995,22 @@ impl ImageTask {
self.draw = 0;
return Ok(());
};
self.triangle = self.project(draw)?;
// Only the part of the triangle's bounds inside this tile.
self.triangle = self.project(draw)?.and_then(|mut t| {
narrowed(t.bounds, [cx0, cx1, cy0, cy1]).map(|bounds| {
(t.bounds, t.x, t.y) = (bounds, bounds[0], bounds[2]);
t
})
});
if self.triangle.is_none() {
self.draw += 1;
}
return Ok(());
}
let clear = self.clear();
let triangle = self.triangle.as_mut().ok_or_else(|| invalid("missing raster triangle"))?;
let eye = (self.request.camera.fov > 0.0).then(|| self.request.camera.eye());
raster_pixel(triangle, self.projection.sw, self.projection.back, eye, self.request.studio_weights, &mut self.pixels, &mut self.depth)?;
let tile = (cx0, cy0, cx1 - cx0 + 1);
raster_pixel(triangle, tile, self.view.back, self.view.eye, self.studio_weights, clear, &mut self.pixels, &mut self.depth)?;
triangle.x += 1;
if triangle.x > triangle.bounds[1] {
triangle.x = triangle.bounds[0];
@@ -730,28 +1024,37 @@ impl ImageTask {
Ok(())
}
fn downsample_step(&mut self) -> Result<()> {
let width = self.request.width as usize;
/// One output pixel of the tile; the tile's rows once its last pixel is in.
fn downsample_step(&mut self) -> Result<Option<RasterPoll>> {
let window = self.window()?;
let (width, ss) = (window.width(), self.ss);
let index = self.output.len() / 4;
if index >= width * self.request.height as usize {
self.encoder =
Some(super::encoding::PngEncoding::new(self.request.width, self.request.height, self.admission.encoded_limit, self.cancel.clone())?);
self.stage = Stage::Encode;
// Pixels and prepared geometry are no longer needed during PNG compression.
self.pixels = Vec::new();
self.depth = Vec::new();
self.opaque = Vec::new();
self.glass = Vec::new();
self.scratch = Vec::new();
self.looks = Vec::new();
self.scene.meshes.clear();
return Ok(());
if index >= width * window.height() {
let rows = std::mem::take(&mut self.output);
self.tile += 1;
if self.tile < self.tiles.len() {
self.output = reserve(rows.capacity())?;
self.pixels.clear();
self.depth.clear();
self.stage = Stage::Clear;
} else {
// Pixels and prepared geometry are no longer needed.
self.pixels = Vec::new();
self.depth = Vec::new();
self.opaque = Vec::new();
self.glass = Vec::new();
self.scratch = Vec::new();
self.looks = Vec::new();
self.scene.meshes.clear();
self.stage = Stage::Done;
}
return Ok(Some(RasterPoll::Tile(window, rows)));
}
let (x, y) = (index % width, index / width);
let (mut colour, mut alpha) = ([0.0; 3], 0.0);
for dy in 0..SS {
for dx in 0..SS {
let p = self.pixels.get((y * SS + dy) * self.projection.sw + x * SS + dx).ok_or_else(|| invalid("missing downsample pixel"))?;
for dy in 0..ss {
for dx in 0..ss {
let p = self.pixels.get((y * ss + dy) * width * ss + x * ss + dx).ok_or_else(|| invalid("missing downsample pixel"))?;
let a = f64::from(p[3]) / 255.0;
for (sum, channel) in colour.iter_mut().zip(p.iter().take(3)) {
*sum += decode(*channel) * a;
@@ -760,32 +1063,127 @@ impl ImageTask {
}
}
let rgb = colour.map(|v| if alpha > 1e-12 { encode(v / alpha) } else { 0 });
self.output.extend_from_slice(&[rgb[0], rgb[1], rgb[2], (alpha * 255.0 / (SS * SS) as f64).round() as u8]);
self.output.extend_from_slice(&[rgb[0], rgb[1], rgb[2], (alpha * 255.0 / (ss * ss) as f64).round() as u8]);
self.pixel_done += 1;
Ok(())
}
fn encode_step(&mut self) -> Result<Option<Vec<u8>>> {
let result = self.encoder.as_mut().ok_or_else(|| invalid("missing PNG writer"))?.step(&self.output)?;
if result.is_some() {
self.output = Vec::new();
self.stage = Stage::Done;
}
Ok(result)
Ok(None)
}
}
/// A picture drawn tile by tile ([`PictureRequest`]).
pub struct PictureTask {
raster: Raster,
}
impl PictureTask {
pub fn new(scene: ProductScene, request: PictureRequest, cancel: CancelToken) -> Result<Self> {
cancel.check()?;
let admission = admit_picture(&scene, &request)?;
let max_tests = MAX_PICTURE_PIXEL_TESTS;
Ok(Self { raster: Raster::new(scene, request, None, admission, max_tests, cancel)? })
}
pub fn progress(&self) -> Progress {
self.raster.progress()
}
/// At most 16384 simple units, or up to the end of a tile.
pub fn step(&mut self, units: usize) -> Result<PicturePoll> {
if self.raster.stage == Stage::Done {
return Ok(PicturePoll::Complete);
}
Ok(match self.raster.step(units)? {
RasterPoll::Pending => PicturePoll::Pending(self.raster.progress()),
RasterPoll::Tile(window, rows) => PicturePoll::Tile(window, rows),
})
}
}
/// CPU product renderer with cooperative preparation, sorting, raster and PNG encoding: a
/// [`Raster`] of one whole-picture tile from the product camera, then its PNG.
pub struct ImageTask {
raster: Raster,
width: u32,
height: u32,
output: Vec<u8>,
encoder: Option<super::encoding::PngEncoding>,
}
impl ImageTask {
pub fn new(scene: ProductScene, request: ImageRequest, cancel: CancelToken) -> Result<Self> {
cancel.check()?;
if request.integrator != super::Integrator::Raster {
return Err(JobError::new(ErrorCode::InvalidRequest, "raster task cannot execute a raytrace request"));
}
let admission = admit(&scene, &request)?;
let (width, height) = (request.width, request.height);
let picture = PictureRequest {
projection: Projection::Camera { camera: request.camera, width, height },
tiles: vec![PixelWindow { x0: 0, y0: 0, x1: width, y1: height }],
supersample: Supersample::Two,
roles: Vec::new(),
background: (!request.transparent_background).then_some(request.background),
};
let raster = Raster::new(scene, picture, request.studio_weights, admission, MAX_PIXEL_TESTS, cancel)?;
Ok(Self { raster, width, height, output: Vec::new(), encoder: None })
}
#[cfg(test)]
fn stage(&self) -> Stage {
self.raster.stage
}
pub fn progress(&self) -> Progress {
match (&self.encoder, self.raster.stage) {
(Some(encoder), Stage::Encode) => Progress { phase: Phase::Encoding, done: encoder.row() as u64, total: u64::from(self.height) },
_ => self.raster.progress(),
}
}
/// At most 16384 simple units; encoding additionally yields after at most four rows.
pub fn step(&mut self, units: usize) -> Result<TaskPoll> {
if let Some(encoder) = self.encoder.as_mut() {
for _ in 0..units.clamp(1, 4) {
self.raster.cancel.check()?;
if let Some(png) = encoder.step(&self.output)? {
self.output = Vec::new();
self.encoder = None;
self.raster.stage = Stage::Done;
return Ok(TaskPoll::Complete(png));
}
}
return Ok(TaskPoll::Pending(self.progress()));
}
if self.raster.stage == Stage::Done {
return Err(JobError::new(ErrorCode::NotCompleted, "render result already consumed"));
}
if let RasterPoll::Tile(_, rows) = self.raster.step(units)? {
self.output = rows;
let limit = self.raster.admission.encoded_limit;
self.encoder = Some(super::encoding::PngEncoding::new(self.width, self.height, limit, self.raster.cancel.clone())?);
// The tile is the whole picture: the raster has handed it over and the PNG is written.
self.raster.stage = Stage::Encode;
}
Ok(TaskPoll::Pending(self.progress()))
}
}
/// Shades and stores one sample: the triangle's at its cursor `(t.x, t.y)`, in a tile whose
/// canvas starts at whole-canvas sample `(x0, y0)` and is `width` samples wide. A depth-only
/// triangle in front puts back `clear`, so it hides what was drawn behind it in either order.
#[allow(clippy::too_many_arguments)]
fn raster_pixel(
t: &Triangle,
width: usize,
(x0, y0, width): (usize, usize, usize),
back: Vec3,
eye: Option<Vec3>,
studio_weights: Option<[f64; 3]>,
clear: [u8; 4],
pixels: &mut [[u8; 4]],
depth: &mut [f32],
) -> Result<()> {
let [a, b, c] = t.points;
// The sample's position from its whole-canvas integer index, so it is the same number in
// every tile and tiles side by side give the whole picture exactly.
let (x, y) = (t.x as f64 + 0.5, t.y as f64 + 0.5);
let w0 = ((b[0] - x) * (c[1] - y) - (b[1] - y) * (c[0] - x)) / t.area;
let w1 = ((c[0] - x) * (a[1] - y) - (c[1] - y) * (a[0] - x)) / t.area;
@@ -806,11 +1204,16 @@ fn raster_pixel(
}
}
let z = (w0 * a[2] + w1 * b[2] + w2 * c[2]) as f32;
let index = t.y * width + t.x;
let index = (t.y - y0) * width + (t.x - x0);
let old_depth = depth.get_mut(index).ok_or_else(|| invalid("missing pixel depth"))?;
if z > *old_depth {
return Ok(());
}
if !t.paint {
*old_depth = z;
*pixels.get_mut(index).ok_or_else(|| invalid("missing colour pixel"))? = clear;
return Ok(());
}
let weights = [w0 * t.inv_w[0], w1 * t.inv_w[1], w2 * t.inv_w[2]];
let sum: f64 = weights.iter().sum();
if !sum.is_finite() || sum <= 1e-12 {
+396 -4
View File
@@ -124,7 +124,7 @@ fn cancellation_is_observed_during_preparation_sort_pixels_and_encoding() {
let mut task = ImageTask::new(scene(vec![square("Glass", [40, 180, 100], 0.4, Vec3::ZERO)]), request(), cancel.clone()).unwrap();
let mut reached = false;
for _ in 0..100_000 {
if task.stage == stage {
if task.stage() == stage {
reached = true;
break;
}
@@ -139,7 +139,7 @@ fn cancellation_is_observed_during_preparation_sort_pixels_and_encoding() {
#[test]
fn encoding_yields_between_rows_and_completed_result_is_consumed_once() {
let mut task = ImageTask::new(scene(Vec::new()), request(), CancelToken::default()).unwrap();
while task.stage != Stage::Encode {
while task.stage() != Stage::Encode {
assert!(matches!(task.step(64).unwrap(), TaskPoll::Pending(_)));
}
let before = task.encoder.as_ref().unwrap().row();
@@ -176,6 +176,35 @@ fn invalid_sizes_indices_opacity_and_large_work_lists_fail_without_a_frame() {
assert_eq!(std::mem::size_of::<Draw>(), 16, "admission accounts for each of the three lists");
}
/// What an image request must be is checked on each way in, a registered executor's too.
#[test]
fn every_way_in_checks_what_an_image_request_must_be() {
let scene = scene(vec![square("Square", [100; 3], 1.0, Vec3::ZERO)]);
let budget = crate::job::CpuBudget::Compact;
let limits = budget.limits();
let refused = |r: &ImageRequest| {
[
admit_geometry(&scene, r, MAX_TRIANGLES).err().map(|e| e.code),
admit_geometry_with_limits(&scene, r, MAX_TRIANGLES, limits.max_bodies, limits.max_geometry_bytes, budget.image_limits(false))
.err()
.map(|e| e.code),
]
};
let invalid = [Some(ErrorCode::InvalidRequest), Some(ErrorCode::InvalidRequest)];
assert_eq!(refused(&request()), [None, None]);
let mut camera = request();
camera.camera.distance = f64::NAN;
assert_eq!(refused(&camera), invalid, "a camera that isn't valid");
for value in [-0.1, 1.1, f64::NAN] {
let weights = ImageRequest { studio_weights: Some([value, 0.0, 0.0]), ..request() };
assert_eq!(refused(&weights), invalid, "a weight of {value}");
}
let lit = ImageRequest { studio_weights: Some([1.0; 3]), ..request() };
assert_eq!(refused(&lit), [None, None]);
let traced = ImageRequest { integrator: crate::job::Integrator::Raytrace, ..lit };
assert_eq!(refused(&traced), invalid, "studio weights on a ray traced image");
}
#[test]
fn registered_executor_geometry_admission_excludes_the_cpu_raster_estimate() {
// A library-bounded source: about 100 MiB of face appearances on a small mesh at the
@@ -230,10 +259,10 @@ fn pixel_work_admission_includes_the_limit_and_uses_clipped_sample_bounds() {
let mut scene = scene(vec![body]);
assert_eq!(admit(&scene, &request).unwrap().projected_pixel_tests, Some(128_000_000));
let mut task = ImageTask::new(scene.clone(), request.clone(), CancelToken::default()).unwrap();
while task.stage != Stage::Clear {
while task.stage() != Stage::Clear {
task.step(1).unwrap();
}
assert_eq!(task.pixel_tests, 128_000_000);
assert_eq!(task.raster.pixel_tests, 128_000_000);
Arc::make_mut(&mut scene.meshes[0].mesh).triangles.push([0, 1, 2]);
assert_eq!(admit(&scene, &request).unwrap_err().code, ErrorCode::LimitExceeded);
// Registered executors budget their own work, independently of the raster projection.
@@ -333,3 +362,366 @@ fn product_render_output_is_pinned() {
assert_eq!(got, want, "{name}: the rows changed");
}
}
/// Every tile of a picture, stepped to the end, stitched into the whole picture's rows.
fn picture(scene: ProductScene, request: PictureRequest) -> Vec<u8> {
let (width, height) = request.projection.size();
let mut task = PictureTask::new(scene, request, CancelToken::default()).unwrap();
let mut rows = vec![0; width as usize * height as usize * 4];
for _ in 0..1_000_000 {
match task.step(4096).unwrap() {
PicturePoll::Pending(p) => assert!(p.done <= p.total, "{p:?}"),
PicturePoll::Tile(w, tile) => {
assert_eq!(tile.len(), (w.x1 - w.x0) as usize * (w.y1 - w.y0) as usize * 4);
for (y, row) in (w.y0..w.y1).zip(tile.chunks_exact((w.x1 - w.x0) as usize * 4)) {
let at = (y as usize * width as usize + w.x0 as usize) * 4;
rows[at..at + row.len()].copy_from_slice(row);
}
}
PicturePoll::Complete => return rows,
}
}
panic!("bounded picture did not finish");
}
/// `columns × rows` tiles of uneven sizes covering a `width × height` picture.
fn tiles(width: u32, height: u32, columns: u32, rows: u32) -> Vec<PixelWindow> {
let cut = |n: u32, parts: u32, i: u32| if i == parts { n } else { (n * i / parts + i % 2).min(n) };
let mut out = Vec::new();
for j in 0..rows {
for i in 0..columns {
out.push(PixelWindow {
x0: cut(width, columns, i),
y0: cut(height, rows, j),
x1: cut(width, columns, i + 1),
y1: cut(height, rows, j + 1),
});
}
}
out
}
/// A front view: paper x along +X, paper y along +Z, seen from −Y; 1 mm pixels on a
/// 100 × 50 mm rectangle with its lower-left corner at the paper origin; the model's origin at
/// paper (50, 25).
fn front_view() -> (OrthoFrame, PixelGrid) {
let frame = OrthoFrame { basis: Plane { origin: Vec3::ZERO, x: Vec3::X, y: Vec3::Z }, scale: 1.0, shift: Vec2::new(50.0, 25.0) };
(frame, PixelGrid::new(0.0, 0.0, 100.0, 50.0, 25.4).unwrap())
}
fn opaque_and_glass() -> ProductScene {
let mut glass = block("Glass", [40, 200, 120], Vec3::new(6.0, -14.0, 4.0), Vec3::new(9.0, 2.0, 9.0));
glass.opacity = 0.4;
scene(vec![
block("Block", [180, 120, 60], Vec3::ZERO, Vec3::new(20.0, 12.0, 8.0)),
block("Post", [60, 140, 200], Vec3::new(0.0, 0.0, 16.0), Vec3::new(4.0, 4.0, 8.0)),
glass,
])
}
/// The tiles of a picture, put side by side, are the picture drawn as one tile, to the byte:
/// in the camera's view and an orthographic frame, at both samplings, with glass and with uneven
/// tiles that split triangles anywhere.
#[test]
fn tiles_side_by_side_are_the_picture_drawn_whole() {
let mut iso = Camera { distance: 120.0, ..Default::default() };
iso.set_view(StandardView::Iso);
let (frame, grid) = front_view();
for projection in [Projection::Camera { camera: Camera { fov: 0.6, ..iso }, width: 61, height: 37 }, Projection::Ortho { frame, grid }] {
let (width, height) = projection.size();
for supersample in [Supersample::One, Supersample::Two] {
let request = |tiles: Vec<PixelWindow>| PictureRequest { projection, tiles, supersample, roles: Vec::new(), background: None };
let whole = picture(opaque_and_glass(), request(tiles(width, height, 1, 1)));
assert!(whole.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).count() > 200, "{projection:?}: the scene is in the picture");
for (columns, rows) in [(2, 2), (7, 3), (1, 9)] {
let stitched = picture(opaque_and_glass(), request(tiles(width, height, columns, rows)));
assert!(stitched == whole, "{projection:?} {supersample:?}: {columns} × {rows} tiles differ from the whole picture");
}
}
}
}
/// The product image is the picture of its camera in one tile, 2 × 2 sampled, then encoded.
#[test]
fn the_product_image_is_its_cameras_picture() {
let mut iso = Camera { distance: 120.0, ..Default::default() };
iso.set_view(StandardView::Iso);
for transparent in [true, false] {
let request = ImageRequest { camera: iso, width: 48, height: 40, transparent_background: transparent, ..request() };
let png = finish(ImageTask::new(opaque_and_glass(), request.clone(), CancelToken::default()).unwrap());
let image = image::load_from_memory(&png).unwrap().to_rgba8().into_raw();
let picture = picture(
opaque_and_glass(),
PictureRequest {
projection: Projection::Camera { camera: iso, width: 48, height: 40 },
tiles: tiles(48, 40, 3, 2),
supersample: Supersample::Two,
roles: Vec::new(),
background: (!transparent).then_some(request.background),
},
);
assert!(image == picture, "transparent {transparent}");
}
}
/// An orthographic picture lands on its paper rectangle's pixel grid: a 20 × 10 mm face whose
/// edges lie on pixel edges covers exactly its 20 × 10 pixels sampled once at the centres (the
/// grid's rows run top-down from the rectangle's top); edges a little inside a pixel give it the
/// share of its 2 × 2 samples they cover, in the face's own colour (straight alpha).
#[test]
fn an_orthographic_picture_lands_on_its_pixel_grid() {
let (frame, grid) = front_view();
// A box seen from the front: paper x from `left` to 60, y from 20 to `top`.
let face = |left: f64, top: f64| {
let (x0, z1) = (left - 50.0, top - 25.0);
let half = Vec3::new((10.0 - x0) / 2.0, 3.0, (z1 + 5.0) / 2.0);
scene(vec![block("Face", [200, 100, 50], Vec3::new((x0 + 10.0) / 2.0, 0.0, (z1 - 5.0) / 2.0), half)])
};
let request = |supersample| PictureRequest {
projection: Projection::Ortho { frame, grid },
tiles: tiles(100, 50, 1, 1),
supersample,
roles: Vec::new(),
background: None,
};
let px = |rows: &[u8], x: usize, y: usize| <[u8; 4]>::try_from(&rows[(y * 100 + x) * 4..(y * 100 + x) * 4 + 4]).unwrap();
// Paper x 40..60, y 20..30: columns 40..60 and, counted from the top of the 50 px grid, rows 20..30.
let rows = picture(face(40.0, 30.0), request(Supersample::One));
for y in 0..50 {
for x in 0..100 {
let inside = (40..60).contains(&x) && (20..30).contains(&y);
assert_eq!(px(&rows, x, y)[3], if inside { 255 } else { 0 }, "pixel ({x}, {y})");
}
}
// The left edge at paper x 39.6 and the top at 30.4, sampled once: column 39's centre (39.5)
// and row 19's (30.5) lie outside, so neither is drawn.
let rows = picture(face(39.6, 30.4), request(Supersample::One));
assert_eq!((px(&rows, 39, 25)[3], px(&rows, 50, 19)[3], px(&rows, 40, 20)[3]), (0, 0, 255));
// Sampled 2 × 2: column 39 is half covered, row 19 half, their corner a quarter; each in the
// face's colour.
let rows = picture(face(39.6, 30.4), request(Supersample::Two));
let inner = px(&rows, 50, 25);
assert_eq!(inner[3], 255);
for ((x, y), alpha) in [((39, 25), 128), ((50, 19), 128), ((39, 19), 64), ((38, 25), 0), ((50, 18), 0)] {
let p = px(&rows, x, y);
assert_eq!(p[3], alpha, "pixel ({x}, {y})");
if alpha > 0 {
assert_eq!(p[..3], inner[..3], "pixel ({x}, {y}) keeps the face's colour");
}
}
}
/// The orthographic map puts a point where its frame says it lies on paper, to 1e-9 mm, for a
/// turned frame, a scale and a shift: the guarantee that a picture and lines drawn from the same
/// frame register.
#[test]
fn the_orthographic_map_is_the_frames_paper_map() {
let basis = Plane::new(Vec3::new(3.0, -2.0, 7.0), Vec3::new(0.8, 0.6, 0.0), Vec3::new(-0.3, 0.4, 0.9)).unwrap();
let frame = OrthoFrame { basis, scale: 0.5, shift: Vec2::new(41.0, 17.5) };
let grid = PixelGrid::new(12.5, 4.0, 60.0, 30.0, 300.0).unwrap();
let scene = opaque_and_glass();
let view = Projection::Ortho { frame, grid }.view(&scene, 2).unwrap();
let (x0, y0, x1, y1) = grid.covers();
for mesh in &scene.meshes {
for p in &mesh.mesh.positions {
let c = view.vp.apply(*p);
assert_eq!(c[3], 1.0);
let paper = Vec2::new(x0 + (c[0] + 1.0) / 2.0 * (x1 - x0), y0 + (c[1] + 1.0) / 2.0 * (y1 - y0));
let want = frame.to_paper(*p);
assert!((paper - want).len() < 1e-9, "{p:?}: {paper:?} against {want:?}");
assert!((-1e-12..=1.0 + 1e-12).contains(&c[2]), "depth within the design's box: {c:?}");
}
}
// Nearer the viewer (along the normal) is nearer in depth.
let n = basis.normal();
assert!(view.vp.apply(basis.origin + n)[2] < view.vp.apply(basis.origin)[2]);
}
/// A depth-only body hides what lies behind it and draws nothing itself, whichever is drawn
/// first; the painted body shows where it is not covered.
#[test]
fn a_depth_only_body_hides_what_is_behind_it() {
let (frame, grid) = front_view();
// Seen from −Y: the front block (y −10) covers paper x 40..50 of the back one (x 40..60).
let front = block("Front", [20, 20, 220], Vec3::new(-5.0, -10.0, 0.0), Vec3::new(5.0, 2.0, 5.0));
let back = block("Back", [220, 20, 20], Vec3::new(0.0, 10.0, 0.0), Vec3::new(10.0, 2.0, 5.0));
for order in [[0, 1], [1, 0]] {
let bodies = [front.clone(), back.clone()];
let meshes: Vec<ProductMesh> = order.iter().map(|&i| bodies[i].clone()).collect();
let roles: Vec<BodyRole> = order.iter().map(|&i| if i == 0 { BodyRole::DepthOnly } else { BodyRole::Painted }).collect();
let request = PictureRequest {
projection: Projection::Ortho { frame, grid },
tiles: tiles(100, 50, 1, 1),
supersample: Supersample::One,
roles,
background: None,
};
let rows = picture(scene(meshes), request);
let px = |x: usize, y: usize| <[u8; 4]>::try_from(&rows[(y * 100 + x) * 4..(y * 100 + x) * 4 + 4]).unwrap();
assert_eq!(px(45, 25), [0; 4], "order {order:?}: hidden behind the depth-only body, which draws nothing");
let shown = px(55, 25);
assert!(shown[3] == 255 && shown[0] > shown[2], "order {order:?}: the painted body beside it: {shown:?}");
}
}
/// A picture's pixel work is admitted as a product image's is, by the raster's own projection:
/// under a cap that grows with its samples, and counting a depth-only body, which is never see-through.
#[test]
fn a_picture_is_admitted_under_its_own_pixel_work_cap() {
let (frame, grid) = front_view();
// Its faces drawn `times` over.
let big = |times: usize| {
let mut big = block("Big", [100; 3], Vec3::ZERO, Vec3::new(60.0, 2.0, 30.0));
let mesh = Arc::make_mut(&mut big.mesh);
mesh.triangles = mesh.triangles.repeat(times);
mesh.tri_face = mesh.tri_face.repeat(times);
scene(vec![big])
};
let small = PictureRequest {
projection: Projection::Ortho { frame, grid },
tiles: tiles(100, 50, 1, 1),
supersample: Supersample::One,
roles: Vec::new(),
background: None,
};
assert!(admit_picture(&big(8000), &small).unwrap().projected_pixel_tests.unwrap() > MAX_PIXEL_TESTS, "over a product image's cap");
// The raster holds it to the same figure: it prepares all of that and starts to paint.
let mut task = PictureTask::new(big(8000), small.clone(), CancelToken::default()).unwrap();
while !matches!(task.step(16_384).unwrap(), PicturePoll::Pending(p) if matches!(p.phase, Phase::Running)) {}
let sheet = OrthoFrame { shift: Vec2::new(594.0, 420.0), ..frame };
let a0_grid = PixelGrid::new(0.0, 0.0, 1189.0, 841.0, 1200.0).unwrap();
let a0 = PictureRequest {
projection: Projection::Ortho { frame: sheet, grid: a0_grid },
tiles: tiles(a0_grid.width, a0_grid.height, 28, 20),
..small.clone()
};
// A larger picture gets no more: the figure bounds the time, whatever the sheet.
let error = admit_picture(&big(100), &a0).unwrap_err();
assert_eq!(error.code, ErrorCode::LimitExceeded);
assert!(error.message.contains("400000000 pixel tests"), "{}", error.message);
// A small part of that picture counts only what it draws.
let part = PictureRequest { tiles: vec![PixelWindow { x0: 28_000, y0: 19_000, x1: 28_064, y1: 19_064 }], ..a0.clone() };
assert!(admit_picture(&big(100), &part).unwrap().projected_pixel_tests.unwrap() <= 100 * 12 * 64 * 64);
let mut unseen = block("Unseen", [100; 3], Vec3::ZERO, Vec3::new(10.0, 2.0, 5.0));
unseen.opacity = 0.0;
let count = |request: &PictureRequest| admit_picture(&scene(vec![unseen.clone()]), request).unwrap().projected_pixel_tests.unwrap();
assert_eq!(count(&small), 0, "a painted body nothing can see draws nothing");
assert!(count(&PictureRequest { roles: vec![BodyRole::DepthOnly], ..small.clone() }) > 0, "a depth-only one hides what is behind it");
}
/// One triangle at paper `corners`, in [`front_view`].
fn one_triangle(corners: [[f64; 2]; 3]) -> ProductScene {
let positions = corners.iter().map(|&[x, y]| Vec3::new(x - 50.0, 0.0, y - 25.0)).collect();
let mesh = Mesh { positions, normals: vec![Vec3::new(0.0, -1.0, 0.0); 3], triangles: vec![[0, 1, 2]], tri_face: vec![0], ..Default::default() };
scene(vec![ProductMesh { mesh: Arc::new(mesh), ..block("One", [100; 3], Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0)) }])
}
/// A triangle counts only where it meets the tiles asked for, on the raster's own bounds, and the
/// count is the work: a thin one across a tile's edge, one outside, a tile inside a huge one, and a
/// long sliver. The numbers are bounds' columns times rows, worked by hand on the 100 x 50 canvas.
#[test]
fn a_triangle_counts_only_where_it_meets_the_tiles() {
let (frame, grid) = front_view();
let window = |x0: u32, x1: u32, y0: u32, y1: u32| PixelWindow { x0, y0, x1, y1 };
let whole = window(0, 100, 0, 50);
let thin = [[29.5, 10.5], [30.5, 10.5], [30.5, 40.5]];
let huge = [[-1000.0, -1000.0], [3000.0, -1000.0], [-1000.0, 3000.0]];
let sliver = [[0.5, 0.5], [99.5, 49.5], [99.5, 49.6]];
let cases = [
// Columns 29 to 31, rows 9 to 40: 3 x 32; each side of the edge at 30 has its own part.
(thin, vec![whole], 96),
(thin, vec![window(0, 30, 0, 50)], 32),
(thin, vec![window(30, 100, 0, 50)], 64),
(thin, vec![window(0, 30, 0, 50), window(30, 100, 0, 50)], 96),
(thin, vec![window(60, 70, 0, 50)], 0),
(huge, vec![whole], 5000),
(huge, vec![window(10, 20, 10, 20)], 100),
(sliver, vec![whole], 5000),
(sliver, vec![window(40, 60, 20, 30)], 200),
];
for (corners, windows, count) in cases {
let request = PictureRequest {
projection: Projection::Ortho { frame, grid },
tiles: windows,
supersample: Supersample::One,
roles: Vec::new(),
background: None,
};
let drawn = one_triangle(corners);
assert_eq!(admit_picture(&drawn, &request).unwrap().projected_pixel_tests, Some(count), "{corners:?}: {:?}", request.tiles);
let mut task = PictureTask::new(drawn, request.clone(), CancelToken::default()).unwrap();
// Each tile's samples are cleared and put down, a unit each, and the rest are its tests.
let held: u64 = request.tiles.iter().map(|t| 2 * (t.width() * t.height()) as u64).sum();
loop {
match task.step(1).unwrap() {
PicturePoll::Complete => break,
PicturePoll::Pending(p) if matches!(p.phase, Phase::Running) => assert_eq!(p.total, count + held, "progress is of the tiles"),
_ => {}
}
}
assert_eq!(task.raster.pixel_tests, count, "the raster counts what admission did");
assert_eq!(task.raster.pixel_done, count + held, "{corners:?}: the count is the work: {:?}", request.tiles);
}
// At two samples a pixel, a tile holds four times the samples, from its own first one.
let two = PictureRequest {
projection: Projection::Ortho { frame, grid },
tiles: vec![window(10, 20, 10, 20)],
supersample: Supersample::Two,
roles: Vec::new(),
background: None,
};
assert_eq!(admit_picture(&one_triangle(huge), &two).unwrap().projected_pixel_tests, Some(400));
// A sum at its end is refused by name, not wrapped; no one triangle's count can overflow, its
// sides being two samples a pixel of at most 65536 pixels.
let view = Projection::Ortho { frame, grid }.view(&one_triangle(thin), 1).unwrap();
let triangle = view.project(&one_triangle(thin).meshes[0], 0, None, true).unwrap().unwrap();
let mut sum = u64::MAX;
let e = add_pixel_tests(&mut sum, &triangle, [0, 99, 0, 49], u64::MAX).unwrap_err();
assert_eq!(e.message, "pixel work overflow");
}
/// A picture outside its bounds is refused before anything is allocated.
#[test]
fn hostile_pictures_are_refused() {
let (frame, grid) = front_view();
let good = PictureRequest {
projection: Projection::Ortho { frame, grid },
tiles: tiles(100, 50, 1, 1),
supersample: Supersample::Two,
roles: Vec::new(),
background: None,
};
let code = |request: PictureRequest| PictureTask::new(opaque_and_glass(), request, CancelToken::default()).err().map(|e| e.code);
assert_eq!(code(good.clone()), None);
let bad = |f: &dyn Fn(&mut PictureRequest)| {
let mut request = good.clone();
f(&mut request);
code(request)
};
let invalid = Some(ErrorCode::InvalidRequest);
let one = vec![PixelWindow { x0: 0, y0: 0, x1: 1, y1: 1 }];
assert_eq!(bad(&|r| r.tiles.clear()), invalid, "no tiles");
assert_eq!(bad(&|r| r.tiles = vec![PixelWindow { x0: 0, y0: 0, x1: 101, y1: 10 }]), invalid, "a tile outside the picture");
assert_eq!(bad(&|r| r.tiles = vec![PixelWindow { x0: 5, y0: 0, x1: 5, y1: 10 }]), invalid, "an empty tile");
assert_eq!(bad(&|r| r.tiles = vec![one[0]; MAX_TILES + 1]), invalid, "too many tiles");
assert_eq!(bad(&|r| r.roles = vec![BodyRole::Painted]), invalid, "a role for some bodies only");
let mut skew = frame;
skew.basis.y = Vec3::new(0.0, 0.3, 1.0);
assert_eq!(bad(&|r| r.projection = Projection::Ortho { frame: skew, grid }), invalid, "a basis that isn't orthonormal");
for scale in [0.0, -1.0, f64::NAN] {
assert_eq!(bad(&|r| r.projection = Projection::Ortho { frame: OrthoFrame { scale, ..frame }, grid }), invalid, "scale {scale}");
}
let far = OrthoFrame { shift: Vec2::new(f64::INFINITY, 0.0), ..frame };
assert_eq!(bad(&|r| r.projection = Projection::Ortho { frame: far, grid }), invalid, "an infinite shift");
assert_eq!(bad(&|r| r.projection = Projection::Ortho { frame, grid: PixelGrid { pitch: f64::NAN, ..grid } }), invalid, "a NaN pixel");
let limit = Some(ErrorCode::LimitExceeded);
let wide = Projection::Camera { camera: Camera::default(), width: 65_537, height: 1 };
assert_eq!(bad(&|r| (r.projection, r.tiles) = (wide, one.clone())), limit, "a side over 65,536 px");
// A whole A0 sheet at 1200 dpi is accepted, drawn in strips; one tile that large is not.
let a0 = Projection::Ortho { frame, grid: PixelGrid::new(0.0, 0.0, 1189.0, 841.0, 1200.0).unwrap() };
assert_eq!(bad(&|r| (r.projection, r.tiles) = (a0, vec![PixelWindow { x0: 0, y0: 0, x1: 4096, y1: 64 }])), None);
// As the product image refuses a size over the resolver's limits.
assert_eq!(bad(&|r| (r.projection, r.tiles) = (a0, vec![PixelWindow { x0: 0, y0: 0, x1: 5000, y1: 5000 }])), invalid, "a tile over its limits");
}
+4 -1
View File
@@ -45,7 +45,10 @@ pub use host::{
CpuHost, HostKind, ImageHost, JobEnvironmentInfo, JobHandle, JobStatus, LinearAlphaConvention, LinearColourEncoding, LinearMasterInfo,
PreviewInfo, RenderFrame, RenderPreview,
};
pub use image::{Admission, ImageTask, TaskPoll, admit, admit_geometry, admit_geometry_with_limits};
pub use image::{
Admission, BodyRole, ImageTask, MAX_TILES, OrthoFrame, PicturePoll, PictureRequest, PictureTask, PixelWindow, Projection, Supersample, TaskPoll,
admit, admit_geometry, admit_geometry_with_limits, admit_picture,
};
pub use lens::ThinLens;
pub use lighting::{AreaLight, AreaLights, MAX_AREA_LIGHTS, StudioLighting, Sun, ToneMap};
pub use png::PngTask;