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 <35337584+0xlillith@users.noreply.github.com>
517 lines
18 KiB
Rust
517 lines
18 KiB
Rust
//! 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;
|
|
}
|
|
}
|
|
}
|