Render: integrated workspace, preview image jobs and opaque PBR (#1046)
Lands the Render workspace (R3), bounded preview-image jobs with cancellation and atomic PNG export (R4a), and opaque PBR looks as one change. - Render workspace: an overlay-free product preview on the installed GPU device. Returning to Design restores its camera. render.scene and render.settings.apply are added. - Image jobs: render.preflight/start/status/cancel/export run bounded CPU preview jobs. Export captures the job, overwrite choice and document, and checks the caller's file scope before writing. - Opaque PBR: looks can carry metal fraction, roughness and dielectric IOR. The product viewport and preview PNG use them; geometry and mass are unchanged. The v1 catalog export refuses PBR rather than dropping it. - Render setups and PBR looks are saved under design format 21. Labels are in en/de/ja/ko/zh/ru. Supersedes #706 and #800. Refs #620, #673, #756, #1004. Contributed by @0xlillith. Co-authored-by: lillith <[email protected]>
This commit is contained in:
1 parent
e4ef6cfe29
commit
a261aaa841
70 files changed
+5860
-101
No files matched your search
@@ -0,0 +1,516 @@
|
||||
//! Caller-owned CPU execution host. One worker, two bounded queued inputs; browser pumping
|
||||
//! uses the same incremental task without threads. Shared ownership never includes CAD state.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::sync::Condvar;
|
||||
use std::sync::{
|
||||
Arc, Mutex, Weak,
|
||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{
|
||||
Admission, AlphaConvention, CancelToken, ColourEncoding, ErrorCode, FrameInfo, ImageRequest, ImageTask, JobError, JobId, Phase, Progress, Result,
|
||||
TaskPoll, admit,
|
||||
};
|
||||
use crate::{
|
||||
Availability, ExecutorDescriptor, ExecutorId, ProductScene, RenderFormat, RenderLimits, RenderMode, RenderOperation, RenderProfile, ScratchLayout,
|
||||
};
|
||||
|
||||
const MEMORY_LIMIT: u64 = 512 * 1024 * 1024;
|
||||
const QUEUE_LIMIT: usize = 2;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "host_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HostKind {
|
||||
NativeWorker,
|
||||
Cooperative,
|
||||
}
|
||||
|
||||
struct Budget {
|
||||
used: AtomicU64,
|
||||
}
|
||||
struct Reservation {
|
||||
budget: Arc<Budget>,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
fn reserve(self: &Arc<Self>, bytes: u64) -> Result<Reservation> {
|
||||
let mut used = self.used.load(Ordering::Acquire);
|
||||
loop {
|
||||
let next = used
|
||||
.checked_add(bytes)
|
||||
.filter(|n| *n <= MEMORY_LIMIT)
|
||||
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "CPU image host's 512 MiB admission pool is full"))?;
|
||||
match self.used.compare_exchange_weak(used, next, Ordering::AcqRel, Ordering::Acquire) {
|
||||
Ok(_) => break,
|
||||
Err(current) => used = current,
|
||||
}
|
||||
}
|
||||
Ok(Reservation { budget: self.clone(), bytes })
|
||||
}
|
||||
}
|
||||
impl Reservation {
|
||||
fn shrink(&mut self, bytes: u64) -> Result<()> {
|
||||
if bytes > self.bytes {
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "completed PNG exceeds its reservation"));
|
||||
}
|
||||
self.budget.used.fetch_sub(self.bytes - bytes, Ordering::AcqRel);
|
||||
self.bytes = bytes;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Drop for Reservation {
|
||||
fn drop(&mut self) {
|
||||
self.budget.used.fetch_sub(self.bytes, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow encoded bytes through the frame, so retaining the data also retains its admission lease.
|
||||
pub struct RenderFrame {
|
||||
pub info: FrameInfo,
|
||||
png: Vec<u8>,
|
||||
_reservation: Reservation,
|
||||
}
|
||||
impl RenderFrame {
|
||||
pub fn png(&self) -> &[u8] {
|
||||
&self.png
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct JobStatus {
|
||||
pub job: JobId,
|
||||
pub scene_generation: u64,
|
||||
pub request: ImageRequest,
|
||||
pub resolved: crate::ResolvedRequest,
|
||||
pub progress: Progress,
|
||||
pub error: Option<JobError>,
|
||||
pub frame: Option<FrameInfo>,
|
||||
}
|
||||
|
||||
struct Data {
|
||||
progress: Progress,
|
||||
error: Option<JobError>,
|
||||
frame: Option<Arc<RenderFrame>>,
|
||||
}
|
||||
struct Record {
|
||||
id: JobId,
|
||||
scene_generation: u64,
|
||||
request: ImageRequest,
|
||||
resolved: crate::ResolvedRequest,
|
||||
cancel: CancelToken,
|
||||
data: Mutex<Data>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
done: Condvar,
|
||||
}
|
||||
|
||||
impl Record {
|
||||
fn notify(&self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.done.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct JobHandle(Arc<Record>);
|
||||
|
||||
impl JobHandle {
|
||||
pub fn id(&self) -> JobId {
|
||||
self.0.id
|
||||
}
|
||||
pub fn status(&self) -> Result<JobStatus> {
|
||||
let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
|
||||
Ok(JobStatus {
|
||||
job: self.0.id,
|
||||
scene_generation: self.0.scene_generation,
|
||||
request: self.0.request.clone(),
|
||||
resolved: self.0.resolved.clone(),
|
||||
progress: data.progress,
|
||||
error: data.error.clone(),
|
||||
frame: data.frame.as_ref().map(|f| f.info.clone()),
|
||||
})
|
||||
}
|
||||
pub fn cancel(&self) -> Result<JobStatus> {
|
||||
{
|
||||
let mut data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
|
||||
if !data.progress.phase.terminal() {
|
||||
self.0.cancel.cancel();
|
||||
data.progress.phase = if data.progress.phase == Phase::Queued { Phase::Cancelled } else { Phase::Cancelling };
|
||||
}
|
||||
}
|
||||
self.0.notify();
|
||||
self.status()
|
||||
}
|
||||
|
||||
/// Native application helper; engine status commands remain non-blocking.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn wait_timeout(&self, timeout: std::time::Duration) -> Result<JobStatus> {
|
||||
let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
|
||||
let (data, timed) = self
|
||||
.0
|
||||
.done
|
||||
.wait_timeout_while(data, timeout, |d| !d.progress.phase.terminal())
|
||||
.map_err(|_| JobError::new(ErrorCode::Internal, "render completion lock poisoned"))?;
|
||||
if timed.timed_out() && !data.progress.phase.terminal() {
|
||||
return Err(JobError::new(ErrorCode::TimedOut, "render wait timed out"));
|
||||
}
|
||||
drop(data);
|
||||
self.status()
|
||||
}
|
||||
pub fn frame(&self) -> Result<Option<Arc<RenderFrame>>> {
|
||||
let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render result lock poisoned"))?;
|
||||
let frame = data.frame.clone();
|
||||
if frame.as_ref().is_some_and(|f| f.info.job != self.0.id || f.info.scene_generation != self.0.scene_generation) {
|
||||
return Err(JobError::new(ErrorCode::WrongOwner, "render frame identity does not match its job"));
|
||||
}
|
||||
Ok(frame)
|
||||
}
|
||||
}
|
||||
|
||||
struct Work {
|
||||
scene: ProductScene,
|
||||
handle: JobHandle,
|
||||
reservation: Reservation,
|
||||
}
|
||||
struct Running {
|
||||
task: ImageTask,
|
||||
handle: JobHandle,
|
||||
reservation: Option<Reservation>,
|
||||
}
|
||||
|
||||
fn terminal(handle: &JobHandle, error: JobError) {
|
||||
if let Ok(mut data) = handle.0.data.lock() {
|
||||
if data.progress.phase == Phase::Completed {
|
||||
return;
|
||||
}
|
||||
if handle.0.cancel.is_cancelled() || error.code == ErrorCode::Cancelled {
|
||||
data.progress.phase = Phase::Cancelled;
|
||||
data.error = None;
|
||||
} else {
|
||||
data.progress.phase = Phase::Failed;
|
||||
data.error = Some(error);
|
||||
}
|
||||
data.frame = None;
|
||||
}
|
||||
handle.0.notify();
|
||||
}
|
||||
|
||||
impl Running {
|
||||
fn begin(work: Work) -> Option<Self> {
|
||||
if work.handle.0.cancel.is_cancelled() {
|
||||
terminal(&work.handle, JobError::new(ErrorCode::Cancelled, "render cancelled"));
|
||||
return None;
|
||||
}
|
||||
if let Ok(mut data) = work.handle.0.data.lock()
|
||||
&& !work.handle.0.cancel.is_cancelled()
|
||||
{
|
||||
data.progress.phase = Phase::Preparing;
|
||||
}
|
||||
let task = ImageTask::new(work.scene, work.handle.0.request.clone(), work.handle.0.cancel.clone());
|
||||
match task {
|
||||
Ok(task) => Some(Self { task, handle: work.handle, reservation: Some(work.reservation) }),
|
||||
Err(e) => {
|
||||
terminal(&work.handle, e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn advance(&mut self, units: usize) -> Result<bool> {
|
||||
let result = self.task.step(units)?;
|
||||
let mut data = self.handle.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
|
||||
// The final publication check is under the same lock as cancellation.
|
||||
if self.handle.0.cancel.is_cancelled() {
|
||||
data.progress.phase = Phase::Cancelled;
|
||||
data.frame = None;
|
||||
self.handle.0.notify();
|
||||
return Ok(false);
|
||||
}
|
||||
match result {
|
||||
TaskPoll::Pending(progress) => {
|
||||
data.progress = progress;
|
||||
Ok(true)
|
||||
}
|
||||
TaskPoll::Complete(png) => {
|
||||
let mut reservation = self.reservation.take().ok_or_else(|| JobError::new(ErrorCode::Internal, "missing render reservation"))?;
|
||||
reservation.shrink(png.capacity() as u64 + 1024)?;
|
||||
let info = FrameInfo {
|
||||
job: self.handle.0.id,
|
||||
scene_generation: self.handle.0.scene_generation,
|
||||
backend: self.handle.0.resolved.backend.clone(),
|
||||
executor: self.handle.0.resolved.executor.clone(),
|
||||
width: self.handle.0.request.width,
|
||||
height: self.handle.0.request.height,
|
||||
encoding: ColourEncoding::Srgb,
|
||||
alpha: AlphaConvention::Straight,
|
||||
};
|
||||
data.frame = Some(Arc::new(RenderFrame { info, png, _reservation: reservation }));
|
||||
data.progress = Progress { phase: Phase::Completed, done: 1, total: 1 };
|
||||
self.handle.0.notify();
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn guarded_step(&mut self, units: usize) -> bool {
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.advance(units)));
|
||||
match result {
|
||||
Ok(Ok(more)) => more,
|
||||
Ok(Err(e)) => {
|
||||
terminal(&self.handle, e);
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
terminal(&self.handle, JobError::new(ErrorCode::Internal, "CPU image host encountered an internal error"));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
queue: Mutex<VecDeque<Work>>,
|
||||
active: Mutex<Option<Weak<Record>>>,
|
||||
stop: AtomicBool,
|
||||
budget: Arc<Budget>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
wake: Condvar,
|
||||
}
|
||||
|
||||
struct Owner {
|
||||
shared: Arc<Shared>,
|
||||
kind: HostKind,
|
||||
running: Mutex<Option<Running>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
_worker: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for Owner {
|
||||
fn drop(&mut self) {
|
||||
self.shared.stop.store(true, Ordering::Release);
|
||||
if let Ok(active) = self.shared.active.lock()
|
||||
&& let Some(record) = active.as_ref().and_then(Weak::upgrade)
|
||||
{
|
||||
let _ = JobHandle(record).cancel();
|
||||
}
|
||||
if let Ok(mut queue) = self.shared.queue.lock() {
|
||||
for work in queue.iter() {
|
||||
let _ = work.handle.cancel();
|
||||
}
|
||||
queue.clear();
|
||||
}
|
||||
if let Ok(mut running) = self.running.lock()
|
||||
&& let Some(work) = running.take()
|
||||
{
|
||||
let _ = work.handle.cancel();
|
||||
terminal(&work.handle, JobError::new(ErrorCode::Cancelled, "render host closed"));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.shared.wake.notify_all();
|
||||
// Detaching cannot stall the UI; the bounded worker observes stop/cancel at its next unit.
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CpuHost(Arc<Owner>);
|
||||
|
||||
impl CpuHost {
|
||||
fn shared() -> Arc<Shared> {
|
||||
Arc::new(Shared {
|
||||
queue: Mutex::new(VecDeque::new()),
|
||||
active: Mutex::new(None),
|
||||
stop: AtomicBool::new(false),
|
||||
budget: Arc::new(Budget { used: AtomicU64::new(0) }),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
wake: Condvar::new(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cooperative() -> Self {
|
||||
Self(Arc::new(Owner {
|
||||
shared: Self::shared(),
|
||||
kind: HostKind::Cooperative,
|
||||
running: Mutex::new(None),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
_worker: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn for_platform() -> Result<Self> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
Ok(Self::cooperative())
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let shared = Self::shared();
|
||||
let worker_shared = shared.clone();
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("solidcraft-render".into())
|
||||
.spawn(move || native_worker(worker_shared))
|
||||
.map_err(|e| JobError::new(ErrorCode::HostUnavailable, format!("could not start CPU image worker: {e}")))?;
|
||||
Ok(Self(Arc::new(Owner { shared, kind: HostKind::NativeWorker, running: Mutex::new(None), _worker: Some(worker) })))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> HostKind {
|
||||
self.0.kind
|
||||
}
|
||||
|
||||
pub fn same_host(&self, other: &Self) -> bool {
|
||||
Arc::ptr_eq(&self.0, &other.0)
|
||||
}
|
||||
pub fn is_available(&self) -> bool {
|
||||
!self.0.shared.stop.load(Ordering::Acquire)
|
||||
}
|
||||
pub fn admitted_bytes(&self) -> u64 {
|
||||
self.0.shared.budget.used.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn descriptor(&self) -> Result<ExecutorDescriptor> {
|
||||
Ok(ExecutorDescriptor {
|
||||
id: ExecutorId::new("cpu-product").map_err(|e| JobError::new(ErrorCode::Internal, e.to_string()))?,
|
||||
availability: if self.0.shared.stop.load(Ordering::Acquire) {
|
||||
Availability::Unavailable { reason: "CPU image host has stopped".into() }
|
||||
} else {
|
||||
Availability::Available
|
||||
},
|
||||
profiles: vec![RenderProfile {
|
||||
mode: RenderMode::Preview,
|
||||
operation: RenderOperation::Image,
|
||||
formats: vec![RenderFormat::Png],
|
||||
features: vec![crate::RenderFeature::Pbr],
|
||||
}],
|
||||
limits: RenderLimits::default(),
|
||||
scratch: ScratchLayout { sample_scale: 2, sample_bytes_per_pixel: 8, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 8 },
|
||||
})
|
||||
}
|
||||
|
||||
pub fn submit(&self, id: JobId, scene_generation: u64, scene: ProductScene, request: ImageRequest) -> Result<JobHandle> {
|
||||
if id.session == 0 || id.serial == 0 || id.generation == 0 {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid render job identity"));
|
||||
}
|
||||
let Admission { estimated_total_bytes, .. } = admit(&scene, &request)?;
|
||||
let mut backend = crate::builtin_backend();
|
||||
backend.executors.push(self.descriptor()?);
|
||||
let resolved = crate::resolve_request(
|
||||
&[backend],
|
||||
&crate::RenderRequest {
|
||||
backend: None,
|
||||
mode: RenderMode::Preview,
|
||||
operation: RenderOperation::Image,
|
||||
width: u64::from(request.width),
|
||||
height: u64::from(request.height),
|
||||
format: Some(RenderFormat::Png),
|
||||
features: scene.required_features(),
|
||||
policy: Default::default(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| JobError::new(ErrorCode::HostUnavailable, e.to_string()))?;
|
||||
if resolved.executor.as_str() != "cpu-product" {
|
||||
return Err(JobError::new(ErrorCode::HostUnavailable, "CPU product executor is unavailable"));
|
||||
}
|
||||
let mut queue = self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?;
|
||||
if self.0.shared.stop.load(Ordering::Acquire) {
|
||||
return Err(JobError::new(ErrorCode::HostUnavailable, "CPU image host has stopped"));
|
||||
}
|
||||
queue.retain(|work| !work.handle.0.cancel.is_cancelled());
|
||||
if queue.len() >= QUEUE_LIMIT {
|
||||
return Err(JobError::new(ErrorCode::QueueFull, "CPU image queue is full"));
|
||||
}
|
||||
let reservation = self.0.shared.budget.reserve(estimated_total_bytes)?;
|
||||
let handle = JobHandle(Arc::new(Record {
|
||||
id,
|
||||
scene_generation,
|
||||
request,
|
||||
resolved,
|
||||
cancel: CancelToken::default(),
|
||||
data: Mutex::new(Data { progress: Progress { phase: Phase::Queued, done: 0, total: 1 }, error: None, frame: None }),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
done: Condvar::new(),
|
||||
}));
|
||||
queue.push_back(Work { scene, handle: handle.clone(), reservation });
|
||||
drop(queue);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.0.shared.wake.notify_one();
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
/// Cooperative callers pump a small quantum independently of polling a job's status.
|
||||
/// Native jobs run even when the UI is idle or a headless caller is not polling.
|
||||
pub fn pump(&self, units: usize) -> Result<bool> {
|
||||
if self.0.kind != HostKind::Cooperative {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut running = self.0.running.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "cooperative host lock poisoned"))?;
|
||||
if running.is_none() {
|
||||
let work = self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?.pop_front();
|
||||
if let Some(work) = work {
|
||||
*running = Running::begin(work);
|
||||
}
|
||||
}
|
||||
if let Some(work) = running.as_mut() {
|
||||
if let Ok(mut active) = self.0.shared.active.lock() {
|
||||
*active = Some(Arc::downgrade(&work.handle.0));
|
||||
}
|
||||
if !work.guarded_step(units) {
|
||||
*running = None;
|
||||
if let Ok(mut active) = self.0.shared.active.lock() {
|
||||
*active = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(running.is_some()
|
||||
|| !self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn native_worker(shared: Arc<Shared>) {
|
||||
loop {
|
||||
let mut queue = match shared.queue.lock() {
|
||||
Ok(q) => q,
|
||||
Err(_) => {
|
||||
shared.stop.store(true, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
};
|
||||
while queue.is_empty() && !shared.stop.load(Ordering::Acquire) {
|
||||
queue = match shared.wake.wait(queue) {
|
||||
Ok(q) => q,
|
||||
Err(_) => {
|
||||
shared.stop.store(true, Ordering::Release);
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
if shared.stop.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let work = queue.pop_front();
|
||||
drop(queue);
|
||||
let Some(mut running) = work.and_then(Running::begin) else { continue };
|
||||
if let Ok(mut active) = shared.active.lock() {
|
||||
*active = Some(Arc::downgrade(&running.handle.0));
|
||||
}
|
||||
loop {
|
||||
if shared.stop.load(Ordering::Acquire) {
|
||||
let _ = running.handle.cancel();
|
||||
}
|
||||
if !running.guarded_step(4096) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Ok(mut active) = shared.active.lock() {
|
||||
*active = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use super::*;
|
||||
use solidcraft_geom::{Aabb3, Mesh};
|
||||
|
||||
fn request() -> ImageRequest {
|
||||
ImageRequest { camera: Default::default(), width: 16, height: 16, background: [40, 80, 120], transparent_background: false }
|
||||
}
|
||||
fn empty() -> ProductScene {
|
||||
ProductScene { meshes: Vec::new(), bounds: Aabb3::EMPTY }
|
||||
}
|
||||
fn id(owner: u64, serial: u64) -> JobId {
|
||||
JobId { session: owner, serial, generation: serial }
|
||||
}
|
||||
fn drain(host: &CpuHost) {
|
||||
for _ in 0..10_000 {
|
||||
if !host.pump(4096).unwrap() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("small cooperative jobs did not drain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queue_is_bounded_and_cancelled_entries_release_their_reservations() {
|
||||
let host = CpuHost::cooperative();
|
||||
let a = host.submit(id(10, 1), 100, empty(), request()).unwrap();
|
||||
let b = host.submit(id(20, 1), 200, empty(), request()).unwrap();
|
||||
let before = host.admitted_bytes();
|
||||
assert_eq!(host.submit(id(30, 1), 300, empty(), request()).err().unwrap().code, ErrorCode::QueueFull);
|
||||
assert_eq!(host.admitted_bytes(), before);
|
||||
assert_eq!(a.cancel().unwrap().progress.phase, Phase::Cancelled);
|
||||
let c = host.submit(id(30, 1), 300, empty(), request()).unwrap();
|
||||
drain(&host);
|
||||
assert_eq!(a.status().unwrap().progress.phase, Phase::Cancelled);
|
||||
for (handle, owner, generation) in [(&b, 20, 200), (&c, 30, 300)] {
|
||||
let frame = handle.frame().unwrap().unwrap();
|
||||
assert_eq!(frame.info.job, id(owner, 1));
|
||||
assert_eq!(frame.info.scene_generation, generation);
|
||||
assert_eq!(frame.info.executor.as_str(), "cpu-product");
|
||||
assert_eq!(image::load_from_memory(frame.png()).unwrap().width(), 16);
|
||||
}
|
||||
assert!(host.admitted_bytes() > 0 && host.admitted_bytes() < before);
|
||||
let frame = b.frame().unwrap().unwrap();
|
||||
drop(b);
|
||||
drop(c);
|
||||
assert!(host.admitted_bytes() > 0, "an externally retained frame retains its lease");
|
||||
drop(frame);
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_while_encoding_cannot_publish_a_late_frame() {
|
||||
let host = CpuHost::cooperative();
|
||||
let job = host.submit(id(1, 1), 8, empty(), request()).unwrap();
|
||||
for _ in 0..10_000 {
|
||||
host.pump(64).unwrap();
|
||||
let status = job.status().unwrap();
|
||||
if status.progress.phase == Phase::Encoding && status.progress.done > 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(job.status().unwrap().progress.phase, Phase::Encoding);
|
||||
assert_eq!(job.cancel().unwrap().progress.phase, Phase::Cancelling);
|
||||
drain(&host);
|
||||
assert_eq!(job.status().unwrap().progress.phase, Phase::Cancelled);
|
||||
assert!(job.frame().unwrap().is_none());
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_preparation_fails_the_job_without_stopping_the_host() {
|
||||
let host = CpuHost::cooperative();
|
||||
let scene = ProductScene {
|
||||
bounds: Aabb3::EMPTY,
|
||||
meshes: vec![crate::ProductMesh {
|
||||
name: "Broken".into(),
|
||||
mesh: Arc::new(Mesh { triangles: vec![[99, 99, 99]], ..Default::default() }),
|
||||
color: [0; 3],
|
||||
opacity: 1.0,
|
||||
surface: None,
|
||||
faces: Vec::new(),
|
||||
}],
|
||||
};
|
||||
let broken = host.submit(id(1, 1), 1, scene, request()).unwrap();
|
||||
drain(&host);
|
||||
let status = broken.status().unwrap();
|
||||
assert_eq!(status.progress.phase, Phase::Failed);
|
||||
assert_eq!(status.error.unwrap().code, ErrorCode::InvalidScene);
|
||||
assert!(broken.frame().unwrap().is_none());
|
||||
assert!(host.is_available());
|
||||
let healthy = host.submit(id(1, 2), 1, empty(), request()).unwrap();
|
||||
drain(&host);
|
||||
assert_eq!(healthy.status().unwrap().progress.phase, Phase::Completed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_host_cancels_active_and_queued_work() {
|
||||
let host = CpuHost::cooperative();
|
||||
let budget = host.0.shared.budget.clone();
|
||||
let active = host.submit(id(1, 1), 1, empty(), request()).unwrap();
|
||||
let queued = host.submit(id(2, 1), 1, empty(), request()).unwrap();
|
||||
host.pump(1).unwrap();
|
||||
drop(host);
|
||||
assert_eq!(active.status().unwrap().progress.phase, Phase::Cancelled);
|
||||
assert_eq!(queued.status().unwrap().progress.phase, Phase::Cancelled);
|
||||
assert_eq!(budget.used.load(Ordering::Acquire), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_foreign_frame_is_rejected_even_if_a_broken_host_puts_it_in_the_result_slot() {
|
||||
let host = CpuHost::cooperative();
|
||||
let job = host.submit(id(1, 1), 1, empty(), request()).unwrap();
|
||||
drain(&host);
|
||||
{
|
||||
let mut data = job.0.data.lock().unwrap();
|
||||
let frame = Arc::get_mut(data.frame.as_mut().unwrap()).unwrap();
|
||||
frame.info.job.session = 2;
|
||||
}
|
||||
assert_eq!(job.frame().err().unwrap().code, ErrorCode::WrongOwner);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn native_job_finishes_without_any_poll_or_ui_pump() {
|
||||
let host = CpuHost::for_platform().unwrap();
|
||||
assert_eq!(host.kind(), HostKind::NativeWorker);
|
||||
let job = host.submit(id(1, 1), 12, empty(), request()).unwrap();
|
||||
// A generous deadlock watchdog, not a performance assertion.
|
||||
let status = job.wait_timeout(std::time::Duration::from_secs(60)).unwrap();
|
||||
assert_eq!(status.progress.phase, Phase::Completed);
|
||||
assert_eq!(job.frame().unwrap().unwrap().info.scene_generation, 12);
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
//! Incremental product rasterization. Every phase yields between bounded work units.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::Serialize;
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use super::{CancelToken, ErrorCode, ImageRequest, JobError, Phase, Progress, Result};
|
||||
use crate::{FaceAppearance, Mat4, ProductScene, RenderFormat, RenderMode, RenderOperation, RenderRequest};
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "image_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
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;
|
||||
const MAX_STEP: usize = 16_384;
|
||||
const SS: usize = 2;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
pub struct Admission {
|
||||
pub triangles: u64,
|
||||
pub face_appearances: u64,
|
||||
pub geometry_bytes: u64,
|
||||
pub pixel_scratch_bytes: u64,
|
||||
pub preparation_bytes: u64,
|
||||
pub estimated_total_bytes: u64,
|
||||
pub encoded_limit: u64,
|
||||
}
|
||||
|
||||
fn limit(message: &str) -> JobError {
|
||||
JobError::new(ErrorCode::LimitExceeded, message)
|
||||
}
|
||||
fn invalid(message: &str) -> JobError {
|
||||
JobError::new(ErrorCode::InvalidScene, message)
|
||||
}
|
||||
fn add_bytes(sum: &mut u64, count: usize, size: usize) -> Result<()> {
|
||||
let bytes = (count as u64).checked_mul(size as u64).ok_or_else(|| limit("geometry size overflow"))?;
|
||||
*sum = sum.checked_add(bytes).ok_or_else(|| limit("geometry size overflow"))?;
|
||||
if *sum > MAX_GEOMETRY_BYTES {
|
||||
return Err(limit("retained product geometry exceeds 128 MiB"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Conservative admission before allocating pixels or projected-work lists. Source Arcs are
|
||||
/// counted even when another owner already holds them. Allocator and codec internals are estimates.
|
||||
pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission> {
|
||||
if !request.camera.is_valid() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid image camera"));
|
||||
}
|
||||
if scene.meshes.len() > 16_384 {
|
||||
return Err(limit("too many product bodies"));
|
||||
}
|
||||
let resolved = crate::resolve_request(
|
||||
&[crate::builtin_backend()],
|
||||
&RenderRequest {
|
||||
backend: None,
|
||||
mode: RenderMode::Preview,
|
||||
operation: RenderOperation::Image,
|
||||
width: u64::from(request.width),
|
||||
height: u64::from(request.height),
|
||||
format: Some(RenderFormat::Png),
|
||||
features: Vec::new(),
|
||||
policy: Default::default(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| JobError::new(ErrorCode::InvalidRequest, e.to_string()))?;
|
||||
let (mut triangles, mut faces, mut geometry, mut polylines) = (0u64, 0u64, 0u64, 0u64);
|
||||
add_bytes(&mut geometry, scene.meshes.capacity(), std::mem::size_of::<crate::ProductMesh>())?;
|
||||
for body in &scene.meshes {
|
||||
let m = &body.mesh;
|
||||
triangles = triangles.checked_add(m.triangles.len() as u64).ok_or_else(|| limit("triangle count overflow"))?;
|
||||
faces = faces.checked_add(body.faces.len() as u64).ok_or_else(|| limit("appearance count overflow"))?;
|
||||
polylines = polylines.checked_add(m.edges.len() as u64 + m.edge_faces.len() as u64).ok_or_else(|| limit("edge count overflow"))?;
|
||||
if triangles > MAX_TRIANGLES || faces > MAX_TRIANGLES || polylines > MAX_TRIANGLES {
|
||||
return Err(limit("product image exceeds the 262144 triangle/appearance/polyline admission cap"));
|
||||
}
|
||||
add_bytes(&mut geometry, m.positions.capacity(), std::mem::size_of::<Vec3>())?;
|
||||
add_bytes(&mut geometry, m.normals.capacity(), std::mem::size_of::<Vec3>())?;
|
||||
add_bytes(&mut geometry, m.triangles.capacity(), std::mem::size_of::<[u32; 3]>())?;
|
||||
add_bytes(&mut geometry, m.tri_face.capacity(), std::mem::size_of::<u32>())?;
|
||||
add_bytes(&mut geometry, body.faces.capacity(), std::mem::size_of::<FaceAppearance>())?;
|
||||
add_bytes(&mut geometry, body.name.capacity(), 1)?;
|
||||
add_bytes(&mut geometry, m.edges.capacity(), std::mem::size_of::<Vec<Vec3>>())?;
|
||||
add_bytes(&mut geometry, m.edge_faces.capacity(), std::mem::size_of::<Vec<u32>>())?;
|
||||
for edge in &m.edges {
|
||||
add_bytes(&mut geometry, edge.capacity(), std::mem::size_of::<Vec3>())?;
|
||||
}
|
||||
for edge in &m.edge_faces {
|
||||
add_bytes(&mut geometry, edge.capacity(), std::mem::size_of::<u32>())?;
|
||||
}
|
||||
for count in [m.seams.capacity(), m.smooth_edges.capacity(), m.sharp_edges.capacity()] {
|
||||
add_bytes(&mut geometry, count, 1)?;
|
||||
}
|
||||
if body.surface.is_some() && body.opacity != 1.0 {
|
||||
return Err(invalid("opaque PBR requires opacity 1"));
|
||||
}
|
||||
if !body.opacity.is_finite() || !(0.0..=1.0).contains(&body.opacity) {
|
||||
return Err(invalid("invalid body opacity"));
|
||||
}
|
||||
}
|
||||
// Three bounded 16-byte lists, a conservative map-node allowance, and codec overhead.
|
||||
let preparation = triangles
|
||||
.checked_mul(48)
|
||||
.and_then(|n| faces.checked_mul(160).and_then(|f| n.checked_add(f)))
|
||||
.and_then(|n| n.checked_add(scene.meshes.len() as u64 * 128 + 262_144))
|
||||
.ok_or_else(|| limit("work list size overflow"))?;
|
||||
let total = geometry
|
||||
.checked_add(preparation)
|
||||
.and_then(|n| n.checked_add(resolved.estimated_scratch_bytes))
|
||||
.ok_or_else(|| limit("image memory overflow"))?;
|
||||
if total > MAX_JOB_BYTES {
|
||||
return Err(limit("product job exceeds its 384 MiB admission estimate"));
|
||||
}
|
||||
let encoded_limit = u64::from(request.width) * u64::from(request.height) * 8 + 65_536;
|
||||
Ok(Admission {
|
||||
triangles,
|
||||
face_appearances: faces,
|
||||
geometry_bytes: geometry,
|
||||
pixel_scratch_bytes: resolved.estimated_scratch_bytes,
|
||||
preparation_bytes: preparation,
|
||||
estimated_total_bytes: total,
|
||||
encoded_limit,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Draw {
|
||||
body: u32,
|
||||
triangle: u32,
|
||||
depth: f64,
|
||||
}
|
||||
|
||||
impl Draw {
|
||||
fn compare(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.depth.total_cmp(&other.depth).then_with(|| (self.body, self.triangle).cmp(&(other.body, other.triangle)))
|
||||
}
|
||||
}
|
||||
|
||||
struct Triangle {
|
||||
points: [[f64; 3]; 3],
|
||||
world: [Vec3; 3],
|
||||
normals: [Vec3; 3],
|
||||
inv_w: [f64; 3],
|
||||
colour: [f64; 3],
|
||||
opacity: f64,
|
||||
surface: Option<crate::surface::OpaqueSurface>,
|
||||
centre_depth: f64,
|
||||
area: f64,
|
||||
bounds: [usize; 4],
|
||||
x: usize,
|
||||
y: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Stage {
|
||||
Looks,
|
||||
Prepare,
|
||||
Sort,
|
||||
Clear,
|
||||
Opaque,
|
||||
Glass,
|
||||
Downsample,
|
||||
Encode,
|
||||
Done,
|
||||
}
|
||||
|
||||
struct BufferWriter {
|
||||
bytes: Arc<Mutex<Vec<u8>>>,
|
||||
limit: usize,
|
||||
cancel: CancelToken,
|
||||
}
|
||||
|
||||
impl Write for BufferWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
if self.cancel.is_cancelled() {
|
||||
return Err(std::io::Error::other("render cancelled"));
|
||||
}
|
||||
let mut out = self.bytes.lock().map_err(|_| std::io::Error::other("PNG output lock poisoned"))?;
|
||||
if out.len().checked_add(bytes.len()).is_none_or(|n| n > self.limit) {
|
||||
return Err(std::io::Error::other("PNG output exceeds its byte budget"));
|
||||
}
|
||||
out.try_reserve_exact(bytes.len()).map_err(|_| std::io::Error::other("PNG allocation failed"))?;
|
||||
out.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
if self.cancel.is_cancelled() { Err(std::io::Error::other("render cancelled")) } else { Ok(()) }
|
||||
}
|
||||
}
|
||||
|
||||
pub enum TaskPoll {
|
||||
Pending(Progress),
|
||||
Complete(Vec<u8>),
|
||||
}
|
||||
|
||||
/// CPU product renderer with cooperative preparation, sorting, raster and PNG encoding.
|
||||
/// Transparency sorts triangle centres and blends in linear space; it is not refraction.
|
||||
pub struct ImageTask {
|
||||
scene: ProductScene,
|
||||
request: ImageRequest,
|
||||
admission: Admission,
|
||||
cancel: CancelToken,
|
||||
stage: Stage,
|
||||
vp: Mat4,
|
||||
back: Vec3,
|
||||
sw: usize,
|
||||
sh: usize,
|
||||
looks: Vec<BTreeMap<u32, FaceAppearance>>,
|
||||
body: usize,
|
||||
item: usize,
|
||||
prepare_done: u64,
|
||||
opaque: Vec<Draw>,
|
||||
glass: Vec<Draw>,
|
||||
scratch: Vec<Draw>,
|
||||
sort_width: usize,
|
||||
sort_block: usize,
|
||||
sort_left: usize,
|
||||
sort_right: usize,
|
||||
sort_mid: usize,
|
||||
sort_end: usize,
|
||||
sort_dst: usize,
|
||||
pixels: Vec<[u8; 4]>,
|
||||
depth: Vec<f32>,
|
||||
pixel_tests: u64,
|
||||
pixel_done: u64,
|
||||
draw: usize,
|
||||
triangle: Option<Triangle>,
|
||||
output: Vec<u8>,
|
||||
encoded: Arc<Mutex<Vec<u8>>>,
|
||||
encoder: Option<png::StreamWriter<'static, BufferWriter>>,
|
||||
row: usize,
|
||||
}
|
||||
|
||||
impl ImageTask {
|
||||
pub fn new(scene: ProductScene, request: ImageRequest, cancel: CancelToken) -> Result<Self> {
|
||||
cancel.check()?;
|
||||
let admission = admit(&scene, &request)?;
|
||||
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"));
|
||||
}
|
||||
let vp = request.camera.view_proj(sw as f64 / sh as f64, radius);
|
||||
let back = request.camera.back();
|
||||
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,
|
||||
vp,
|
||||
back,
|
||||
sw,
|
||||
sh,
|
||||
looks,
|
||||
body: 0,
|
||||
item: 0,
|
||||
prepare_done: 0,
|
||||
opaque,
|
||||
glass,
|
||||
scratch,
|
||||
sort_width: 1,
|
||||
sort_block: 0,
|
||||
sort_left: 0,
|
||||
sort_right: 0,
|
||||
sort_mid: 0,
|
||||
sort_end: 0,
|
||||
sort_dst: 0,
|
||||
pixels,
|
||||
depth,
|
||||
pixel_tests: 0,
|
||||
pixel_done: 0,
|
||||
draw: 0,
|
||||
triangle: None,
|
||||
output,
|
||||
encoded: Arc::new(Mutex::new(Vec::new())),
|
||||
encoder: None,
|
||||
row: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub 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.sw * self.sh) as u64 + u64::from(self.request.width) * u64::from(self.request.height),
|
||||
},
|
||||
Stage::Encode => Progress { phase: Phase::Encoding, done: self.row as u64, total: u64::from(self.request.height) },
|
||||
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;
|
||||
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::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::Done => return Err(JobError::new(ErrorCode::NotCompleted, "render result already consumed")),
|
||||
}
|
||||
}
|
||||
Ok(TaskPoll::Pending(self.progress()))
|
||||
}
|
||||
|
||||
fn look_step(&mut self) -> Result<()> {
|
||||
let Some(body) = self.scene.meshes.get(self.body) else {
|
||||
self.body = 0;
|
||||
self.item = 0;
|
||||
self.stage = Stage::Prepare;
|
||||
return Ok(());
|
||||
};
|
||||
if self.looks.len() == self.body {
|
||||
self.looks.push(BTreeMap::new());
|
||||
}
|
||||
if let Some(face) = body.faces.get(self.item) {
|
||||
if !face.opacity.is_finite() || !(0.0..=1.0).contains(&face.opacity) {
|
||||
return Err(invalid("invalid face opacity"));
|
||||
}
|
||||
let looks = self.looks.get_mut(self.body).ok_or_else(|| invalid("missing body appearances"))?;
|
||||
// Match the document policy: the last assignment of a face wins.
|
||||
looks.insert(face.face, face.clone());
|
||||
self.item += 1;
|
||||
} else {
|
||||
self.body += 1;
|
||||
self.item = 0;
|
||||
}
|
||||
self.prepare_done += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn project(&self, draw: Draw) -> Result<Option<Triangle>> {
|
||||
let body = self.scene.meshes.get(draw.body as usize).ok_or_else(|| invalid("missing product body"))?;
|
||||
let mesh = &body.mesh;
|
||||
let indices = mesh.triangles.get(draw.triangle as usize).ok_or_else(|| invalid("missing product triangle"))?;
|
||||
let vertices = indices.map(|i| mesh.positions.get(i as usize).copied());
|
||||
let normals = indices.map(|i| mesh.normals.get(i as usize).copied());
|
||||
let ([Some(a), Some(b), Some(c)], [Some(na), Some(nb), Some(nc)]) = (vertices, normals) else {
|
||||
return Err(invalid("invalid triangle index or missing normal"));
|
||||
};
|
||||
let world = [a, b, c];
|
||||
let normals = [na, nb, nc];
|
||||
if world.iter().chain(normals.iter()).any(|v| !v.is_finite()) {
|
||||
return Err(invalid("non-finite product triangle"));
|
||||
}
|
||||
let look = self.looks.get(draw.body as usize).and_then(|looks| mesh.tri_face.get(draw.triangle as usize).and_then(|face| looks.get(face)));
|
||||
let (colour, opacity, surface) = look.map_or((body.color, body.opacity, body.surface), |look| (look.color, look.opacity, look.surface));
|
||||
if surface.is_some() && opacity != 1.0 {
|
||||
return Err(invalid("opaque PBR requires opacity 1"));
|
||||
}
|
||||
let normals = if surface.is_some() {
|
||||
[crate::surface::unit_direction(na), crate::surface::unit_direction(nb), crate::surface::unit_direction(nc)]
|
||||
.map(|n| n.map_err(|e| invalid(&e.to_string())))
|
||||
} else {
|
||||
[Ok(normals[0]), Ok(normals[1]), Ok(normals[2])]
|
||||
};
|
||||
let [na, nb, nc] = normals;
|
||||
let normals = [na?, nb?, nc?];
|
||||
if opacity <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let clip = world.map(|p| self.vp.apply(p));
|
||||
if clip.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return Err(invalid("non-finite product projection"));
|
||||
}
|
||||
if clip.iter().any(|p| p[3] <= 1e-9) {
|
||||
return Ok(None);
|
||||
}
|
||||
let inv_w = clip.map(|p| 1.0 / p[3]);
|
||||
let points = clip.map(|p| [(p[0] / p[3] + 1.0) * 0.5 * self.sw as f64, (1.0 - p[1] / p[3]) * 0.5 * self.sh as f64, p[2] / p[3]]);
|
||||
if points.iter().flatten().any(|v| !v.is_finite()) || points.iter().any(|p| !(p[2] as f32).is_finite()) {
|
||||
return Err(invalid("product projection exceeds pixel precision"));
|
||||
}
|
||||
let [a, b, c] = points;
|
||||
let (x0, x1) = (a[0].min(b[0]).min(c[0]).floor().max(0.0), a[0].max(b[0]).max(c[0]).ceil().min(self.sw as f64 - 1.0));
|
||||
let (y0, y1) = (a[1].min(b[1]).min(c[1]).floor().max(0.0), a[1].max(b[1]).max(c[1]).ceil().min(self.sh as f64 - 1.0));
|
||||
let area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
||||
if !(x0 <= x1 && y0 <= y1) || area.abs() < 1e-12 {
|
||||
return Ok(None);
|
||||
}
|
||||
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.request.camera.target).dot(self.back);
|
||||
if !centre_depth.is_finite() {
|
||||
return Err(invalid("invalid triangle depth"));
|
||||
}
|
||||
Ok(Some(Triangle {
|
||||
points,
|
||||
world,
|
||||
normals,
|
||||
inv_w,
|
||||
colour: colour.map(decode),
|
||||
opacity,
|
||||
surface,
|
||||
centre_depth,
|
||||
area,
|
||||
bounds: [x0 as usize, x1 as usize, y0 as usize, y1 as usize],
|
||||
x: x0 as usize,
|
||||
y: y0 as usize,
|
||||
}))
|
||||
}
|
||||
|
||||
fn prepare_step(&mut self) -> Result<()> {
|
||||
let Some(body) = self.scene.meshes.get(self.body) else {
|
||||
self.stage = if self.glass.len() > 1 { Stage::Sort } else { Stage::Clear };
|
||||
self.sort_block = 0;
|
||||
self.init_merge();
|
||||
return Ok(());
|
||||
};
|
||||
if self.item >= body.mesh.triangles.len() {
|
||||
self.body += 1;
|
||||
self.item = 0;
|
||||
return Ok(());
|
||||
}
|
||||
let mut draw = Draw { body: self.body as u32, triangle: self.item as u32, depth: 0.0 };
|
||||
if let Some(triangle) = self.project(draw)? {
|
||||
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"))?;
|
||||
self.pixel_tests = self.pixel_tests.checked_add(tests).ok_or_else(|| limit("pixel work overflow"))?;
|
||||
if self.pixel_tests > MAX_PIXEL_TESTS {
|
||||
return Err(limit("projected product exceeds 128000000 pixel tests"));
|
||||
}
|
||||
draw.depth = triangle.centre_depth;
|
||||
if triangle.opacity >= 1.0 {
|
||||
self.opaque.push(draw);
|
||||
} else {
|
||||
self.glass.push(draw);
|
||||
self.scratch.push(draw);
|
||||
}
|
||||
}
|
||||
self.item += 1;
|
||||
self.prepare_done += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_merge(&mut self) {
|
||||
let n = self.glass.len();
|
||||
self.sort_mid = (self.sort_block + self.sort_width).min(n);
|
||||
self.sort_end = (self.sort_block + 2 * self.sort_width).min(n);
|
||||
self.sort_left = self.sort_block;
|
||||
self.sort_right = self.sort_mid;
|
||||
self.sort_dst = self.sort_block;
|
||||
}
|
||||
|
||||
fn sort_step(&mut self) -> Result<()> {
|
||||
if self.sort_dst >= self.sort_end {
|
||||
self.sort_block += 2 * self.sort_width;
|
||||
if self.sort_block >= self.glass.len() {
|
||||
std::mem::swap(&mut self.glass, &mut self.scratch);
|
||||
self.sort_width *= 2;
|
||||
self.sort_block = 0;
|
||||
if self.sort_width >= self.glass.len() {
|
||||
self.stage = Stage::Clear;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.init_merge();
|
||||
}
|
||||
let left = self.glass.get(self.sort_left).filter(|_| self.sort_left < self.sort_mid);
|
||||
let right = self.glass.get(self.sort_right).filter(|_| self.sort_right < self.sort_end);
|
||||
let take_left = match (left, right) {
|
||||
(Some(a), Some(b)) => a.compare(b).is_le(),
|
||||
(Some(_), None) => true,
|
||||
_ => false,
|
||||
};
|
||||
let source = if take_left {
|
||||
let i = self.sort_left;
|
||||
self.sort_left += 1;
|
||||
i
|
||||
} else {
|
||||
let i = self.sort_right;
|
||||
self.sort_right += 1;
|
||||
i
|
||||
};
|
||||
let draw = self.glass.get(source).copied().ok_or_else(|| invalid("invalid transparent sort source"))?;
|
||||
*self.scratch.get_mut(self.sort_dst).ok_or_else(|| invalid("invalid transparent sort destination"))? = draw;
|
||||
self.sort_dst += 1;
|
||||
self.prepare_done += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_step(&mut self) {
|
||||
if self.pixels.len() >= self.sw * self.sh {
|
||||
self.stage = Stage::Opaque;
|
||||
self.draw = 0;
|
||||
return;
|
||||
}
|
||||
let [r, g, b] = self.request.background;
|
||||
self.pixels.push(if self.request.transparent_background { [0; 4] } else { [r, g, b, 255] });
|
||||
self.depth.push(f32::INFINITY);
|
||||
self.pixel_done += 1;
|
||||
}
|
||||
|
||||
fn draw_step(&mut self) -> Result<()> {
|
||||
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 {
|
||||
self.stage = if self.stage == Stage::Opaque { Stage::Glass } else { Stage::Downsample };
|
||||
self.draw = 0;
|
||||
return Ok(());
|
||||
};
|
||||
self.triangle = self.project(draw)?;
|
||||
if self.triangle.is_none() {
|
||||
self.draw += 1;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
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.sw, self.back, eye, &mut self.pixels, &mut self.depth)?;
|
||||
triangle.x += 1;
|
||||
if triangle.x > triangle.bounds[1] {
|
||||
triangle.x = triangle.bounds[0];
|
||||
triangle.y += 1;
|
||||
}
|
||||
if triangle.y > triangle.bounds[3] {
|
||||
self.triangle = None;
|
||||
self.draw += 1;
|
||||
}
|
||||
self.pixel_done += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn downsample_step(&mut self) -> Result<()> {
|
||||
let width = self.request.width as usize;
|
||||
let index = self.output.len() / 4;
|
||||
if index >= width * self.request.height as usize {
|
||||
let writer = BufferWriter { bytes: self.encoded.clone(), limit: self.admission.encoded_limit as usize, cancel: self.cancel.clone() };
|
||||
let mut encoder = png::Encoder::new(writer, self.request.width, self.request.height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
|
||||
self.encoder = Some(
|
||||
encoder.write_header().and_then(|w| w.into_stream_writer()).map_err(|e| JobError::new(ErrorCode::EncodingFailed, e.to_string()))?,
|
||||
);
|
||||
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(());
|
||||
}
|
||||
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.sw + 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;
|
||||
}
|
||||
alpha += a;
|
||||
}
|
||||
}
|
||||
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.pixel_done += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_step(&mut self) -> Result<Option<Vec<u8>>> {
|
||||
if self.row < self.request.height as usize {
|
||||
let length = self.request.width as usize * 4;
|
||||
let start = self.row * length;
|
||||
let row = self.output.get(start..start + length).ok_or_else(|| invalid("missing PNG row"))?;
|
||||
self.encoder
|
||||
.as_mut()
|
||||
.ok_or_else(|| invalid("missing PNG writer"))?
|
||||
.write_all(row)
|
||||
.map_err(|e| JobError::new(ErrorCode::EncodingFailed, e.to_string()))?;
|
||||
self.row += 1;
|
||||
return Ok(None);
|
||||
}
|
||||
self.encoder
|
||||
.take()
|
||||
.ok_or_else(|| invalid("missing PNG writer"))?
|
||||
.finish()
|
||||
.map_err(|e| JobError::new(ErrorCode::EncodingFailed, e.to_string()))?;
|
||||
self.cancel.check()?;
|
||||
let mut bytes = self.encoded.lock().map_err(|_| JobError::new(ErrorCode::Internal, "PNG output lock poisoned"))?;
|
||||
let png = std::mem::take(&mut *bytes);
|
||||
if !png.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return Err(JobError::new(ErrorCode::EncodingFailed, "invalid PNG output"));
|
||||
}
|
||||
self.output = Vec::new();
|
||||
self.stage = Stage::Done;
|
||||
Ok(Some(png))
|
||||
}
|
||||
}
|
||||
|
||||
fn raster_pixel(t: &Triangle, width: usize, back: Vec3, eye: Option<Vec3>, pixels: &mut [[u8; 4]], depth: &mut [f32]) -> Result<()> {
|
||||
let [a, b, c] = t.points;
|
||||
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;
|
||||
let w2 = 1.0 - w0 - w1;
|
||||
// A shared triangle edge belongs to exactly one raster triangle, including glass.
|
||||
for (w, mut p, mut q) in [(w0, b, c), (w1, c, a), (w2, a, b)] {
|
||||
if w < -1e-9 {
|
||||
return Ok(());
|
||||
}
|
||||
if w.abs() <= 1e-9 {
|
||||
if t.area < 0.0 {
|
||||
std::mem::swap(&mut p, &mut q);
|
||||
}
|
||||
let (dx, dy) = (q[0] - p[0], q[1] - p[1]);
|
||||
if !(dy < 0.0 || (dy == 0.0 && dx > 0.0)) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
let z = (w0 * a[2] + w1 * b[2] + w2 * c[2]) as f32;
|
||||
let index = t.y * width + t.x;
|
||||
let old_depth = depth.get_mut(index).ok_or_else(|| invalid("missing pixel depth"))?;
|
||||
if z > *old_depth {
|
||||
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 {
|
||||
return Ok(());
|
||||
}
|
||||
let n = t.normals.iter().zip(weights).fold(Vec3::ZERO, |v, (n, w)| v + *n * (w / sum)).normalized().unwrap_or(Vec3::Z);
|
||||
let colour = if let Some(surface) = t.surface {
|
||||
let world = t.world.iter().zip(weights).fold(Vec3::ZERO, |p, (v, w)| p + *v * (w / sum));
|
||||
let view = eye.map_or(back, |eye| eye - world);
|
||||
let radiance = crate::studio::shade(surface, n, view).map_err(|e| invalid(&e.to_string()))?;
|
||||
let mapped = crate::studio::tone_map(radiance).map_err(|e| invalid(&e.to_string()))?;
|
||||
[mapped.x, mapped.y, mapped.z]
|
||||
} else {
|
||||
crate::raster::shade(n, back, t.colour)
|
||||
};
|
||||
if colour.iter().any(|v| !v.is_finite()) {
|
||||
return Err(invalid("non-finite shaded colour"));
|
||||
}
|
||||
let pixel = pixels.get_mut(index).ok_or_else(|| invalid("missing colour pixel"))?;
|
||||
if t.opacity >= 1.0 {
|
||||
*old_depth = z;
|
||||
*pixel = [encode(colour[0]), encode(colour[1]), encode(colour[2]), 255];
|
||||
} else {
|
||||
let old_alpha = f64::from(pixel[3]) / 255.0;
|
||||
let alpha = t.opacity + old_alpha * (1.0 - t.opacity);
|
||||
for (out, src) in pixel.iter_mut().take(3).zip(colour) {
|
||||
*out = encode((src * t.opacity + decode(*out) * old_alpha * (1.0 - t.opacity)) / alpha);
|
||||
}
|
||||
pixel[3] = (alpha * 255.0).round() as u8;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode(v: u8) -> f64 {
|
||||
let s = f64::from(v) / 255.0;
|
||||
if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
fn encode(v: f64) -> u8 {
|
||||
let l = v.clamp(0.0, 1.0);
|
||||
let s = if l <= 0.0031308 { l * 12.92 } else { 1.055 * l.powf(1.0 / 2.4) - 0.055 };
|
||||
(s * 255.0).round() as u8
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use super::*;
|
||||
use crate::{Camera, ProductMesh, StandardView};
|
||||
use solidcraft_geom::{Aabb3, Mesh};
|
||||
|
||||
fn request() -> ImageRequest {
|
||||
let mut camera = Camera { distance: 50.0, ..Default::default() };
|
||||
camera.set_view(StandardView::Front);
|
||||
ImageRequest { camera, width: 64, height: 64, background: [20, 40, 60], transparent_background: true }
|
||||
}
|
||||
|
||||
fn square(name: &str, colour: [u8; 3], opacity: f64, offset: Vec3) -> ProductMesh {
|
||||
let mesh = Mesh {
|
||||
positions: vec![
|
||||
Vec3::new(-10.0, 0.0, -10.0) + offset,
|
||||
Vec3::new(10.0, 0.0, -10.0) + offset,
|
||||
Vec3::new(10.0, 0.0, 10.0) + offset,
|
||||
Vec3::new(-10.0, 0.0, 10.0) + offset,
|
||||
],
|
||||
normals: vec![Vec3::new(0.0, -1.0, 0.0); 4],
|
||||
triangles: vec![[0, 1, 2], [0, 2, 3]],
|
||||
tri_face: vec![0, 1],
|
||||
..Default::default()
|
||||
};
|
||||
ProductMesh { name: name.into(), mesh: Arc::new(mesh), color: colour, opacity, surface: None, faces: Vec::new() }
|
||||
}
|
||||
|
||||
fn scene(meshes: Vec<ProductMesh>) -> ProductScene {
|
||||
let bounds = meshes.iter().fold(Aabb3::EMPTY, |b, m| b.union(&m.mesh.bounds()));
|
||||
ProductScene { meshes, bounds }
|
||||
}
|
||||
|
||||
fn finish(mut task: ImageTask) -> Vec<u8> {
|
||||
for _ in 0..100_000 {
|
||||
match task.step(4096).unwrap() {
|
||||
TaskPoll::Complete(png) => return png,
|
||||
TaskPoll::Pending(p) => assert!(p.done <= p.total, "{p:?}"),
|
||||
}
|
||||
}
|
||||
panic!("bounded fixture did not finish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_png_has_straight_srgb_colour_without_a_shared_edge_seam() {
|
||||
let scene = scene(vec![square("Glass", [220, 40, 20], 0.5, Vec3::ZERO)]);
|
||||
let png = finish(ImageTask::new(scene, request(), CancelToken::default()).unwrap());
|
||||
let image = image::load_from_memory(&png).unwrap().to_rgba8();
|
||||
assert_eq!(image.get_pixel(0, 0).0, [0; 4]);
|
||||
for (x, y) in [(32, 32), (31, 31), (30, 32), (32, 30)] {
|
||||
let p = image.get_pixel(x, y).0;
|
||||
assert!((i16::from(p[3]) - 128).abs() <= 1, "a shared diagonal must not double-blend: {p:?}");
|
||||
assert!(p[0] > 180 && p[0] > p[1] + 60, "RGB is straight, not multiplied by 0.5: {p:?}");
|
||||
}
|
||||
let reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
|
||||
assert_eq!(reader.info().srgb, Some(png::SrgbRenderingIntent::Perceptual));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_is_sorted_back_to_front_independently_of_body_order() {
|
||||
let request = request();
|
||||
let far = square("Far", [240, 20, 20], 0.5, request.camera.back() * -5.0);
|
||||
let near = square("Near", [20, 20, 240], 0.5, request.camera.back() * 5.0);
|
||||
let a = finish(ImageTask::new(scene(vec![far.clone(), near.clone()]), request.clone(), CancelToken::default()).unwrap());
|
||||
let b = finish(ImageTask::new(scene(vec![near, far]), request, CancelToken::default()).unwrap());
|
||||
assert_eq!(a, b);
|
||||
let image = image::load_from_memory(&a).unwrap().to_rgba8();
|
||||
let p = image.get_pixel(32, 32).0;
|
||||
assert!((i16::from(p[3]) - 192).abs() <= 1, "two half-transparent layers: {p:?}");
|
||||
assert!(p[2] > p[0] + 30, "the near blue layer contributes more: {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_is_observed_during_preparation_sort_pixels_and_encoding() {
|
||||
for stage in [Stage::Looks, Stage::Prepare, Stage::Sort, Stage::Clear, Stage::Glass, Stage::Downsample, Stage::Encode] {
|
||||
let cancel = CancelToken::default();
|
||||
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 {
|
||||
reached = true;
|
||||
break;
|
||||
}
|
||||
assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
|
||||
}
|
||||
assert!(reached);
|
||||
cancel.cancel();
|
||||
assert_eq!(task.step(4096).err().unwrap().code, ErrorCode::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
assert!(matches!(task.step(64).unwrap(), TaskPoll::Pending(_)));
|
||||
}
|
||||
let before = task.row;
|
||||
assert!(matches!(task.step(usize::MAX).unwrap(), TaskPoll::Pending(_)));
|
||||
assert!(task.row - before <= 4);
|
||||
loop {
|
||||
if let TaskPoll::Complete(bytes) = task.step(4096).unwrap() {
|
||||
assert_eq!(image::load_from_memory(&bytes).unwrap().width(), 64);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(task.step(1).err().unwrap().code, ErrorCode::NotCompleted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_sizes_indices_opacity_and_large_work_lists_fail_without_a_frame() {
|
||||
let body = square("Product", [100, 120, 140], 1.0, Vec3::ZERO);
|
||||
let mut bad_request = request();
|
||||
bad_request.width = u32::MAX;
|
||||
assert!(ImageTask::new(scene(vec![body.clone()]), bad_request, CancelToken::default()).is_err());
|
||||
for bad in [0, 1] {
|
||||
let mut body = body.clone();
|
||||
if bad == 0 {
|
||||
Arc::make_mut(&mut body.mesh).triangles[0][0] = u32::MAX;
|
||||
} else {
|
||||
body.faces.push(FaceAppearance { face: 0, color: [0; 3], opacity: f64::NAN, surface: None });
|
||||
}
|
||||
let mut task = ImageTask::new(scene(vec![body]), request(), CancelToken::default()).unwrap();
|
||||
let error = loop {
|
||||
if let Err(error) = task.step(64) {
|
||||
break error;
|
||||
}
|
||||
};
|
||||
assert_eq!(error.code, ErrorCode::InvalidScene);
|
||||
}
|
||||
let mut body = body;
|
||||
Arc::make_mut(&mut body.mesh).triangles.resize(MAX_TRIANGLES as usize + 1, [0, 1, 2]);
|
||||
assert_eq!(ImageTask::new(scene(vec![body]), request(), CancelToken::default()).err().unwrap().code, ErrorCode::LimitExceeded);
|
||||
assert_eq!(std::mem::size_of::<Draw>(), 16, "admission accounts for each of the three lists");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_output_budget_and_cancellation_fail_without_partial_publication() {
|
||||
let bytes = Arc::new(Mutex::new(Vec::new()));
|
||||
let cancel = CancelToken::default();
|
||||
let mut writer = BufferWriter { bytes: bytes.clone(), limit: 4, cancel: cancel.clone() };
|
||||
writer.write_all(b"PNG").unwrap();
|
||||
assert!(writer.write_all(b"too large").is_err());
|
||||
assert_eq!(*bytes.lock().unwrap(), b"PNG");
|
||||
cancel.cancel();
|
||||
assert!(writer.write_all(b"x").is_err());
|
||||
assert!(writer.flush().is_err());
|
||||
assert_eq!(*bytes.lock().unwrap(), b"PNG");
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! CPU product-image work and its device-independent, session-scoped protocol.
|
||||
//! Hosts own execution; documents and history never contain these values.
|
||||
|
||||
use std::fmt;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{BackendId, Camera, ExecutorId};
|
||||
|
||||
mod host;
|
||||
mod image;
|
||||
pub use host::{CpuHost, HostKind, JobHandle, JobStatus, RenderFrame};
|
||||
pub use image::{Admission, ImageTask, TaskPoll, admit};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct JobId {
|
||||
pub session: u64,
|
||||
pub serial: u64,
|
||||
pub generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Phase {
|
||||
Queued,
|
||||
Preparing,
|
||||
Running,
|
||||
Encoding,
|
||||
Cancelling,
|
||||
Cancelled,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl Phase {
|
||||
pub fn terminal(self) -> bool {
|
||||
matches!(self, Self::Cancelled | Self::Completed | Self::Failed)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct Progress {
|
||||
pub phase: Phase,
|
||||
pub done: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorCode {
|
||||
InvalidRequest,
|
||||
LimitExceeded,
|
||||
AllocationFailed,
|
||||
HostUnavailable,
|
||||
QueueFull,
|
||||
TimedOut,
|
||||
Cancelled,
|
||||
InvalidScene,
|
||||
EncodingFailed,
|
||||
WrongOwner,
|
||||
UnknownJob,
|
||||
NotCompleted,
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct JobError {
|
||||
pub code: ErrorCode,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl JobError {
|
||||
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
|
||||
Self { code, message: message.into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for JobError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{:?}: {}", self.code, self.message)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for JobError {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, JobError>;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct CancelToken(Arc<AtomicBool>);
|
||||
|
||||
impl CancelToken {
|
||||
pub fn cancel(&self) {
|
||||
self.0.store(true, Ordering::Release);
|
||||
}
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.0.load(Ordering::Acquire)
|
||||
}
|
||||
pub fn check(&self) -> Result<()> {
|
||||
if self.is_cancelled() { Err(JobError::new(ErrorCode::Cancelled, "render cancelled")) } else { Ok(()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Fully resolved input; no mutable session, topology graph or GPU resources.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ImageRequest {
|
||||
pub camera: Camera,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub background: [u8; 3],
|
||||
pub transparent_background: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ColourEncoding {
|
||||
Srgb,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AlphaConvention {
|
||||
Straight,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct FrameInfo {
|
||||
pub job: JobId,
|
||||
pub scene_generation: u64,
|
||||
pub backend: BackendId,
|
||||
pub executor: ExecutorId,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub encoding: ColourEncoding,
|
||||
pub alpha: AlphaConvention,
|
||||
}
|
||||
@@ -7,8 +7,11 @@
|
||||
mod backend;
|
||||
mod camera;
|
||||
mod curve_display;
|
||||
pub mod job;
|
||||
pub mod lighting;
|
||||
mod product;
|
||||
mod raster;
|
||||
pub mod studio;
|
||||
pub mod surface;
|
||||
pub mod texture_input;
|
||||
|
||||
@@ -19,4 +22,5 @@ pub use backend::{
|
||||
};
|
||||
pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out};
|
||||
pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped};
|
||||
pub use product::{FaceAppearance, ProductMesh, ProductScene};
|
||||
pub use raster::{Canvas, Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent};
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Renderer-independent product geometry. No modelling overlays or device resources.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use solidcraft_geom::{Aabb3, Mesh};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FaceAppearance {
|
||||
/// The existing mesh's B-rep face index, resolved by the document's appearance policy.
|
||||
pub face: u32,
|
||||
pub color: [u8; 3],
|
||||
pub opacity: f64,
|
||||
pub surface: Option<crate::surface::OpaqueSurface>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProductMesh {
|
||||
/// Existing computed body name, including occurrence identity where applicable.
|
||||
pub name: String,
|
||||
/// Established world-space mesh. Adapters must not apply occurrence transforms again.
|
||||
pub mesh: Arc<Mesh>,
|
||||
pub color: [u8; 3],
|
||||
pub opacity: f64,
|
||||
pub surface: Option<crate::surface::OpaqueSurface>,
|
||||
pub faces: Vec<FaceAppearance>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProductScene {
|
||||
pub meshes: Vec<ProductMesh>,
|
||||
pub bounds: Aabb3,
|
||||
}
|
||||
|
||||
impl ProductScene {
|
||||
pub fn required_features(&self) -> Vec<crate::RenderFeature> {
|
||||
if self.meshes.iter().any(|m| m.surface.is_some() || m.faces.iter().any(|f| f.surface.is_some())) {
|
||||
vec![crate::RenderFeature::Pbr]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ impl Canvas {
|
||||
}
|
||||
}
|
||||
|
||||
fn shade(n: Vec3, view_dir: Vec3, base: [f64; 3]) -> [f64; 3] {
|
||||
pub(crate) fn shade(n: Vec3, view_dir: Vec3, base: [f64; 3]) -> [f64; 3] {
|
||||
let n = if n.dot(view_dir) < 0.0 { -n } else { n };
|
||||
let key = (view_dir * 0.8 + Vec3::new(-0.3, 0.2, 0.6)).normalized().unwrap_or(view_dir);
|
||||
let diff = n.dot(key).max(0.0);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Original fixed direct-light studio used by opaque product preview consumers.
|
||||
//! Relative linear radiance, not a calibrated illuminance or an HDRI environment.
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::surface::{OpaqueSurface, SurfaceError, unit_direction};
|
||||
|
||||
/// Fixed, neutral directional lights in the project's world frame (Z up).
|
||||
/// Directions point towards each light and are analytically unit length.
|
||||
pub const LIGHTS: [(Vec3, f64); 3] = [(Vec3::new(0.6, 0.0, 0.8), 3.0), (Vec3::new(-0.8, 0.0, 0.6), 1.0), (Vec3::new(0.0, -0.6, 0.8), 1.5)];
|
||||
|
||||
/// Sum BRDF * incident radiance * light cosine, without display transfer.
|
||||
/// There are no cast shadows, indirect bounces, ambient term or emission.
|
||||
pub fn shade(surface: OpaqueSurface, normal: Vec3, view: Vec3) -> Result<Vec3, SurfaceError> {
|
||||
let n = unit_direction(normal)?;
|
||||
let mut radiance = Vec3::ZERO;
|
||||
for (light, intensity) in LIGHTS {
|
||||
radiance += surface.brdf(n, view, light)? * (intensity * n.dot(light).max(0.0));
|
||||
}
|
||||
Ok(radiance)
|
||||
}
|
||||
|
||||
/// Per-channel Reinhard preview tone map, still in linear sRGB.
|
||||
/// Consumers encode exactly once after applying this to nonnegative finite radiance.
|
||||
pub fn tone_map(radiance: Vec3) -> Result<Vec3, SurfaceError> {
|
||||
if !radiance.is_finite() || radiance.x < 0.0 || radiance.y < 0.0 || radiance.z < 0.0 {
|
||||
return Err(SurfaceError::Radiance);
|
||||
}
|
||||
let map = |c: f64| c / (1.0 + c);
|
||||
Ok(Vec3::new(map(radiance.x), map(radiance.y), map(radiance.z)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
#[test]
|
||||
fn direct_white_conductor_matches_closed_form_and_is_tone_mapped_once() {
|
||||
let s = OpaqueSurface::new(Vec3::new(1.0, 1.0, 1.0), 1.0, 1.0, 1.5).unwrap();
|
||||
let c = shade(s, Vec3::Z, Vec3::Z).unwrap();
|
||||
let expected = (4.5 * 0.8 / 1.8 + 0.6 / 1.6) / (2.0 * PI);
|
||||
assert!((c - Vec3::new(expected, expected, expected)).len() < 1e-14);
|
||||
let mapped = tone_map(c).unwrap();
|
||||
assert!((mapped.x - expected / (1.0 + expected)).abs() < 1e-14);
|
||||
assert_eq!(tone_map(Vec3::ZERO).unwrap(), Vec3::ZERO);
|
||||
assert!(tone_map(Vec3::new(f64::NAN, 0.0, 0.0)).is_err());
|
||||
assert!(tone_map(Vec3::new(-1.0, 0.0, 0.0)).is_err());
|
||||
assert!(shade(s, Vec3::ZERO, Vec3::Z).is_err());
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ pub enum SurfaceError {
|
||||
Roughness,
|
||||
DielectricIor,
|
||||
Direction,
|
||||
Radiance,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SurfaceError {
|
||||
@@ -35,6 +36,7 @@ impl std::fmt::Display for SurfaceError {
|
||||
Self::Roughness => "perceptual roughness must be finite and in [0.045, 1]",
|
||||
Self::DielectricIor => "dielectric IOR must be finite and in [1, 3]",
|
||||
Self::Direction => "surface directions must be finite and nonzero",
|
||||
Self::Radiance => "radiance must be finite and nonnegative",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -81,15 +83,15 @@ impl OpaqueSurface {
|
||||
/// No incident radiance, cosine weight, exposure or display transfer is included.
|
||||
/// Back-facing/tangent directions return zero; malformed directions are errors.
|
||||
pub fn brdf(self, normal: Vec3, view: Vec3, light: Vec3) -> Result<Vec3, SurfaceError> {
|
||||
let n = unit(normal)?;
|
||||
let v = unit(view)?;
|
||||
let l = unit(light)?;
|
||||
let n = unit_direction(normal)?;
|
||||
let v = unit_direction(view)?;
|
||||
let l = unit_direction(light)?;
|
||||
let nv = n.dot(v).clamp(-1.0, 1.0);
|
||||
let nl = n.dot(l).clamp(-1.0, 1.0);
|
||||
if nv <= 0.0 || nl <= 0.0 {
|
||||
return Ok(Vec3::ZERO);
|
||||
}
|
||||
let h = unit(v + l)?;
|
||||
let h = unit_direction(v + l)?;
|
||||
let nh = n.dot(h).clamp(0.0, 1.0);
|
||||
let vh = v.dot(h).clamp(0.0, 1.0);
|
||||
let alpha = self.roughness * self.roughness;
|
||||
@@ -122,7 +124,8 @@ pub fn srgb8_to_linear(channel: u8) -> f64 {
|
||||
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
fn unit(v: Vec3) -> Result<Vec3, SurfaceError> {
|
||||
/// Normalize finite directions without overflow or an arbitrary magnitude threshold.
|
||||
pub fn unit_direction(v: Vec3) -> Result<Vec3, SurfaceError> {
|
||||
if !v.is_finite() {
|
||||
return Err(SurfaceError::Direction);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user