Render: add backend capability discovery and strict request resolution (#650)
Adds render.backends, a metadata-only command that lists the render backends, their executors and profiles, with strict request resolution in the render crate (docs/render-backends.md).
This commit is contained in:
1 parent
1e4387f3ea
commit
0e500cacd3
8 files changed
+832
No files matched your search
@@ -0,0 +1,393 @@
|
||||
//! Capability discovery and strict request resolution. This is metadata, not a render-job API.
|
||||
//! GPU hosts may describe their own executors later; no device or UI types belong here.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Maximum ASCII bytes in a backend or executor identifier.
|
||||
pub const MAX_RENDER_ID_BYTES: usize = 64;
|
||||
|
||||
macro_rules! render_id {
|
||||
($name:ident) => {
|
||||
#[doc = "A validated, bounded identifier: lowercase ASCII letter followed by letters, digits, '.', '_' or '-'."]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct $name(String);
|
||||
|
||||
impl $name {
|
||||
pub fn new(value: &str) -> Result<Self, ResolveError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_RENDER_ID_BYTES
|
||||
|| !value.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
|
||||
|| !value.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b'_' | b'-'))
|
||||
{
|
||||
return Err(ResolveError::InvalidId);
|
||||
}
|
||||
Ok(Self(value.into()))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<String> for $name {
|
||||
type Error = ResolveError;
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
Self::new(&value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<$name> for String {
|
||||
fn from(value: $name) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
render_id!(BackendId);
|
||||
render_id!(ExecutorId);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RenderMode {
|
||||
Quick,
|
||||
Preview,
|
||||
Final,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RenderOperation {
|
||||
Viewport,
|
||||
Image,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RenderFormat {
|
||||
Png,
|
||||
Exr,
|
||||
}
|
||||
|
||||
/// Features callers can require. Listing a variant does not advertise support for it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RenderFeature {
|
||||
Hdri,
|
||||
Pbr,
|
||||
Refraction,
|
||||
Progressive,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum Availability {
|
||||
Available,
|
||||
Unavailable { reason: String },
|
||||
}
|
||||
|
||||
/// A supported combination, rather than the Cartesian product of independent feature flags.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RenderProfile {
|
||||
pub mode: RenderMode,
|
||||
pub operation: RenderOperation,
|
||||
/// Image profiles list their formats; viewport profiles have none.
|
||||
pub formats: Vec<RenderFormat>,
|
||||
pub features: Vec<RenderFeature>,
|
||||
}
|
||||
|
||||
/// Admission policy for the new resolver. Existing snapshot entry points retain their limits.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RenderLimits {
|
||||
pub max_dimension: u64,
|
||||
pub max_pixels: u64,
|
||||
pub max_scratch_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for RenderLimits {
|
||||
fn default() -> Self {
|
||||
Self { max_dimension: 4096, max_pixels: 4_194_304, max_scratch_bytes: 256 * 1024 * 1024 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimated pixel-buffer cost, excluding scene/texture/geometry and codec-internal memory.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScratchLayout {
|
||||
pub sample_scale: u64,
|
||||
pub sample_bytes_per_pixel: u64,
|
||||
pub output_bytes_per_pixel: u64,
|
||||
pub encoding_bytes_per_pixel: u64,
|
||||
}
|
||||
|
||||
impl ScratchLayout {
|
||||
/// Current rasterizer: 2× in each dimension, RGBA8 + f32 depth at both resolutions,
|
||||
/// plus an allowance for one RGBA copy and encoded output (8 bytes/output pixel).
|
||||
pub const CPU_RASTER: Self = Self { sample_scale: 2, sample_bytes_per_pixel: 8, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 8 };
|
||||
|
||||
pub fn estimate(self, width: u64, height: u64) -> Result<u64, ResolveError> {
|
||||
let overflow = ResolveError::SizeOverflow;
|
||||
let pixels = width.checked_mul(height).ok_or(overflow.clone())?;
|
||||
let sw = width.checked_mul(self.sample_scale).ok_or(overflow.clone())?;
|
||||
let sh = height.checked_mul(self.sample_scale).ok_or(overflow.clone())?;
|
||||
let samples = sw.checked_mul(sh).ok_or(overflow.clone())?;
|
||||
let sampled = samples.checked_mul(self.sample_bytes_per_pixel).ok_or(overflow.clone())?;
|
||||
let output = pixels.checked_mul(self.output_bytes_per_pixel).ok_or(overflow.clone())?;
|
||||
let encoding = pixels.checked_mul(self.encoding_bytes_per_pixel).ok_or(overflow.clone())?;
|
||||
let bytes = sampled.checked_add(output).and_then(|n| n.checked_add(encoding)).ok_or(overflow.clone())?;
|
||||
// A request valid on a 64-bit host must not overflow allocations on wasm32.
|
||||
for value in [sw, sh, samples, pixels, bytes] {
|
||||
let value = usize::try_from(value).map_err(|_| overflow.clone())?;
|
||||
if value > isize::MAX as usize {
|
||||
return Err(overflow);
|
||||
}
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExecutorDescriptor {
|
||||
pub id: ExecutorId,
|
||||
pub availability: Availability,
|
||||
pub profiles: Vec<RenderProfile>,
|
||||
pub limits: RenderLimits,
|
||||
pub scratch: ScratchLayout,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BackendDescriptor {
|
||||
pub id: BackendId,
|
||||
pub label: String,
|
||||
pub availability: Availability,
|
||||
pub executors: Vec<ExecutorDescriptor>,
|
||||
}
|
||||
|
||||
/// Only strict resolution exists. Unknown JSON policies fail rather than imply a fallback.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RenderPolicy {
|
||||
#[default]
|
||||
Strict,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RenderRequest {
|
||||
/// Omitted means the integrated backend, never "whichever engine happens to be available".
|
||||
#[serde(default)]
|
||||
pub backend: Option<BackendId>,
|
||||
pub mode: RenderMode,
|
||||
pub operation: RenderOperation,
|
||||
pub width: u64,
|
||||
pub height: u64,
|
||||
pub format: Option<RenderFormat>,
|
||||
#[serde(default)]
|
||||
pub features: Vec<RenderFeature>,
|
||||
#[serde(default)]
|
||||
pub policy: RenderPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct ResolvedRequest {
|
||||
pub backend: BackendId,
|
||||
pub executor: ExecutorId,
|
||||
pub profile: RenderProfile,
|
||||
pub width: u64,
|
||||
pub height: u64,
|
||||
pub format: Option<RenderFormat>,
|
||||
pub estimated_scratch_bytes: u64,
|
||||
/// Always empty under the only supported policy, Strict.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ResolveError {
|
||||
InvalidId,
|
||||
InvalidRequest(&'static str),
|
||||
InvalidRegistry(&'static str),
|
||||
DuplicateBackend(BackendId),
|
||||
DuplicateExecutor { backend: BackendId, executor: ExecutorId },
|
||||
UnknownBackend(BackendId),
|
||||
BackendUnavailable(String),
|
||||
NoAvailableExecutor,
|
||||
UnsupportedProfile { mode: RenderMode, operation: RenderOperation },
|
||||
UnsupportedFormat,
|
||||
UnsupportedFeature(RenderFeature),
|
||||
InvalidDimensions,
|
||||
LimitExceeded { resource: &'static str, requested: u64, limit: u64 },
|
||||
SizeOverflow,
|
||||
}
|
||||
|
||||
impl fmt::Display for ResolveError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidId => write!(
|
||||
f,
|
||||
"render identifier must be 1–64 ASCII bytes, start with a lowercase letter and contain only lowercase letters, digits, '.', '_' or '-'"
|
||||
),
|
||||
Self::InvalidRequest(reason) => write!(f, "invalid render request: {reason}"),
|
||||
Self::InvalidRegistry(reason) => write!(f, "invalid render registry: {reason}"),
|
||||
Self::DuplicateBackend(id) => write!(f, "duplicate render backend: {}", id.as_str()),
|
||||
Self::DuplicateExecutor { backend, executor } => write!(f, "duplicate executor {} in backend {}", executor.as_str(), backend.as_str()),
|
||||
Self::UnknownBackend(id) => write!(f, "unknown render backend: {}", id.as_str()),
|
||||
Self::BackendUnavailable(reason) => write!(f, "render backend unavailable: {reason}"),
|
||||
Self::NoAvailableExecutor => write!(f, "render backend has no available executor"),
|
||||
Self::UnsupportedProfile { mode, operation } => write!(f, "unsupported render profile: {mode:?}/{operation:?}"),
|
||||
Self::UnsupportedFormat => write!(f, "unsupported render output format for this profile"),
|
||||
Self::UnsupportedFeature(feature) => write!(f, "unsupported render feature: {feature:?}"),
|
||||
Self::InvalidDimensions => write!(f, "render dimensions must be positive"),
|
||||
Self::LimitExceeded { resource, requested, limit } => write!(f, "render {resource} exceeds limit: {requested} > {limit}"),
|
||||
Self::SizeOverflow => write!(f, "render buffer size overflows the supported address space"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ResolveError {}
|
||||
|
||||
/// The existing CPU raster snapshot capability. No device is probed and no job is started.
|
||||
pub fn builtin_backend() -> BackendDescriptor {
|
||||
BackendDescriptor {
|
||||
id: BackendId("builtin".into()),
|
||||
label: "Integrated".into(),
|
||||
availability: Availability::Available,
|
||||
executors: vec![ExecutorDescriptor {
|
||||
id: ExecutorId("cpu-raster".into()),
|
||||
availability: Availability::Available,
|
||||
profiles: vec![RenderProfile {
|
||||
mode: RenderMode::Preview,
|
||||
operation: RenderOperation::Image,
|
||||
formats: vec![RenderFormat::Png],
|
||||
features: Vec::new(),
|
||||
}],
|
||||
limits: RenderLimits::default(),
|
||||
scratch: ScratchLayout::CPU_RASTER,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_registry(backends: &[BackendDescriptor]) -> Result<(), ResolveError> {
|
||||
if backends.len() > 32 {
|
||||
return Err(ResolveError::InvalidRegistry("too many backends (maximum 32)"));
|
||||
}
|
||||
let mut ids = BTreeSet::new();
|
||||
for backend in backends {
|
||||
if !ids.insert(&backend.id) {
|
||||
return Err(ResolveError::DuplicateBackend(backend.id.clone()));
|
||||
}
|
||||
if backend.executors.len() > 16 {
|
||||
return Err(ResolveError::InvalidRegistry("too many executors (maximum 16 per backend)"));
|
||||
}
|
||||
let mut executors = BTreeSet::new();
|
||||
for executor in &backend.executors {
|
||||
if !executors.insert(&executor.id) {
|
||||
return Err(ResolveError::DuplicateExecutor { backend: backend.id.clone(), executor: executor.id.clone() });
|
||||
}
|
||||
if executor.profiles.len() > 16 || executor.scratch.sample_scale == 0 {
|
||||
return Err(ResolveError::InvalidRegistry("invalid profile count or zero sample scale"));
|
||||
}
|
||||
let mut profiles = BTreeSet::new();
|
||||
for profile in &executor.profiles {
|
||||
if profile.formats.len() > 16 || profile.features.len() > 32 {
|
||||
return Err(ResolveError::InvalidRegistry("too many formats or features"));
|
||||
}
|
||||
if !profiles.insert((profile.mode, profile.operation)) {
|
||||
return Err(ResolveError::InvalidRegistry("duplicate mode/operation profile"));
|
||||
}
|
||||
let formats: BTreeSet<_> = profile.formats.iter().collect();
|
||||
let features: BTreeSet<_> = profile.features.iter().collect();
|
||||
if formats.len() != profile.formats.len() || features.len() != profile.features.len() {
|
||||
return Err(ResolveError::InvalidRegistry("duplicate format or feature"));
|
||||
}
|
||||
if (profile.operation == RenderOperation::Image) == profile.formats.is_empty() {
|
||||
return Err(ResolveError::InvalidRegistry("image profiles require formats; viewport profiles must have none"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_limit(resource: &'static str, requested: u64, limit: u64) -> Result<(), ResolveError> {
|
||||
if requested > limit { Err(ResolveError::LimitExceeded { resource, requested, limit }) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn check_request(executor: &ExecutorDescriptor, profile: &RenderProfile, request: &RenderRequest) -> Result<u64, ResolveError> {
|
||||
match (request.operation, request.format) {
|
||||
(RenderOperation::Image, Some(format)) if profile.formats.contains(&format) => {}
|
||||
(RenderOperation::Viewport, None) => {}
|
||||
_ => return Err(ResolveError::UnsupportedFormat),
|
||||
}
|
||||
// Stable diagnostics even if callers reorder required features.
|
||||
for feature in request.features.iter().collect::<BTreeSet<_>>() {
|
||||
if !profile.features.contains(feature) {
|
||||
return Err(ResolveError::UnsupportedFeature(*feature));
|
||||
}
|
||||
}
|
||||
let limits = executor.limits;
|
||||
check_limit("width", request.width, limits.max_dimension)?;
|
||||
check_limit("height", request.height, limits.max_dimension)?;
|
||||
let pixels = request.width.checked_mul(request.height).ok_or(ResolveError::SizeOverflow)?;
|
||||
check_limit("pixels", pixels, limits.max_pixels)?;
|
||||
let scratch = executor.scratch.estimate(request.width, request.height)?;
|
||||
check_limit("estimated scratch bytes", scratch, limits.max_scratch_bytes)?;
|
||||
Ok(scratch)
|
||||
}
|
||||
|
||||
/// Selects the lexicographically first available executor that satisfies the complete request.
|
||||
/// Order in the registry never implies priority; explicit backends never fall back to another.
|
||||
pub fn resolve_request(backends: &[BackendDescriptor], request: &RenderRequest) -> Result<ResolvedRequest, ResolveError> {
|
||||
validate_registry(backends)?;
|
||||
if request.width == 0 || request.height == 0 {
|
||||
return Err(ResolveError::InvalidDimensions);
|
||||
}
|
||||
if request.features.len() > 32 {
|
||||
return Err(ResolveError::InvalidRequest("too many required features (maximum 32)"));
|
||||
}
|
||||
let id = request.backend.clone().unwrap_or_else(|| BackendId("builtin".into()));
|
||||
let backend = backends.iter().find(|b| b.id == id).ok_or_else(|| ResolveError::UnknownBackend(id.clone()))?;
|
||||
if let Availability::Unavailable { reason } = &backend.availability {
|
||||
return Err(ResolveError::BackendUnavailable(reason.clone()));
|
||||
}
|
||||
let mut executors: Vec<_> = backend.executors.iter().filter(|e| e.availability == Availability::Available).collect();
|
||||
executors.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
if executors.is_empty() {
|
||||
return Err(ResolveError::NoAvailableExecutor);
|
||||
}
|
||||
let mut failure = None;
|
||||
for executor in executors {
|
||||
let Some(profile) = executor.profiles.iter().find(|p| p.mode == request.mode && p.operation == request.operation) else { continue };
|
||||
match check_request(executor, profile, request) {
|
||||
Ok(estimated_scratch_bytes) => {
|
||||
let mut profile = profile.clone();
|
||||
profile.formats.sort();
|
||||
profile.features.sort();
|
||||
return Ok(ResolvedRequest {
|
||||
backend: id,
|
||||
executor: executor.id.clone(),
|
||||
profile,
|
||||
width: request.width,
|
||||
height: request.height,
|
||||
format: request.format,
|
||||
estimated_scratch_bytes,
|
||||
warnings: Vec::new(),
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
failure.get_or_insert(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(failure.unwrap_or(ResolveError::UnsupportedProfile { mode: request.mode, operation: request.operation }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
Reference in new issue
Block a user