Render: save wood images with physical grain controls (#1599)
* Render: save wood images with physical grain controls * Render: prepare plain glass before marking the GPU host ready
This commit is contained in:
1 parent
6d2465b52d
commit
8691cefbbf
60 files changed
+5776
-238
No files matched your search
@@ -225,6 +225,12 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## Render foundation
|
||||
|
||||
- Wood surface images (#1345): native CPU and GPU opaque rendering share physical
|
||||
repeat frames and filtered mip chains. Imported maps retain exact bytes and
|
||||
declared provenance in the design, reopen offline, and expose grain-angle
|
||||
controls. Scalar face appearances suppress body maps. Planar projection and
|
||||
CPU encoding/optical limits remain explicit; see [portable surfaces](docs/render-portable-surfaces-and-stages.md).
|
||||
|
||||
- Original starter appearances (#1722): 34 opaque recipes through existing commands, with six explicitly unavailable glass recipes and preserved legacy looks; see [the contract](docs/render-starter-appearances.md).
|
||||
|
||||
- Bounded linear film accumulation (#1696) supplies caller-admitted mean/variance storage and complete-pass views; actual progressive consumers remain separate. See [the film contract](docs/render-film.md).
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"de": {
|
||||
"render.stage_panels.grain_angle": "Maserungswinkel",
|
||||
"render.surface_import.author": "Autor",
|
||||
"render.surface_import.busy": "Bild wird importiert…",
|
||||
"render.surface_import.complete": "Wählen Sie ein Bild und geben Sie Autor, Quelle und Lizenz an.",
|
||||
"render.surface_import.embedded": "Importiertes Bild",
|
||||
"render.surface_import.failed": "Der Bildimport wurde nicht abgeschlossen.",
|
||||
"render.surface_import.license": "Lizenz / Eigentum",
|
||||
"render.surface_import.repeat_u": "Wiederholung U",
|
||||
"render.surface_import.repeat_v": "Wiederholung V",
|
||||
"render.surface_import.save": "Bild am Körper speichern",
|
||||
"render.surface_import.saved_cpu": "Speichern Sie Texturen für das CPU-Rendering.",
|
||||
"render.surface_import.source": "Quelle",
|
||||
"render.surface_import.title": "Bild importieren",
|
||||
"render.surface_import.raytrace_required": "Bilder erfordern CPU- oder GPU-Raytracing. Wählen Sie es unter Ausgabe.",
|
||||
"cmd.render.surface.import": "Oberflächentexturen importieren",
|
||||
"cmd.render.texture.list": "Eingebettete Oberflächenbilder auflisten",
|
||||
"cmd.render.texture.remove": "Eingebettetes Oberflächenbild entfernen"
|
||||
},
|
||||
"en": {
|
||||
"render.stage_panels.grain_angle": "Grain angle",
|
||||
"render.surface_import.author": "Author",
|
||||
"render.surface_import.busy": "Importing image…",
|
||||
"render.surface_import.complete": "Choose an image and enter its author, source and license.",
|
||||
"render.surface_import.embedded": "Imported image",
|
||||
"render.surface_import.failed": "The image import did not finish.",
|
||||
"render.surface_import.license": "License / ownership",
|
||||
"render.surface_import.repeat_u": "Repeat U",
|
||||
"render.surface_import.repeat_v": "Repeat V",
|
||||
"render.surface_import.save": "Save image on body",
|
||||
"render.surface_import.saved_cpu": "Save maps to use them with CPU rendering.",
|
||||
"render.surface_import.source": "Source",
|
||||
"render.surface_import.title": "Import image",
|
||||
"render.surface_import.raytrace_required": "Images require CPU or GPU ray tracing. Choose it in Output.",
|
||||
"cmd.render.surface.import": "Import Surface Maps",
|
||||
"cmd.render.texture.list": "List Embedded Surface Images",
|
||||
"cmd.render.texture.remove": "Remove Embedded Surface Image"
|
||||
},
|
||||
"ja": {
|
||||
"render.stage_panels.grain_angle": "木目の角度",
|
||||
"render.surface_import.author": "作者",
|
||||
"render.surface_import.busy": "画像をインポート中…",
|
||||
"render.surface_import.complete": "画像を選び、作者、出典、ライセンスを入力してください。",
|
||||
"render.surface_import.embedded": "インポートした画像",
|
||||
"render.surface_import.failed": "画像のインポートが完了しませんでした。",
|
||||
"render.surface_import.license": "ライセンス / 所有権",
|
||||
"render.surface_import.repeat_u": "U方向の繰り返し",
|
||||
"render.surface_import.repeat_v": "V方向の繰り返し",
|
||||
"render.surface_import.save": "ボディに画像を保存",
|
||||
"render.surface_import.saved_cpu": "CPUレンダリングで使うにはマップを保存してください。",
|
||||
"render.surface_import.source": "出典",
|
||||
"render.surface_import.title": "画像をインポート",
|
||||
"render.surface_import.raytrace_required": "画像にはCPUまたはGPUのレイトレーシングが必要です。出力で選択してください。",
|
||||
"cmd.render.surface.import": "サーフェスマップをインポート",
|
||||
"cmd.render.texture.list": "埋め込みサーフェス画像の一覧",
|
||||
"cmd.render.texture.remove": "埋め込みサーフェス画像を削除"
|
||||
},
|
||||
"ko": {
|
||||
"render.stage_panels.grain_angle": "나뭇결 각도",
|
||||
"render.surface_import.author": "작성자",
|
||||
"render.surface_import.busy": "이미지 가져오는 중…",
|
||||
"render.surface_import.complete": "이미지를 선택하고 작성자, 출처, 라이선스를 입력하세요.",
|
||||
"render.surface_import.embedded": "가져온 이미지",
|
||||
"render.surface_import.failed": "이미지 가져오기가 완료되지 않았습니다.",
|
||||
"render.surface_import.license": "라이선스 / 소유권",
|
||||
"render.surface_import.repeat_u": "U 반복",
|
||||
"render.surface_import.repeat_v": "V 반복",
|
||||
"render.surface_import.save": "바디에 이미지 저장",
|
||||
"render.surface_import.saved_cpu": "CPU 렌더링에서 사용하려면 맵을 저장하세요.",
|
||||
"render.surface_import.source": "출처",
|
||||
"render.surface_import.title": "이미지 가져오기",
|
||||
"render.surface_import.raytrace_required": "이미지에는 CPU 또는 GPU 레이 트레이싱이 필요합니다. 출력에서 선택하세요.",
|
||||
"cmd.render.surface.import": "표면 맵 가져오기",
|
||||
"cmd.render.texture.list": "포함된 표면 이미지 목록",
|
||||
"cmd.render.texture.remove": "포함된 표면 이미지 제거"
|
||||
},
|
||||
"zh": {
|
||||
"render.stage_panels.grain_angle": "木纹角度",
|
||||
"render.surface_import.author": "作者",
|
||||
"render.surface_import.busy": "正在导入图像…",
|
||||
"render.surface_import.complete": "请选择图像并填写作者、来源和许可。",
|
||||
"render.surface_import.embedded": "导入的图像",
|
||||
"render.surface_import.failed": "图像导入未完成。",
|
||||
"render.surface_import.license": "许可 / 所有权",
|
||||
"render.surface_import.repeat_u": "U 方向重复",
|
||||
"render.surface_import.repeat_v": "V 方向重复",
|
||||
"render.surface_import.save": "将图像保存到实体",
|
||||
"render.surface_import.saved_cpu": "保存贴图以用于 CPU 渲染。",
|
||||
"render.surface_import.source": "来源",
|
||||
"render.surface_import.title": "导入图像",
|
||||
"render.surface_import.raytrace_required": "图像需要 CPU 或 GPU 光线追踪。请在输出中选择。",
|
||||
"cmd.render.surface.import": "导入表面贴图",
|
||||
"cmd.render.texture.list": "列出嵌入的表面图像",
|
||||
"cmd.render.texture.remove": "移除嵌入的表面图像"
|
||||
},
|
||||
"en-GB": {
|
||||
"render.surface_import.complete": "Choose an image and enter its author, source and licence.",
|
||||
"render.surface_import.license": "Licence / ownership"
|
||||
},
|
||||
"ar": {
|
||||
"render.stage_panels.grain_angle": "زاوية عروق الخشب",
|
||||
"render.surface_import.author": "المؤلف",
|
||||
"render.surface_import.busy": "جارٍ استيراد الصورة…",
|
||||
"render.surface_import.complete": "اختر صورة وأدخل مؤلفها ومصدرها وترخيصها.",
|
||||
"render.surface_import.embedded": "صورة مستوردة",
|
||||
"render.surface_import.failed": "لم يكتمل استيراد الصورة.",
|
||||
"render.surface_import.license": "الترخيص / الملكية",
|
||||
"render.surface_import.repeat_u": "تكرار U",
|
||||
"render.surface_import.repeat_v": "تكرار V",
|
||||
"render.surface_import.save": "حفظ الصورة على الجسم",
|
||||
"render.surface_import.saved_cpu": "احفظ الخرائط لاستخدامها في التصيير على CPU.",
|
||||
"render.surface_import.source": "المصدر",
|
||||
"render.surface_import.title": "استيراد صورة",
|
||||
"render.surface_import.raytrace_required": "تتطلب الصور تتبع الأشعة على CPU أو GPU. اختره في الإخراج.",
|
||||
"cmd.render.surface.import": "استيراد خرائط السطح",
|
||||
"cmd.render.texture.list": "عرض صور السطح المضمّنة",
|
||||
"cmd.render.texture.remove": "إزالة صورة السطح المضمّنة"
|
||||
},
|
||||
"he": {
|
||||
"render.stage_panels.grain_angle": "זווית סיבי העץ",
|
||||
"render.surface_import.author": "יוצר",
|
||||
"render.surface_import.busy": "מתבצע ייבוא תמונה…",
|
||||
"render.surface_import.complete": "בחר תמונה והזן את היוצר, המקור והרישיון שלה.",
|
||||
"render.surface_import.embedded": "תמונה מיובאת",
|
||||
"render.surface_import.failed": "ייבוא התמונה לא הושלם.",
|
||||
"render.surface_import.license": "רישיון / בעלות",
|
||||
"render.surface_import.repeat_u": "חזרת U",
|
||||
"render.surface_import.repeat_v": "חזרת V",
|
||||
"render.surface_import.save": "שמירת תמונה על הגוף",
|
||||
"render.surface_import.saved_cpu": "יש לשמור את המפות לשימוש בהדמיה ב־CPU.",
|
||||
"render.surface_import.source": "מקור",
|
||||
"render.surface_import.title": "ייבוא תמונה",
|
||||
"render.surface_import.raytrace_required": "תמונות מחייבות מעקב קרניים ב־CPU או ב־GPU. יש לבחור זאת תחת פלט.",
|
||||
"cmd.render.surface.import": "ייבוא מפות משטח",
|
||||
"cmd.render.texture.list": "הצגת תמונות משטח מוטמעות",
|
||||
"cmd.render.texture.remove": "הסרת תמונת משטח מוטמעת"
|
||||
},
|
||||
"vi": {
|
||||
"render.stage_panels.grain_angle": "Góc vân gỗ",
|
||||
"render.surface_import.author": "Tác giả",
|
||||
"render.surface_import.busy": "Đang nhập ảnh…",
|
||||
"render.surface_import.complete": "Chọn ảnh rồi nhập tác giả, nguồn và giấy phép.",
|
||||
"render.surface_import.embedded": "Ảnh đã nhập",
|
||||
"render.surface_import.failed": "Quá trình nhập ảnh chưa hoàn tất.",
|
||||
"render.surface_import.license": "Giấy phép / quyền sở hữu",
|
||||
"render.surface_import.repeat_u": "Chu kỳ U",
|
||||
"render.surface_import.repeat_v": "Chu kỳ V",
|
||||
"render.surface_import.save": "Lưu ảnh trên khối",
|
||||
"render.surface_import.saved_cpu": "Lưu bản đồ để dùng khi kết xuất bằng CPU.",
|
||||
"render.surface_import.source": "Nguồn",
|
||||
"render.surface_import.title": "Nhập ảnh",
|
||||
"render.surface_import.raytrace_required": "Ảnh cần dò tia trên CPU hoặc GPU. Hãy chọn trong phần Đầu ra.",
|
||||
"cmd.render.surface.import": "Nhập bản đồ bề mặt",
|
||||
"cmd.render.texture.list": "Liệt kê ảnh bề mặt được nhúng",
|
||||
"cmd.render.texture.remove": "Xóa ảnh bề mặt được nhúng"
|
||||
}
|
||||
}
|
||||
@@ -3910,6 +3910,7 @@ impl Document {
|
||||
/// Read a design file's JSON (any format this build knows; older ones are upgraded).
|
||||
pub fn from_json(s: &str) -> Result<Document> {
|
||||
crate::render_resources::check_json_budget(s)?;
|
||||
crate::render_texture_resources::check_json_budget(s)?;
|
||||
let mut d = crate::format::read(s)?;
|
||||
if d.features.len() > MAX_FEATURES || d.params.len() > MAX_PARAMS {
|
||||
return Err(DocError::Invalid("document too large".into()));
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Saves exact encoded texture bytes, inert provenance and explicit saved surface roles.
|
||||
//! Older designs have no embedded textures and retain their built-in surface bindings.
|
||||
//! The last release asks for an update instead of silently dropping the new resources.
|
||||
@@ -77,6 +77,7 @@ pub mod render_optics;
|
||||
pub mod render_resources;
|
||||
pub mod render_settings;
|
||||
pub mod render_surface;
|
||||
pub mod render_texture_resources;
|
||||
pub mod sheet;
|
||||
pub mod sketch_equation;
|
||||
pub mod text_params;
|
||||
|
||||
@@ -280,7 +280,7 @@ pub fn encode_hdr_base64(bytes: &[u8]) -> Result<String> {
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
fn decode_base64(value: &str, maximum: usize) -> Result<Vec<u8>> {
|
||||
pub(crate) fn decode_base64(value: &str, maximum: usize) -> Result<Vec<u8>> {
|
||||
let length = base64_length(value, maximum)?;
|
||||
let padding = if value.ends_with("==") { 2 } else { usize::from(value.ends_with('=')) };
|
||||
let mut bytes = Vec::new();
|
||||
@@ -420,7 +420,7 @@ impl<'de> DeserializeSeed<'de> for EncodedBudget {
|
||||
// RawValue borrows the JSON source without building serde_json's unescaped String scratch.
|
||||
// Count base64 characters (including legal JSON slash/Unicode escapes) before any payload
|
||||
// allocation. Full canonical padding, stream, radiance and digest checks still follow.
|
||||
fn json_base64_length(raw: &str, maximum: usize) -> Result<usize> {
|
||||
pub(crate) fn json_base64_length(raw: &str, maximum: usize) -> Result<usize> {
|
||||
let text = raw.strip_prefix('"').and_then(|s| s.strip_suffix('"')).ok_or_else(|| invalid("HDR data must be a base64 string"))?;
|
||||
let mut bytes = text.as_bytes().iter().copied();
|
||||
let mut count = 0usize;
|
||||
|
||||
@@ -121,8 +121,10 @@ impl RenderSetup {
|
||||
if !self.stage.is_empty() && matches!(self.job, Some(RenderJob::Raster { .. })) {
|
||||
return Err(DocError::Invalid("passive stage geometry requires raytracing; raster cannot silently discard it".into()));
|
||||
}
|
||||
if !self.surfaces.is_empty() && !matches!(self.job, Some(RenderJob::GpuPath { .. })) {
|
||||
return Err(DocError::Invalid("saved surfaces require an explicit GPU job; unsupported methods cannot discard bindings".into()));
|
||||
if !self.surfaces.is_empty() && !matches!(self.job, Some(RenderJob::CpuRaytrace { .. } | RenderJob::GpuPath { .. })) {
|
||||
return Err(DocError::Invalid(
|
||||
"saved surfaces require an explicit CPU raytrace or GPU path job; unsupported methods cannot discard bindings".into(),
|
||||
));
|
||||
}
|
||||
let (width, height) = (u64::from(self.width), u64::from(self.height));
|
||||
if width == 0 || height == 0 {
|
||||
@@ -185,6 +187,8 @@ pub struct RenderSettings {
|
||||
pub setups: BTreeMap<String, RenderSetup>,
|
||||
#[serde(skip_serializing_if = "crate::render_resources::HdrResources::is_empty")]
|
||||
pub resources: crate::render_resources::HdrResources,
|
||||
#[serde(skip_serializing_if = "crate::render_texture_resources::TextureResources::is_empty")]
|
||||
pub texture_resources: crate::render_texture_resources::TextureResources,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
@@ -192,12 +196,13 @@ pub struct RenderSettings {
|
||||
struct UncheckedSettings {
|
||||
setups: BTreeMap<String, RenderSetup>,
|
||||
resources: crate::render_resources::HdrResources,
|
||||
texture_resources: crate::render_texture_resources::TextureResources,
|
||||
}
|
||||
|
||||
impl TryFrom<UncheckedSettings> for RenderSettings {
|
||||
type Error = DocError;
|
||||
fn try_from(value: UncheckedSettings) -> Result<Self> {
|
||||
let settings = Self { setups: value.setups, resources: value.resources };
|
||||
let settings = Self { setups: value.setups, resources: value.resources, texture_resources: value.texture_resources };
|
||||
settings.validate()?;
|
||||
Ok(settings)
|
||||
}
|
||||
@@ -205,7 +210,7 @@ impl TryFrom<UncheckedSettings> for RenderSettings {
|
||||
|
||||
impl RenderSettings {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.setups.is_empty() && self.resources.is_empty()
|
||||
self.setups.is_empty() && self.resources.is_empty() && self.texture_resources.is_empty()
|
||||
}
|
||||
|
||||
pub fn validate_name(name: &str) -> Result<()> {
|
||||
@@ -228,6 +233,17 @@ impl RenderSettings {
|
||||
}
|
||||
|
||||
pub fn validate_resources(&self, setup: &RenderSetup) -> Result<()> {
|
||||
for surface in setup.surfaces.iter() {
|
||||
if let crate::render_surface::SurfaceSource::Embedded(source) = &surface.source {
|
||||
for (role, id) in source.role_resources() {
|
||||
let resource = self.texture_resources.required(&id)?;
|
||||
resource.validate_role(role)?;
|
||||
if matches!(setup.job, Some(RenderJob::CpuRaytrace { .. })) {
|
||||
resource.validate_cpu_encoding()?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match &setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
if let Some(hdr) = options.hdr {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Portable built-in surface intent. Targets use definition/feature/occurrence identity,
|
||||
//! never a captured mesh ordinal. Frames are definition-local millimetres. Encoded map
|
||||
//! hashes pin the installed built-in resource revision; no paths or device objects are saved.
|
||||
//! Portable opaque surface intent with built-in or design-owned encoded sources.
|
||||
//! Targets use definition/feature/occurrence identity, never a captured mesh ordinal.
|
||||
//! Frames are definition-local millimetres; no paths or device objects are saved.
|
||||
use crate::{DocError, Result, render_resources::HdrId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_render::job::textures::PlanarFrame;
|
||||
@@ -72,10 +72,74 @@ impl BuiltinSurfaceSource {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SavedSurface {
|
||||
pub target: RenderBodyTarget,
|
||||
pub source: BuiltinSurfaceSource,
|
||||
pub source: SurfaceSource,
|
||||
pub local_frame: PlanarFrame,
|
||||
pub normal_strength: f64,
|
||||
}
|
||||
|
||||
/// Built-in records retain their original wire shape. Embedded records explicitly
|
||||
/// identify each role; one digest may have several independently prepared uses.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum SurfaceSource {
|
||||
Builtin(BuiltinSurfaceSource),
|
||||
Embedded(EmbeddedSurfaceSource),
|
||||
}
|
||||
impl From<BuiltinSurfaceSource> for SurfaceSource {
|
||||
fn from(source: BuiltinSurfaceSource) -> Self {
|
||||
Self::Builtin(source)
|
||||
}
|
||||
}
|
||||
impl SurfaceSource {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
match self {
|
||||
Self::Builtin(source) => source.validate(),
|
||||
Self::Embedded(source) => source.validate(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EmbeddedSurfaceKind {
|
||||
Embedded,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EmbeddedSurfaceSource {
|
||||
pub kind: EmbeddedSurfaceKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub base_color: Option<HdrId>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub normal_open_gl: Option<HdrId>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub roughness: Option<HdrId>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub metallic: Option<HdrId>,
|
||||
#[serde(default)]
|
||||
pub flip_v: bool,
|
||||
}
|
||||
impl EmbeddedSurfaceSource {
|
||||
pub fn role_resources(&self) -> impl Iterator<Item = (solidcraft_render::texture_input::MapRole, HdrId)> + '_ {
|
||||
use solidcraft_render::texture_input::MapRole;
|
||||
[
|
||||
(MapRole::BaseColor, self.base_color),
|
||||
(MapRole::NormalOpenGl, self.normal_open_gl),
|
||||
(MapRole::Roughness, self.roughness),
|
||||
(MapRole::Metallic, self.metallic),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(role, id)| id.map(|id| (role, id)))
|
||||
}
|
||||
pub fn resources(&self) -> impl Iterator<Item = HdrId> + '_ {
|
||||
[self.base_color, self.normal_open_gl, self.roughness, self.metallic].into_iter().flatten()
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.resources().next().is_none() {
|
||||
return Err(invalid("an embedded surface requires at least one explicit map role"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl SavedSurface {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.target.validate()?;
|
||||
@@ -190,7 +254,8 @@ mod tests {
|
||||
normal_sha256: HdrId::of(b"normal"),
|
||||
roughness_sha256: HdrId::of(b"rough"),
|
||||
metallic_sha256: HdrId::of(b"metal"),
|
||||
},
|
||||
}
|
||||
.into(),
|
||||
local_frame: PlanarFrame { origin: [1., 2., 3.], u: [100., 0., 0.], v: [0., 100., 0.] },
|
||||
normal_strength: 0.75,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,580 @@
|
||||
//! Exact, immutable opaque image bytes owned by a design. Interpretation belongs
|
||||
//! to each saved map role; provenance is bounded inert text, never a permission grant.
|
||||
use crate::{DocError, Result, render_resources::HdrId};
|
||||
use serde::{Deserialize, Serialize, de::DeserializeSeed, ser::SerializeStruct};
|
||||
use solidcraft_render::texture_input::{MapRole, TextureSource};
|
||||
use std::{collections::BTreeMap, fmt, sync::Arc};
|
||||
|
||||
pub const MAX_TEXTURE_RESOURCES: usize = 32;
|
||||
pub const MAX_TEXTURE_RESOURCE_BYTES: usize = 64 * 1024 * 1024;
|
||||
pub const MAX_TEXTURE_BYTES: usize = solidcraft_render::texture_input::MAX_TEXTURE_BYTES;
|
||||
pub const MAX_TEXTURE_EDGE: u32 = 1024;
|
||||
const MAX_PROVENANCE_JSON: usize = 48 * 1024;
|
||||
fn invalid(message: impl Into<String>) -> DocError {
|
||||
DocError::Invalid(message.into())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct TextureProvenance {
|
||||
#[serde(deserialize_with = "bounded_text")]
|
||||
pub author: String,
|
||||
#[serde(deserialize_with = "bounded_text")]
|
||||
pub source: String,
|
||||
#[serde(deserialize_with = "bounded_text")]
|
||||
pub license: String,
|
||||
}
|
||||
impl TextureProvenance {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
for (text, maximum) in [(&self.author, 512), (&self.source, 2048), (&self.license, 4096)] {
|
||||
if text.len() > maximum || text.chars().any(char::is_control) {
|
||||
return Err(invalid("texture provenance requires bounded inert author, source and license text without control characters"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TextureResource {
|
||||
id: HdrId,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
dimensions: [u32; 2],
|
||||
provenance: TextureProvenance,
|
||||
}
|
||||
impl TextureResource {
|
||||
/// Import runs on the caller's existing worker. Full decode rejects corrupt streams;
|
||||
/// the temporary source and pixels are released before immutable publication.
|
||||
pub fn new(bytes: Vec<u8>, provenance: TextureProvenance) -> Result<Self> {
|
||||
provenance.validate()?;
|
||||
if bytes.is_empty() || bytes.len() > MAX_TEXTURE_BYTES || bytes.capacity() > MAX_TEXTURE_BYTES {
|
||||
return Err(invalid("encoded texture capacity must contain at most 16 MiB"));
|
||||
}
|
||||
reject_animation(&bytes)?;
|
||||
let mut checked = Vec::new();
|
||||
checked.try_reserve_exact(bytes.len()).map_err(|_| invalid("could not reserve texture validation bytes"))?;
|
||||
checked.extend_from_slice(&bytes);
|
||||
let source = TextureSource::new_portable(checked, MapRole::Roughness).map_err(|e| invalid(e.to_string()))?;
|
||||
let dimensions = [source.info().width, source.info().height];
|
||||
if dimensions.into_iter().any(|edge| edge == 0 || edge > MAX_TEXTURE_EDGE) {
|
||||
return Err(invalid("embedded texture dimensions exceed 1024x1024"));
|
||||
}
|
||||
source.validate_static_source().map_err(|e| invalid(e.to_string()))?;
|
||||
source.decode(|| false).map_err(|e| invalid(e.to_string()))?;
|
||||
Ok(Self { id: HdrId::of(&bytes), bytes: Arc::new(bytes), dimensions, provenance })
|
||||
}
|
||||
pub fn id(&self) -> HdrId {
|
||||
self.id
|
||||
}
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
&self.bytes
|
||||
}
|
||||
pub fn dimensions(&self) -> [u32; 2] {
|
||||
self.dimensions
|
||||
}
|
||||
/// The native cooperative row consumer has a narrower encoding contract
|
||||
/// than GPU preparation. The complete stream was validated at construction.
|
||||
pub fn validate_cpu_encoding(&self) -> Result<()> {
|
||||
if !self.bytes.starts_with(b"\x89PNG\r\n\x1a\n") || self.bytes.get(24).copied() != Some(8) || self.bytes.get(28).copied() != Some(0) {
|
||||
return Err(invalid(
|
||||
"CPU surface maps require noninterlaced 8-bit PNG; keep JPEG/interlaced images on the GPU or normalize them before import",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Binding roles are explicit; one encoded image may be color or linear data.
|
||||
pub fn validate_role(&self, role: MapRole) -> Result<()> {
|
||||
solidcraft_render::texture_input::validate_color_declarations(&self.bytes, role).map_err(|e| invalid(e.to_string()))
|
||||
}
|
||||
pub fn provenance(&self) -> &TextureProvenance {
|
||||
&self.provenance
|
||||
}
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
self.bytes.capacity()
|
||||
}
|
||||
pub fn base64(&self) -> Result<String> {
|
||||
crate::render_resources::encode_hdr_base64(&self.bytes)
|
||||
}
|
||||
}
|
||||
fn reject_animation(bytes: &[u8]) -> Result<()> {
|
||||
if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
return Ok(());
|
||||
}
|
||||
let mut offset = 8usize;
|
||||
while offset < bytes.len() {
|
||||
let header = bytes.get(offset..offset.saturating_add(8)).ok_or_else(|| invalid("truncated texture PNG chunk"))?;
|
||||
let length = header
|
||||
.get(..4)
|
||||
.and_then(|v| <[u8; 4]>::try_from(v).ok())
|
||||
.map(u32::from_be_bytes)
|
||||
.ok_or_else(|| invalid("invalid texture PNG chunk length"))? as usize;
|
||||
if header.get(4..8) == Some(b"acTL") {
|
||||
return Err(invalid("embedded textures require static PNG or JPEG images"));
|
||||
}
|
||||
offset = offset
|
||||
.checked_add(length)
|
||||
.and_then(|n| n.checked_add(12))
|
||||
.filter(|n| *n <= bytes.len())
|
||||
.ok_or_else(|| invalid("texture PNG chunk exceeds encoded bytes"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
impl Serialize for TextureResource {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
let mut value = serializer.serialize_struct("TextureResource", 3)?;
|
||||
value.serialize_field("kind", "opaque_texture")?;
|
||||
value.serialize_field("data", &self.base64().map_err(serde::ser::Error::custom)?)?;
|
||||
value.serialize_field("provenance", &self.provenance)?;
|
||||
value.end()
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct TextureResources(BTreeMap<HdrId, TextureResource>);
|
||||
impl TextureResources {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
pub fn iter(&self) -> impl Iterator<Item = (&HdrId, &TextureResource)> {
|
||||
self.0.iter()
|
||||
}
|
||||
pub fn get(&self, id: &HdrId) -> Option<&TextureResource> {
|
||||
self.0.get(id)
|
||||
}
|
||||
pub fn required(&self, id: &HdrId) -> Result<&TextureResource> {
|
||||
self.get(id).ok_or_else(|| invalid(format!("missing embedded texture resource {id}")))
|
||||
}
|
||||
pub fn total_bytes(&self) -> usize {
|
||||
self.0.values().map(TextureResource::retained_bytes).sum()
|
||||
}
|
||||
/// Identical bytes retain the first stored provenance; importing labels never
|
||||
/// changes content identity or another binding's provenance.
|
||||
pub fn insert(&mut self, resource: TextureResource) -> Result<HdrId> {
|
||||
let id = resource.id();
|
||||
if let Some(old) = self.get(&id) {
|
||||
if old.bytes() != resource.bytes() {
|
||||
return Err(invalid("texture SHA-256 collision"));
|
||||
}
|
||||
return Ok(id);
|
||||
}
|
||||
if self.len() >= MAX_TEXTURE_RESOURCES || resource.retained_bytes() > MAX_TEXTURE_RESOURCE_BYTES.saturating_sub(self.total_bytes()) {
|
||||
return Err(invalid("embedded textures exceed 32 entries or 64 MiB"));
|
||||
}
|
||||
self.0.insert(id, resource);
|
||||
Ok(id)
|
||||
}
|
||||
/// The engine checks saved users before removing a resource transactionally.
|
||||
pub fn remove(&mut self, id: &HdrId) -> Option<TextureResource> {
|
||||
self.0.remove(id)
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for TextureResources {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
struct Resources;
|
||||
impl<'de> serde::de::Visitor<'de> for Resources {
|
||||
type Value = TextureResources;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("32 unique opaque texture sources totalling at most 64 MiB")
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> std::result::Result<Self::Value, A::Error> {
|
||||
let mut result = TextureResources::default();
|
||||
while let Some(id) = map.next_key::<HdrId>()? {
|
||||
if result.len() >= MAX_TEXTURE_RESOURCES || result.get(&id).is_some() {
|
||||
return Err(serde::de::Error::custom("too many or duplicate embedded texture resources"));
|
||||
}
|
||||
let remaining = MAX_TEXTURE_RESOURCE_BYTES.saturating_sub(result.total_bytes()).min(MAX_TEXTURE_BYTES);
|
||||
let resource = map.next_value_seed(ResourceSeed(remaining))?;
|
||||
if resource.id() != id {
|
||||
return Err(serde::de::Error::custom("embedded texture hash mismatch"));
|
||||
}
|
||||
result.insert(resource).map_err(serde::de::Error::custom)?;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_map(Resources)
|
||||
}
|
||||
}
|
||||
struct ResourceSeed(usize);
|
||||
impl<'de> DeserializeSeed<'de> for ResourceSeed {
|
||||
type Value = TextureResource;
|
||||
fn deserialize<D: serde::Deserializer<'de>>(self, deserializer: D) -> std::result::Result<Self::Value, D::Error> {
|
||||
struct ResourceVisitor(usize);
|
||||
impl<'de> serde::de::Visitor<'de> for ResourceVisitor {
|
||||
type Value = TextureResource;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("opaque_texture kind, bounded base64 data and inert provenance")
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> std::result::Result<Self::Value, A::Error> {
|
||||
let (mut kind, mut data, mut provenance) = (false, None, None);
|
||||
while let Some(field) = map.next_key::<Field>()? {
|
||||
match field {
|
||||
Field::Kind if !kind => {
|
||||
map.next_value_seed(KindSeed)?;
|
||||
kind = true;
|
||||
}
|
||||
Field::Data if data.is_none() => data = Some(map.next_value_seed(PayloadSeed(self.0))?),
|
||||
Field::Provenance if provenance.is_none() => provenance = Some(map.next_value_seed(ProvenanceSeed)?),
|
||||
_ => return Err(serde::de::Error::custom("duplicate embedded texture field")),
|
||||
}
|
||||
}
|
||||
if !kind {
|
||||
return Err(serde::de::Error::missing_field("kind"));
|
||||
}
|
||||
TextureResource::new(
|
||||
data.ok_or_else(|| serde::de::Error::missing_field("data"))?,
|
||||
provenance.ok_or_else(|| serde::de::Error::missing_field("provenance"))?,
|
||||
)
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_map(ResourceVisitor(self.0))
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(field_identifier, rename_all = "snake_case")]
|
||||
enum Field {
|
||||
Kind,
|
||||
Data,
|
||||
Provenance,
|
||||
}
|
||||
struct KindSeed;
|
||||
impl<'de> DeserializeSeed<'de> for KindSeed {
|
||||
type Value = ();
|
||||
fn deserialize<D: serde::Deserializer<'de>>(self, d: D) -> std::result::Result<(), D::Error> {
|
||||
struct Kind;
|
||||
impl serde::de::Visitor<'_> for Kind {
|
||||
type Value = ();
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("opaque_texture")
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, value: &str) -> std::result::Result<(), E> {
|
||||
if value == "opaque_texture" { Ok(()) } else { Err(E::custom("opaque_texture kind required")) }
|
||||
}
|
||||
}
|
||||
d.deserialize_str(Kind)
|
||||
}
|
||||
}
|
||||
struct ProvenanceSeed;
|
||||
impl<'de> DeserializeSeed<'de> for ProvenanceSeed {
|
||||
type Value = TextureProvenance;
|
||||
fn deserialize<D: serde::Deserializer<'de>>(self, d: D) -> std::result::Result<Self::Value, D::Error> {
|
||||
let value = TextureProvenance::deserialize(d)?;
|
||||
value.validate().map_err(serde::de::Error::custom)?;
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
struct PayloadSeed(usize);
|
||||
impl<'de> DeserializeSeed<'de> for PayloadSeed {
|
||||
type Value = Vec<u8>;
|
||||
fn deserialize<D: serde::Deserializer<'de>>(self, d: D) -> std::result::Result<Self::Value, D::Error> {
|
||||
struct Payload(usize);
|
||||
impl serde::de::Visitor<'_> for Payload {
|
||||
type Value = Vec<u8>;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("bounded canonical texture base64")
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, value: &str) -> std::result::Result<Self::Value, E> {
|
||||
crate::render_resources::decode_base64(value, self.0).map_err(E::custom)
|
||||
}
|
||||
}
|
||||
d.deserialize_str(Payload(self.0))
|
||||
}
|
||||
}
|
||||
fn bounded_text<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<String, D::Error> {
|
||||
struct Text;
|
||||
impl serde::de::Visitor<'_> for Text {
|
||||
type Value = String;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("at most 4096 provenance bytes")
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, value: &str) -> std::result::Result<Self::Value, E> {
|
||||
if value.len() > 4096 {
|
||||
return Err(E::custom("texture provenance text exceeds its bound"));
|
||||
}
|
||||
Ok(value.to_owned())
|
||||
}
|
||||
}
|
||||
d.deserialize_str(Text)
|
||||
}
|
||||
|
||||
/// First pass borrows payload JSON and enforces count/unique/encoded budgets before
|
||||
/// Document's migration Value can allocate decoded base64 strings or coalesce keys.
|
||||
pub(crate) fn check_json_budget(text: &str) -> Result<()> {
|
||||
#[derive(Default, Deserialize)]
|
||||
struct Render {
|
||||
#[serde(default, deserialize_with = "budgets")]
|
||||
texture_resources: (),
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Design {
|
||||
#[serde(default)]
|
||||
render: Render,
|
||||
}
|
||||
let Design { render: Render { texture_resources: () } } =
|
||||
serde_json::from_str(text).map_err(|e| invalid(format!("texture resource admission: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
fn budgets<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<(), D::Error> {
|
||||
struct Budgets;
|
||||
impl<'de> serde::de::Visitor<'de> for Budgets {
|
||||
type Value = ();
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("bounded unique texture resources")
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> std::result::Result<(), A::Error> {
|
||||
let mut keys = std::collections::BTreeSet::new();
|
||||
let mut total = 0usize;
|
||||
while let Some(id) = map.next_key::<HdrId>()? {
|
||||
if keys.len() >= MAX_TEXTURE_RESOURCES || !keys.insert(id) {
|
||||
return Err(serde::de::Error::custom("too many or duplicate embedded texture resources"));
|
||||
}
|
||||
total += map.next_value_seed(ResourceBudget(MAX_TEXTURE_RESOURCE_BYTES.saturating_sub(total).min(MAX_TEXTURE_BYTES)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
d.deserialize_map(Budgets)
|
||||
}
|
||||
struct ResourceBudget(usize);
|
||||
impl<'de> DeserializeSeed<'de> for ResourceBudget {
|
||||
type Value = usize;
|
||||
fn deserialize<D: serde::Deserializer<'de>>(self, d: D) -> std::result::Result<Self::Value, D::Error> {
|
||||
struct ResourceVisitor(usize);
|
||||
impl<'de> serde::de::Visitor<'de> for ResourceVisitor {
|
||||
type Value = usize;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("bounded texture metadata and base64")
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> std::result::Result<Self::Value, A::Error> {
|
||||
let (mut kind, mut size, mut provenance) = (false, None, false);
|
||||
while let Some(field) = map.next_key::<Field>()? {
|
||||
match field {
|
||||
Field::Kind if !kind => {
|
||||
let raw = map.next_value::<&serde_json::value::RawValue>()?;
|
||||
if raw.get().len() > 86 {
|
||||
return Err(serde::de::Error::custom("texture kind exceeds its budget"));
|
||||
}
|
||||
let value: String = serde_json::from_str(raw.get()).map_err(serde::de::Error::custom)?;
|
||||
if value != "opaque_texture" {
|
||||
return Err(serde::de::Error::custom("opaque_texture kind required"));
|
||||
}
|
||||
kind = true;
|
||||
}
|
||||
Field::Data if size.is_none() => {
|
||||
let raw = map.next_value::<&serde_json::value::RawValue>()?;
|
||||
size = Some(crate::render_resources::json_base64_length(raw.get(), self.0).map_err(serde::de::Error::custom)?);
|
||||
}
|
||||
Field::Provenance if !provenance => {
|
||||
let raw = map.next_value::<&serde_json::value::RawValue>()?;
|
||||
if raw.get().len() > MAX_PROVENANCE_JSON {
|
||||
return Err(serde::de::Error::custom("texture provenance exceeds its budget"));
|
||||
}
|
||||
let value: TextureProvenance = serde_json::from_str(raw.get()).map_err(serde::de::Error::custom)?;
|
||||
value.validate().map_err(serde::de::Error::custom)?;
|
||||
provenance = true;
|
||||
}
|
||||
_ => return Err(serde::de::Error::custom("duplicate embedded texture field")),
|
||||
}
|
||||
}
|
||||
if !kind || !provenance {
|
||||
return Err(serde::de::Error::custom("complete texture metadata required"));
|
||||
}
|
||||
size.ok_or_else(|| serde::de::Error::missing_field("data"))
|
||||
}
|
||||
}
|
||||
d.deserialize_map(ResourceVisitor(self.0))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::render_settings::{GpuJob, RenderJob, RenderSettings, RenderSetup};
|
||||
use crate::render_surface::{EmbeddedSurfaceKind, EmbeddedSurfaceSource, RenderBodyTarget, SavedSurface, SurfaceSource};
|
||||
use serde_json::json;
|
||||
fn image() -> Vec<u8> {
|
||||
include_bytes!("../../../assets/render/starter/materials/exports/SURF-027/base_color.png").to_vec()
|
||||
}
|
||||
fn provenance() -> TextureProvenance {
|
||||
TextureProvenance { author: "SolidCraft contributors".into(), source: "original test asset".into(), license: "MIT OR Apache-2.0".into() }
|
||||
}
|
||||
fn resource() -> TextureResource {
|
||||
TextureResource::new(image(), provenance()).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn exact_encoded_texture_bytes_are_shared_deduplicated_and_portable() {
|
||||
let first = resource();
|
||||
let copy = first.clone();
|
||||
assert!(Arc::ptr_eq(&first.bytes, ©.bytes));
|
||||
assert_eq!(first.id(), HdrId::of(&image()));
|
||||
assert_eq!(first.dimensions(), [512, 512]);
|
||||
let mut bank = TextureResources::default();
|
||||
let id = bank.insert(first).unwrap();
|
||||
let mut different_label = copy.clone();
|
||||
different_label.provenance.author = "Another declared author".into();
|
||||
assert_eq!(bank.insert(different_label).unwrap(), id);
|
||||
assert_eq!(bank.len(), 1);
|
||||
assert_eq!(bank.total_bytes(), copy.retained_bytes());
|
||||
assert_eq!(bank.required(&id).unwrap().provenance(), &provenance());
|
||||
let wire = serde_json::to_value(&bank).unwrap();
|
||||
let from_value: TextureResources = serde_json::from_value(wire.clone()).unwrap();
|
||||
let from_json: TextureResources = serde_json::from_str(&wire.to_string()).unwrap();
|
||||
assert_eq!(from_value, bank);
|
||||
assert_eq!(from_json, bank);
|
||||
assert!(bank.required(&HdrId::of(b"missing")).is_err());
|
||||
assert_eq!(bank.remove(&id).unwrap().bytes(), image());
|
||||
assert!(bank.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn embedded_roles_roundtrip_and_missing_references_refuse() {
|
||||
let resource = resource();
|
||||
let id = resource.id();
|
||||
let embedded = EmbeddedSurfaceSource {
|
||||
kind: EmbeddedSurfaceKind::Embedded,
|
||||
base_color: Some(id),
|
||||
normal_open_gl: None,
|
||||
roughness: Some(id),
|
||||
metallic: None,
|
||||
flip_v: true,
|
||||
};
|
||||
let mut setup = RenderSetup {
|
||||
job: Some(RenderJob::GpuPath { transparent_background: false, options: Box::new(GpuJob::default()) }),
|
||||
..Default::default()
|
||||
};
|
||||
setup
|
||||
.surfaces
|
||||
.set(
|
||||
SavedSurface {
|
||||
target: RenderBodyTarget { definition: "Wood test panel".into(), feature: 7, component: 0, occurrence_path: vec![] },
|
||||
source: SurfaceSource::Embedded(embedded),
|
||||
local_frame: solidcraft_render::job::textures::PlanarFrame { origin: [0.; 3], u: [240., 0., 0.], v: [0., 480., 0.] },
|
||||
normal_strength: 0.5,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let mut settings = RenderSettings::default();
|
||||
settings.setups.insert("Wood".into(), setup);
|
||||
assert!(settings.validate().unwrap_err().to_string().contains("missing embedded texture"));
|
||||
settings.texture_resources.insert(resource).unwrap();
|
||||
settings.validate().unwrap();
|
||||
let doc = crate::Document { render: Arc::new(settings.clone()), ..Default::default() };
|
||||
let wire = doc.to_json();
|
||||
assert_eq!(crate::Document::from_json(&wire).unwrap(), doc);
|
||||
assert!(wire.contains("embedded-surface-textures"));
|
||||
assert!(!wire.contains("builtin://"));
|
||||
assert!(!wire.contains("file://"));
|
||||
settings.texture_resources.remove(&id);
|
||||
assert!(settings.validate().is_err());
|
||||
assert!(serde_json::from_value::<EmbeddedSurfaceSource>(json!({"kind":"embedded","flip_v":false})).unwrap().validate().is_err());
|
||||
assert!(serde_json::from_value::<SurfaceSource>(json!({"kind":"automatic","base_color":id})).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn corrupt_changed_digest_animated_or_malformed_texture_records_refuse() {
|
||||
let good = resource();
|
||||
let id = good.id().to_string();
|
||||
let entry = serde_json::to_value(&good).unwrap();
|
||||
let wire = json!({id.clone():entry.clone()});
|
||||
for (field, value) in [
|
||||
("kind", json!("exr")),
|
||||
("data", json!("AAAA")),
|
||||
("provenance", json!({"author":"x\n","source":"","license":""})),
|
||||
("path", json!("wood.png")),
|
||||
] {
|
||||
let mut changed = wire.clone();
|
||||
changed[&id][field] = value;
|
||||
assert!(serde_json::from_value::<TextureResources>(changed).is_err(), "{field}");
|
||||
}
|
||||
let wrong_id = HdrId::of(b"wrong").to_string();
|
||||
assert!(serde_json::from_value::<TextureResources>(json!({wrong_id:entry.clone()})).unwrap_err().to_string().contains("hash mismatch"));
|
||||
let duplicate = format!("{{\"{id}\":{entry},\"{id}\":{entry}}}");
|
||||
assert!(serde_json::from_str::<TextureResources>(&duplicate).unwrap_err().to_string().contains("duplicate"));
|
||||
let mut truncated = image();
|
||||
truncated.truncate(truncated.len() - 1);
|
||||
assert!(TextureResource::new(truncated, provenance()).is_err());
|
||||
// APNG control is rejected before interpreting a potentially moving image.
|
||||
let mut animated = image();
|
||||
animated.splice(8..8, [0, 0, 0, 0, b'a', b'c', b'T', b'L', 0, 0, 0, 0]);
|
||||
assert!(TextureResource::new(animated, provenance()).unwrap_err().to_string().contains("static"));
|
||||
let mut corrupt_tail = image();
|
||||
let end = corrupt_tail.len() - 12;
|
||||
corrupt_tail.splice(end..end, [0, 0, 0, 1, b'v', b'p', b'A', b'g', 17, 0, 0, 0, 0]);
|
||||
assert!(TextureResource::new(corrupt_tail, provenance()).unwrap_err().to_string().contains("CRC"));
|
||||
let mut oversized_capacity = Vec::with_capacity(MAX_TEXTURE_BYTES + 1);
|
||||
oversized_capacity.extend_from_slice(&image());
|
||||
assert!(TextureResource::new(oversized_capacity, provenance()).is_err());
|
||||
let mut bad_provenance = provenance();
|
||||
bad_provenance.source = "x".repeat(2049);
|
||||
assert!(TextureResource::new(image(), bad_provenance).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn json_texture_admission_preserves_duplicates_and_enforces_count_before_migration() {
|
||||
let entry = serde_json::to_value(resource()).unwrap();
|
||||
let mut resources = serde_json::Map::new();
|
||||
for n in 0..33 {
|
||||
resources.insert(HdrId::of(&[n]).to_string(), entry.clone());
|
||||
}
|
||||
let text = json!({"render":{"texture_resources":resources}}).to_string();
|
||||
assert!(check_json_budget(&text).unwrap_err().to_string().contains("too many"));
|
||||
let id = HdrId::of(&image()).to_string();
|
||||
let text = format!("{{\"render\":{{\"texture_resources\":{{\"{id}\":{entry},\"{id}\":{entry}}}}}}}");
|
||||
assert!(check_json_budget(&text).unwrap_err().to_string().contains("duplicate"));
|
||||
let escaped = "\\u0041".repeat(12);
|
||||
let text = format!("{{\"kind\":\"opaque_texture\",\"data\":\"{escaped}\",\"provenance\":{{}}}}");
|
||||
let mut d = serde_json::Deserializer::from_str(&text);
|
||||
assert!(ResourceBudget(1).deserialize(&mut d).is_err());
|
||||
let text = json!({"render":{"texture_resources":{id:{"kind":"opaque_texture","data":"AAAA","provenance":{"author":"x".repeat(MAX_PROVENANCE_JSON)}}}}}).to_string();
|
||||
assert!(check_json_budget(&text).unwrap_err().to_string().contains("provenance"));
|
||||
}
|
||||
#[test]
|
||||
fn encoded_resource_is_role_independent_and_conflicting_color_binding_is_rejected() {
|
||||
let mut bytes = image();
|
||||
let mut chunk = 4_u32.to_be_bytes().to_vec();
|
||||
chunk.extend(b"gAMA");
|
||||
chunk.extend(100_000_u32.to_be_bytes());
|
||||
let mut crc = u32::MAX;
|
||||
for byte in &chunk[4..] {
|
||||
crc ^= u32::from(*byte);
|
||||
for _ in 0..8 {
|
||||
crc = (crc >> 1) ^ (0xedb8_8320_u32.wrapping_mul(crc & 1));
|
||||
}
|
||||
}
|
||||
chunk.extend((!crc).to_be_bytes());
|
||||
bytes.splice(33..33, chunk);
|
||||
let resource = TextureResource::new(bytes, provenance()).unwrap();
|
||||
assert!(resource.validate_role(MapRole::BaseColor).unwrap_err().to_string().contains("normalized sRGB"));
|
||||
resource.validate_role(MapRole::Roughness).unwrap();
|
||||
let id = resource.id();
|
||||
let mut setup = RenderSetup {
|
||||
job: Some(RenderJob::GpuPath { transparent_background: false, options: Box::new(GpuJob::default()) }),
|
||||
..Default::default()
|
||||
};
|
||||
let mut binding = SavedSurface {
|
||||
target: RenderBodyTarget { definition: "Wood".into(), feature: 7, component: 0, occurrence_path: vec![] },
|
||||
source: SurfaceSource::Embedded(EmbeddedSurfaceSource {
|
||||
kind: EmbeddedSurfaceKind::Embedded,
|
||||
base_color: Some(id),
|
||||
normal_open_gl: None,
|
||||
roughness: None,
|
||||
metallic: None,
|
||||
flip_v: false,
|
||||
}),
|
||||
local_frame: solidcraft_render::job::textures::PlanarFrame { origin: [0.; 3], u: [1., 0., 0.], v: [0., 1., 0.] },
|
||||
normal_strength: 1.,
|
||||
};
|
||||
setup.surfaces.set(binding.clone(), false).unwrap();
|
||||
let mut settings = RenderSettings::default();
|
||||
settings.texture_resources.insert(resource).unwrap();
|
||||
assert!(settings.validate_resources(&setup).is_err());
|
||||
if let SurfaceSource::Embedded(source) = &mut binding.source {
|
||||
source.base_color = None;
|
||||
source.roughness = Some(id);
|
||||
}
|
||||
setup.surfaces.set(binding, true).unwrap();
|
||||
settings.validate_resources(&setup).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -64,3 +64,7 @@ solidcraft-sketch = { workspace = true, features = ["solver-profiling"] }
|
||||
[[example]]
|
||||
name = "render_resource_workflow"
|
||||
required-features = ["raytrace-cpu"]
|
||||
|
||||
[[example]]
|
||||
name = "wood_texture_workflow"
|
||||
required-features = ["raytrace-cpu"]
|
||||
@@ -45,8 +45,8 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
restored.execute("render.scene", &json!({}))?;
|
||||
let before = restored.doc.to_json();
|
||||
require(
|
||||
restored.execute("render.settings.set", &json!({"name":"Mapped","set":{"job":{"method":"cpu_raytrace","options":{}}}})).is_err(),
|
||||
"unsupported CPU maps were silently discarded",
|
||||
restored.execute("render.settings.set", &json!({"name":"Mapped","set":{"job":{"method":"raster"}}})).is_err(),
|
||||
"unsupported raster maps were silently discarded",
|
||||
)?;
|
||||
require(restored.doc.to_json() == before, "failed method change mutated saved design")?;
|
||||
let target = restored.doc.render.setups["Mapped"].surfaces.iter().next().ok_or("surface target missing")?.target.clone();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Original public-command wood fixture and cold offline CPU render acceptance.
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::{
|
||||
Session,
|
||||
doc::Document,
|
||||
render::job::{CpuHost, Phase},
|
||||
};
|
||||
use std::{error::Error, fs, path::Path, time::Duration};
|
||||
|
||||
fn render(document: Document, output: &Path) -> Result<(), Box<dyn Error>> {
|
||||
let mut session = Session::new(document);
|
||||
session.set_render_job_host(Some(CpuHost::for_platform()?))?;
|
||||
session.execute("render.scene", &json!({}))?;
|
||||
let preflight = session.execute("render.preflight", &json!({"setup":"Wood"}))?;
|
||||
let started = session.execute("render.start", &json!({"setup":"Wood"}))?;
|
||||
let id = serde_json::from_value(started["job"].clone())?;
|
||||
let handle = session.render_job_handle(id)?;
|
||||
let status = handle.wait_timeout(Duration::from_secs(120))?;
|
||||
if status.progress.phase != Phase::Completed {
|
||||
return Err(format!("wood rendering failed: {:?}", status.error).into());
|
||||
}
|
||||
let frame = handle.frame()?.ok_or("wood frame missing")?;
|
||||
fs::write(output, frame.png())?;
|
||||
println!("{}", serde_json::to_string_pretty(&json!({"preflight":preflight,"frame":frame.info,"textures":status.textures}))?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if args.len() == 3 && args.first().is_some_and(|value| value == "--reopen") {
|
||||
let bytes = fs::read(Path::new(&args[1]))?;
|
||||
return render(solidcraft_io::read_design(&bytes)?, Path::new(&args[2]));
|
||||
}
|
||||
let output = args.first().map(Path::new).ok_or("usage: wood_texture_workflow NEW_OUTPUT_DIRECTORY [TEXTURE_DIRECTORY] | --reopen DESIGN PNG")?;
|
||||
let source = args.get(1).map(Path::new);
|
||||
let photographic = source.is_some();
|
||||
let dimensions = if photographic { [900.0, 300.0, 25.0] } else { [220.0, 100.0, 12.0] };
|
||||
let provenance = if let Some(source) = source {
|
||||
serde_json::from_slice::<serde_json::Value>(&fs::read(source.join("provenance.json"))?)?
|
||||
} else {
|
||||
json!({"author":"SolidCraft contributors","source":"Original SURF-027 procedural wood illustration; repository ATTRIBUTION.md","license":"MIT OR Apache-2.0"})
|
||||
};
|
||||
fs::create_dir(output)?;
|
||||
let mut session = Session::default();
|
||||
session.execute("solid.box", &json!({"length":dimensions[0],"width":dimensions[1],"height":dimensions[2],"body_name":"Board"}))?;
|
||||
let physical = (session.doc.features.clone(), session.doc.materials.clone());
|
||||
let map = output.join("source-only-before-save.png");
|
||||
let read = |name: &str, fallback: &[u8]| -> Result<Vec<u8>, Box<dyn Error>> {
|
||||
Ok(if let Some(source) = source { fs::read(source.join(name))? } else { fallback.to_vec() })
|
||||
};
|
||||
fs::write(&map, read("base_color.png", include_bytes!("../../../assets/render/starter/materials/exports/SURF-027/base_color.png"))?)?;
|
||||
let normal = output.join("normal-only-before-save.png");
|
||||
fs::write(&normal, read("normal_open_gl.png", include_bytes!("../../../assets/render/starter/materials/exports/SURF-027/normal_open_gl.png"))?)?;
|
||||
let roughness = output.join("roughness-only-before-save.png");
|
||||
fs::write(&roughness, read("roughness.png", include_bytes!("../../../assets/render/starter/materials/exports/SURF-027/roughness.png"))?)?;
|
||||
let frame = if photographic {
|
||||
json!({"origin":[0,300,25],"u":[0,-1500,0],"v":[1500,0,0]})
|
||||
} else {
|
||||
json!({"origin":[0,0,12],"u":[60,0,0],"v":[0,120,0]})
|
||||
};
|
||||
session.execute("render.surface.import", &json!({
|
||||
"name":"Wood","body_index":0,"body_name":"Board",
|
||||
"set":{"width":960,"height":540,"camera":{"target":[dimensions[0]*0.5,dimensions[1]*0.5,dimensions[2]*0.5],"yaw":0.35,"pitch":1.05,"distance":if photographic {1500.0} else {420.0},"fov":0.6},
|
||||
"job":{"method":"cpu_raytrace","options":{"budget":"studio","primary_samples":4}}},
|
||||
"frame":frame,
|
||||
"maps":{"base_color_srgb":{"path":map},"normal_opengl":{"path":normal},"roughness_linear":{"path":roughness}},
|
||||
"provenance":provenance,
|
||||
"flip_v":true,"normal_strength":0.4
|
||||
}))?;
|
||||
if (session.doc.features.clone(), session.doc.materials.clone()) != physical {
|
||||
return Err("surface import changed CAD geometry or physical materials".into());
|
||||
}
|
||||
let json = session.doc.to_json();
|
||||
fs::write(output.join("wood.json"), &json)?;
|
||||
fs::write(output.join("wood.sc"), solidcraft_io::write_design_container(&session.doc)?)?;
|
||||
fs::remove_file(map)?;
|
||||
fs::remove_file(normal)?;
|
||||
fs::remove_file(roughness)?;
|
||||
render(Document::from_json(&json)?, &output.join("wood.png"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -26,10 +26,26 @@ mod shots;
|
||||
mod staging;
|
||||
mod studio_presets;
|
||||
mod surface_bindings;
|
||||
mod surface_resources;
|
||||
mod textures;
|
||||
mod transmission;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("render.surface.import", "Import Surface Maps", crate::checked!("render.surface.import", surface_resources::import))
|
||||
.icon("appearance")
|
||||
.contract(&surface_resources::IMPORT_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_surface_resources::ImportResult>)
|
||||
.params("name; set?: typed CPU raytrace or GPU path setup patch; body_index and body_name from render.scene; frame: world-mm origin/u/v physical repeat vectors; maps: explicit base_color_srgb/normal_opengl/roughness_linear/metallic_linear {path,expected_sha256?}; provenance:{author,source,license}; normal_strength?:1; flip_v?:false; replace?:false. Captures exact PNG/JPEG bytes and saves definition-local binding in one undo. CPU maps require noninterlaced 8-bit PNG. Saved designs reopen without the source files."),
|
||||
CommandSpec::new("render.texture.list", "List Embedded Surface Images", crate::checked!("render.texture.list", surface_resources::list))
|
||||
.icon("list").noundo().doc_only()
|
||||
.contract(&surface_resources::LIST_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_surface_resources::ListResult>)
|
||||
.params("No parameters. Lists bounded design-owned texture digests, byte counts, dimensions, declared provenance and referencing setups; never returns image bytes or source filesystem paths."),
|
||||
CommandSpec::new("render.texture.remove", "Remove Embedded Surface Image", crate::checked!("render.texture.remove", surface_resources::remove))
|
||||
.icon("delete").doc_only()
|
||||
.contract(&surface_resources::REMOVE_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render_surface_resources::RemoveResult>)
|
||||
.params("sha256: exact embedded image digest. Refuses while any saved setup uses the image, listing its users. Removing an unused image is one undo step."),
|
||||
CommandSpec::new("render.environment.set", "Set Render Environment", crate::checked!("render.environment.set", environment::set)).icon("settings").doc_only()
|
||||
.contract(&environment::CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::render::EnvironmentResult>)
|
||||
@@ -134,7 +150,7 @@ CommandSpec::new("render.library.appearance.apply", "Apply Library Opaque Appear
|
||||
CommandSpec::new("render.library.environment.apply", "Apply Built-in HDR Environment", library::apply_environment).icon("settings").doc_only()
|
||||
.params("id: HDR-*; name: saved setup; set?: typed CPU/GPU setup patch; rotation_rad?: authored rotation (native origin offset is explicit in the reply); intensity?:1; samples?:16 for CPU; replace_environment?:false. Hash-verifies and embeds original bytes, saves one atomic undo without external files or a render device. An HDR-owned sun cannot duplicate an analytic sun."),
|
||||
CommandSpec::new("render.library.surface.prepare", "Prepare Authored Surface Maps", library::prepare_surface).icon("appearance").noundo()
|
||||
.params("id: SURF-*; body_index and body_name from render.scene; origin?:[0,0,0]; plane?:xy/yz/zx; scale?:1. Returns an explicit GPU planar map binding with source hashes, original millimetre scale, image-up normal orientation and absolute base-color semantics. No document change; height/displacement and true anisotropy are not included."),
|
||||
.params("id: SURF-*; body_index and body_name from render.scene; origin?:[0,0,0]; plane?:xy/yz/zx; scale?:1; rotation_rad?:0 (positive around the plane normal). Returns an explicit GPU planar map binding with source hashes, original millimetre scale, image-up normal orientation and absolute base-color semantics. No document change; height/displacement and true anisotropy are not included."),
|
||||
CommandSpec::new("render.library.studio.prepare", "Prepare Authored Studio Lights", library::prepare_studio).icon("inspect").noundo().doc_only()
|
||||
.params("id: RIG-*; variant: complete for emitter-only rigs, emitters_only for rigs with passive cards; center_mm and subject_extent_mm required. Converts exact rectangular half-edges and solar direction/radius to GPU lighting. Blocked calibration and external-sun rigs refuse. Returns a geometry-clearance review requirement; does not create cards or modify the scene."),
|
||||
CommandSpec::new("render.library.studio.apply", "Apply Authored Studio Lights", library::apply_studio).icon("settings")
|
||||
|
||||
@@ -336,6 +336,15 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
|
||||
None
|
||||
};
|
||||
let mut scene = crate::product_scene::extract(s)?;
|
||||
let textures = if let Some(bindings) = normalized.as_ref().map(|saved| &saved.surfaces).filter(|bindings| !bindings.is_empty()) {
|
||||
if input.integrator != Integrator::Raytrace {
|
||||
return Err(bad(command, "saved surface maps require a raytrace executor"));
|
||||
}
|
||||
let overrides = super::surface_bindings::resolve(s, &scene, bindings, command)?;
|
||||
super::textures::capture_with_resources(&scene, &overrides, Some(&s.doc.render.texture_resources), command)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if environment.is_some() {
|
||||
input.raytrace.as_ref().unwrap_or(&RaytraceOptions::default()).validate_environment_reflections(&scene).map_err(job_error)?;
|
||||
}
|
||||
@@ -410,7 +419,7 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
|
||||
let request = ImageRequest {
|
||||
studio_weights: validate_studio_weights(input.studio_weights, command)?,
|
||||
environment: None,
|
||||
textures: None,
|
||||
textures,
|
||||
integrator: input.integrator,
|
||||
raytrace: input.raytrace.take(),
|
||||
camera: settings.camera,
|
||||
@@ -624,7 +633,7 @@ fn prepare_progressive(
|
||||
height: settings.height,
|
||||
background: settings.background,
|
||||
transparent_background: input.transparent_background,
|
||||
textures: super::textures::capture(&scene, &input.raytrace.textures, command)?,
|
||||
textures: super::textures::capture_with_resources(&scene, &input.raytrace.textures, Some(&s.doc.render.texture_resources), command)?,
|
||||
};
|
||||
let mut admission = host.admit(&scene, &request, options).map_err(job_error)?;
|
||||
admission.estimated_total_bytes = admission
|
||||
|
||||
@@ -505,6 +505,8 @@ struct SurfacePrepare {
|
||||
scale: f64,
|
||||
#[serde(default)]
|
||||
plane: Plane,
|
||||
#[serde(default)]
|
||||
rotation_rad: f64,
|
||||
}
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -522,12 +524,18 @@ pub(super) fn prepare_surface(s: &mut Session, params: &Value) -> Result<Value>
|
||||
if !input.scale.is_finite() || !(0.001..=1000.0).contains(&input.scale) {
|
||||
return Err(bad(command, "surface repeat scale must be finite in 0.001..1000"));
|
||||
}
|
||||
if !input.rotation_rad.is_finite() || input.rotation_rad.abs() > 1e6 {
|
||||
return Err(bad(command, "surface grain rotation must be finite and within one million radians"));
|
||||
}
|
||||
let tile: [f64; 2] = serde_json::from_value(value["tile_mm"].clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let (u, v) = (tile[0] * input.scale, tile[1] * input.scale);
|
||||
let (sin, cos) = input.rotation_rad.sin_cos();
|
||||
// Rotate the unit directions before applying the two physical repeat lengths.
|
||||
// Mixing the already-scaled vectors would skew non-square wood tiles.
|
||||
let (u, v) = match input.plane {
|
||||
Plane::Xy => ([u, 0.0, 0.0], [0.0, v, 0.0]),
|
||||
Plane::Yz => ([0.0, u, 0.0], [0.0, 0.0, v]),
|
||||
Plane::Zx => ([0.0, 0.0, u], [v, 0.0, 0.0]),
|
||||
Plane::Xy => ([cos * u, sin * u, 0.0], [-sin * v, cos * v, 0.0]),
|
||||
Plane::Yz => ([0.0, cos * u, sin * u], [0.0, -sin * v, cos * v]),
|
||||
Plane::Zx => ([sin * u, 0.0, cos * u], [cos * v, 0.0, -sin * v]),
|
||||
};
|
||||
let scene = crate::product_scene::extract(s)?;
|
||||
let body = scene
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use image::ImageEncoder;
|
||||
#[cfg(feature = "bundled-render-library")]
|
||||
use solidcraft_render::job::textures::{BaseColorMode, TextureMap};
|
||||
|
||||
@@ -37,6 +39,139 @@ fn authored_library_search_is_bounded_and_concepts_are_not_runtime_presets() {
|
||||
assert_eq!(s.execute("render.library.list", &json!({"kind":"surfaces","query":"SURF-001"})).unwrap()["total"], 1);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn custom_surface_fixture() -> (Session, std::path::PathBuf, Vec<u8>, Value) {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":100,"width":40,"height":10,"body_name":"Board"})).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut bytes)
|
||||
.write_image(&[128, 64, 32, 255, 192, 96, 48, 255, 64, 32, 16, 255, 224, 160, 96, 255], 2, 2, image::ExtendedColorType::Rgba8)
|
||||
.unwrap();
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-wood-owned-{}-{nonce}.png", std::process::id()));
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let input = json!({"name":"Wood","set":{"width":64,"height":64,"job":{"method":"gpu_path","options":{}}},
|
||||
"body_index":0,"body_name":"Board","frame":{"origin":[0,0,0],"u":[20,0,0],"v":[0,40,0]},
|
||||
"maps":{"base_color_srgb":{"path":path,"expected_sha256":HdrId::of(&bytes)}},
|
||||
"provenance":{"author":"Original test","source":"synthetic diagnostic","license":"CC0-1.0"}});
|
||||
(s, path, bytes, input)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn imported_wood_maps_reopen_without_source_and_keep_geometry_history_and_resource_users() {
|
||||
let (mut s, path, bytes, mut input) = custom_surface_fixture();
|
||||
// The exact same source in two explicitly different roles occupies one bank entry.
|
||||
input["maps"]["roughness_linear"] = input["maps"]["base_color_srgb"].clone();
|
||||
let before = s.doc.to_json();
|
||||
let physical = (s.doc.materials.clone(), s.doc.features.clone());
|
||||
let undo = s.undo.len();
|
||||
let result = s.execute("render.surface.import", &input).unwrap();
|
||||
assert_eq!(result["self_contained"], true);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!((s.doc.materials.clone(), s.doc.features.clone()), physical);
|
||||
assert_eq!(s.doc.render.texture_resources.len(), 1);
|
||||
let digest = HdrId::of(&bytes);
|
||||
assert_eq!(s.doc.render.texture_resources.required(&digest).unwrap().bytes(), bytes);
|
||||
let temporary: super::super::textures::Override = serde_json::from_value(json!({
|
||||
"body_index":0,"body_name":"Board","frame":{"origin":[0,0,0],"u":[20,0,0],"v":[0,40,0]},"base_color_srgb":path
|
||||
}))
|
||||
.unwrap();
|
||||
let temporary = super::super::textures::capture(&crate::product_scene::extract(&s).unwrap(), &[temporary], "test").unwrap().unwrap();
|
||||
assert!(!temporary.bindings()[0].base_color.as_ref().unwrap().gpu_mip_filtering(), "temporary paths retain legacy GPU sampling");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
let saved = s.doc.to_json();
|
||||
assert!(!saved.contains(path.to_str().unwrap()));
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
|
||||
reopened.execute("render.scene", &json!({})).unwrap();
|
||||
let scene = crate::product_scene::extract(&reopened).unwrap();
|
||||
let bindings = &reopened.doc.render.setups["Wood"].surfaces;
|
||||
let lowered = super::super::surface_bindings::resolve(&reopened, &scene, bindings, "test").unwrap();
|
||||
let captured =
|
||||
super::super::textures::capture_with_resources(&scene, &lowered, Some(&reopened.doc.render.texture_resources), "test").unwrap().unwrap();
|
||||
let map = captured.bindings()[0].base_color.as_ref().unwrap();
|
||||
assert!(map.gpu_mip_filtering(), "reopened imported images retain their new filtering policy");
|
||||
assert!(captured.bindings()[0].roughness.as_ref().unwrap().gpu_mip_filtering());
|
||||
assert_eq!(map.dimensions(), [2, 2]);
|
||||
let pixels = map.decode(&solidcraft_render::job::CancelToken::default()).unwrap();
|
||||
let expected = ((128.0_f64 / 255.0 + 0.055) / 1.055).powf(2.4);
|
||||
assert!((f64::from(pixels[0]) - expected).abs() < 1e-7);
|
||||
assert_eq!(captured.bindings()[0].frame.u, [20.0, 0.0, 0.0]);
|
||||
assert_eq!(captured.bindings()[0].frame.v, [0.0, 40.0, 0.0]);
|
||||
let list = reopened.execute("render.texture.list", &json!({})).unwrap();
|
||||
assert_eq!(list["resources"][0]["sha256"], digest.to_string());
|
||||
assert_eq!(list["resources"][0]["users"], json!(["Wood"]));
|
||||
assert!(reopened.execute("render.texture.remove", &json!({"sha256":digest})).unwrap_err().to_string().contains("Wood"));
|
||||
let target = reopened.doc.render.setups["Wood"].surfaces.iter().next().unwrap().target.clone();
|
||||
reopened.execute("render.library.surface.unbind", &json!({"name":"Wood","target":target})).unwrap();
|
||||
reopened.execute("render.texture.remove", &json!({"sha256":digest})).unwrap();
|
||||
assert!(reopened.doc.render.texture_resources.is_empty());
|
||||
reopened.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(reopened.doc.render.texture_resources.required(&digest).unwrap().bytes(), bytes);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
assert!(Session::default().doc.render.texture_resources.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn imported_surface_refusals_are_atomic_and_validate_identity_before_source_access() {
|
||||
let (mut s, path, _, input) = custom_surface_fixture();
|
||||
let before = s.doc.to_json();
|
||||
let history = (s.revision, s.undo.len());
|
||||
for patch in [
|
||||
json!({"body_name":"Wrong", "maps":{"base_color_srgb":{"path":"missing-file.png"}}}),
|
||||
json!({"normal_strength":2.0}),
|
||||
json!({"frame":{"origin":[0,0,0],"u":[20,0,0],"v":[20,0,0]}}),
|
||||
json!({"maps":{}}),
|
||||
json!({"maps":{"base_color_srgb":{"path":path,"expected_sha256":"0".repeat(64)}}}),
|
||||
json!({"set":{"job":{"method":"raster"}}}),
|
||||
json!({"provenance":{"author":"bad\ntext","source":"synthetic","license":"CC0"}}),
|
||||
] {
|
||||
let mut request = input.clone();
|
||||
for (field, value) in patch.as_object().unwrap() {
|
||||
request[field] = value.clone();
|
||||
}
|
||||
assert!(s.execute("render.surface.import", &request).is_err(), "{request}");
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!((s.revision, s.undo.len()), history);
|
||||
}
|
||||
let mut request = input.clone();
|
||||
request["maps"]["normal_opengl"] = json!({"path":"missing-later-map.png"});
|
||||
assert!(s.execute("render.surface.import", &request).is_err());
|
||||
assert_eq!(s.doc.to_json(), before, "a later failed map cannot leak an earlier imported resource");
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn cpu_surface_import_and_method_switch_reject_unsupported_encoding_without_losing_the_gpu_binding() {
|
||||
let (mut s, path, _, mut input) = custom_surface_fixture();
|
||||
let mut jpeg = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new(&mut jpeg).write_image(&[128_u8; 12], 2, 2, image::ExtendedColorType::Rgb8).unwrap();
|
||||
std::fs::write(&path, &jpeg).unwrap();
|
||||
input["maps"]["base_color_srgb"]["expected_sha256"] = json!(HdrId::of(&jpeg));
|
||||
input["set"]["job"]["method"] = json!("cpu_raytrace");
|
||||
let before = s.doc.to_json();
|
||||
let history = (s.revision, s.undo.len());
|
||||
assert!(s.execute("render.surface.import", &input).unwrap_err().to_string().contains("noninterlaced 8-bit PNG"));
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!((s.revision, s.undo.len()), history);
|
||||
input["set"]["job"]["method"] = json!("gpu_path");
|
||||
s.execute("render.surface.import", &input).unwrap();
|
||||
let saved = s.doc.to_json();
|
||||
assert!(
|
||||
s.execute("render.settings.set", &json!({"name":"Wood","set":{"job":{"method":"cpu_raytrace","options":{}}}}))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("noninterlaced 8-bit PNG")
|
||||
);
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(feature = "bundled-render-library")]
|
||||
#[test]
|
||||
fn built_in_maps_keep_exact_published_bytes_and_explicit_channel_roles() {
|
||||
@@ -128,12 +263,104 @@ fn explicit_surface_prepare_has_physical_scale_and_does_not_assign_materials() {
|
||||
let textures = super::super::textures::capture(&scene, &[input], "test").unwrap().unwrap();
|
||||
let binding = &textures.bindings()[0];
|
||||
assert!(binding.flip_v && binding.roughness.is_some() && binding.metallic.is_some());
|
||||
for map in [&binding.base_color, &binding.normal, &binding.roughness, &binding.metallic].into_iter().flatten() {
|
||||
assert!(!map.gpu_mip_filtering(), "built-in preparation retains legacy GPU sampling");
|
||||
}
|
||||
assert_eq!(binding.base_color_mode, BaseColorMode::Replace);
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert!(s.execute("render.library.surface.prepare", &json!({"id":"SURF-001","body_index":0,"body_name":"Old name"})).is_err());
|
||||
}
|
||||
|
||||
#[cfg(feature = "bundled-render-library")]
|
||||
#[test]
|
||||
fn wood_grain_rotation_preserves_non_square_repeats_in_each_plane() {
|
||||
use solidcraft_render::job::textures::PlanarFrame;
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":100,"width":100,"height":20,"body_name":"Board"})).unwrap();
|
||||
let before = s.doc.to_json();
|
||||
let history = (s.undo.len(), s.redo.len());
|
||||
for (plane, expected_u, expected_v) in
|
||||
[("xy", [0.0, 60.0, 0.0], [-120.0, 0.0, 0.0]), ("yz", [0.0, 0.0, 60.0], [0.0, -120.0, 0.0]), ("zx", [60.0, 0.0, 0.0], [0.0, 0.0, -120.0])]
|
||||
{
|
||||
let reply = s
|
||||
.execute(
|
||||
"render.library.surface.prepare",
|
||||
&json!({"id":"SURF-027","body_index":0,"body_name":"Board","plane":plane,"scale":0.25,
|
||||
"rotation_rad":std::f64::consts::FRAC_PI_2}),
|
||||
)
|
||||
.unwrap();
|
||||
let frame: PlanarFrame = serde_json::from_value(reply["texture"]["frame"].clone()).unwrap();
|
||||
for ((u, expected_u), (v, expected_v)) in frame.u.into_iter().zip(expected_u).zip(frame.v.into_iter().zip(expected_v)) {
|
||||
assert!((u - expected_u).abs() < 1e-10);
|
||||
assert!((v - expected_v).abs() < 1e-10);
|
||||
}
|
||||
let reply = s
|
||||
.execute(
|
||||
"render.library.surface.prepare",
|
||||
&json!({"id":"SURF-027","body_index":0,"body_name":"Board","plane":plane,"scale":0.25,
|
||||
"rotation_rad":std::f64::consts::FRAC_PI_4}),
|
||||
)
|
||||
.unwrap();
|
||||
let frame: PlanarFrame = serde_json::from_value(reply["texture"]["frame"].clone()).unwrap();
|
||||
let len2 = |v: [f64; 3]| v.into_iter().map(|x| x * x).sum::<f64>();
|
||||
assert!((len2(frame.u) - 60.0 * 60.0).abs() < 1e-9);
|
||||
assert!((len2(frame.v) - 120.0 * 120.0).abs() < 1e-9);
|
||||
assert!(frame.u.into_iter().zip(frame.v).map(|(u, v)| u * v).sum::<f64>().abs() < 1e-9);
|
||||
}
|
||||
for angle in [json!(1_000_001.0), json!(-1_000_001.0), Value::Null, json!("90 deg"), json!([])] {
|
||||
assert!(
|
||||
s.execute("render.library.surface.prepare", &json!({"id":"SURF-027","body_index":0,"body_name":"Board","rotation_rad":angle})).is_err()
|
||||
);
|
||||
}
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
assert_eq!((s.undo.len(), s.redo.len()), history);
|
||||
}
|
||||
|
||||
#[cfg(feature = "bundled-render-library")]
|
||||
#[test]
|
||||
fn saved_wood_grain_angle_reopens_and_rotates_with_only_its_occurrence() {
|
||||
let mut s = Session::default();
|
||||
let component = s.execute("component.create", &json!({"name":"Board part","grounded":false})).unwrap();
|
||||
s.execute("solid.box", &json!({"length":100,"width":40,"height":10,"body_name":"Board"})).unwrap();
|
||||
s.execute("component.activate", &json!({"component":0})).unwrap();
|
||||
s.execute("occurrence.copy", &json!({"component":component["component"],"translate":[150,0,0]})).unwrap();
|
||||
let physical = (s.doc.materials.clone(), s.doc.features.clone());
|
||||
s.execute(
|
||||
"render.library.surface.bind",
|
||||
&json!({"name":"Grain","set":{"job":{"method":"gpu_path","options":{}}},
|
||||
"surface":{"id":"SURF-027","body_index":0,"body_name":"Board","scale":0.25,
|
||||
"rotation_rad":std::f64::consts::FRAC_PI_4}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((s.doc.materials.clone(), s.doc.features.clone()), physical);
|
||||
let saved = s.doc.to_json();
|
||||
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
|
||||
reopened.execute("render.scene", &json!({})).unwrap();
|
||||
let bindings = reopened.doc.render.setups["Grain"].surfaces.clone();
|
||||
let lower = |session: &Session| {
|
||||
let scene = crate::product_scene::extract(session).unwrap();
|
||||
let lowered = super::super::surface_bindings::resolve(session, &scene, &bindings, "test").unwrap();
|
||||
super::super::textures::capture(&scene, &lowered, "test").unwrap().unwrap().bindings().iter().map(|binding| binding.frame).collect::<Vec<_>>()
|
||||
};
|
||||
let first = lower(&reopened);
|
||||
assert_eq!(first.len(), 1);
|
||||
let frame = first[0];
|
||||
reopened.execute("occurrence.move", &json!({"occurrence":component["occurrence"],"axis":[0,0,1],"angle":std::f64::consts::FRAC_PI_2})).unwrap();
|
||||
let rotated = lower(&reopened);
|
||||
assert_eq!(rotated.len(), 1);
|
||||
for (actual, old) in [(rotated[0].u, frame.u), (rotated[0].v, frame.v)] {
|
||||
assert!((actual[0] + old[1]).abs() < 1e-10);
|
||||
assert!((actual[1] - old[0]).abs() < 1e-10);
|
||||
assert!((actual[2] - old[2]).abs() < 1e-10);
|
||||
}
|
||||
assert_eq!(reopened.doc.render.setups["Grain"].surfaces, bindings);
|
||||
assert_eq!(reopened.doc.materials, physical.0);
|
||||
assert_eq!(reopened.doc.features, physical.1);
|
||||
reopened.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(lower(&reopened)[0], frame);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniform_appearance_requires_acknowledged_projection_and_preserves_physical_data() {
|
||||
let mut s = Session::default();
|
||||
@@ -211,9 +438,16 @@ fn saved_authored_maps_roundtrip_and_resolve_without_paths_or_captured_ordinals(
|
||||
assert_eq!(maps.bindings()[0].body_name, "Coupon");
|
||||
assert_eq!(maps.bindings()[0].base_color_mode, BaseColorMode::Replace);
|
||||
assert!(maps.bindings()[0].flip_v);
|
||||
for map in [&maps.bindings()[0].base_color, &maps.bindings()[0].normal, &maps.bindings()[0].roughness, &maps.bindings()[0].metallic]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!(!map.gpu_mip_filtering(), "existing saved built-in surfaces retain legacy GPU sampling");
|
||||
}
|
||||
let mut changed = s.doc.render.setups["Portable"].clone();
|
||||
let mut binding = changed.surfaces.iter().next().unwrap().clone();
|
||||
binding.source.base_color_sha256 = HdrId::of(b"changed");
|
||||
let solidcraft_doc::render_surface::SurfaceSource::Builtin(source) = &mut binding.source else { panic!("built-in fixture") };
|
||||
source.base_color_sha256 = HdrId::of(b"changed");
|
||||
changed.surfaces.set(binding, true).unwrap();
|
||||
assert!(super::super::surface_bindings::resolve(&s, &crate::product_scene::extract(&s).unwrap(), &changed.surfaces, "test").is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
@@ -290,7 +524,7 @@ fn saved_surface_rejects_changed_feature_and_hidden_target_and_can_be_removed_of
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, previous);
|
||||
let before = s.doc.to_json();
|
||||
assert!(s.execute("render.settings.set", &json!({"name":"P","set":{"job":{"method":"cpu_raytrace","options":{}}}})).is_err());
|
||||
assert!(s.execute("render.settings.set", &json!({"name":"P","set":{"job":{"method":"raster"}}})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ use solidcore_record::Vec3;
|
||||
use solidcraft_doc::{
|
||||
render_resources::HdrId,
|
||||
render_settings::{RenderJob, RenderSetup},
|
||||
render_surface::{BuiltinSurfaceSource, RenderBodyTarget, SavedSurface, SurfaceBindings},
|
||||
render_surface::{BuiltinSurfaceSource, RenderBodyTarget, SavedSurface, SurfaceBindings, SurfaceSource},
|
||||
};
|
||||
use solidcraft_render::{ProductScene, job::textures::PlanarFrame};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
@@ -18,7 +18,7 @@ fn vec(a: [f64; 3]) -> Vec3 {
|
||||
fn array(v: Vec3) -> [f64; 3] {
|
||||
[v.x, v.y, v.z]
|
||||
}
|
||||
fn transform(frame: PlanarFrame, m: &solidcraft_doc::Mat, command: &str) -> Result<PlanarFrame> {
|
||||
pub(super) fn transform(frame: PlanarFrame, m: &solidcraft_doc::Mat, command: &str) -> Result<PlanarFrame> {
|
||||
let result = PlanarFrame {
|
||||
origin: array(solidcraft_doc::apply_point(m, vec(frame.origin))),
|
||||
u: array(solidcraft_doc::apply_vector(m, vec(frame.u))),
|
||||
@@ -85,7 +85,9 @@ pub(super) fn resolve(s: &Session, scene: &ProductScene, bindings: &SurfaceBindi
|
||||
let mut result = Vec::new();
|
||||
result.try_reserve_exact(bindings.len()).map_err(|_| bad(command, "could not reserve saved surface lowering"))?;
|
||||
for binding in bindings.iter() {
|
||||
if binding.source != source(&binding.source.id, command)? {
|
||||
if let SurfaceSource::Builtin(builtin) = &binding.source
|
||||
&& *builtin != source(&builtin.id, command)?
|
||||
{
|
||||
return Err(bad(command, "saved surface version or SHA-256 differs from installed source; explicit rebinding required"));
|
||||
}
|
||||
let name =
|
||||
@@ -96,10 +98,36 @@ pub(super) fn resolve(s: &Session, scene: &ProductScene, bindings: &SurfaceBindi
|
||||
return Err(bad(command, "surface definition/world mapping disagrees"));
|
||||
}
|
||||
let frame = transform(binding.local_frame, &body.source_transform, command)?;
|
||||
let id = &binding.source.id;
|
||||
let wire = json!({"body_index":ordinal,"body_name":name,"frame":frame,"normal_strength":binding.normal_strength,
|
||||
"flip_v":true,"base_color_mode":"replace","base_color_srgb":format!("builtin://{id}/base_color"),
|
||||
"normal_opengl":format!("builtin://{id}/normal_open_gl"),"roughness_linear":format!("builtin://{id}/roughness"),"metallic_linear":format!("builtin://{id}/metallic")});
|
||||
let mut wire = json!({"body_index":ordinal,"body_name":name,"frame":frame,"normal_strength":binding.normal_strength,
|
||||
"base_color_mode":"replace"});
|
||||
match &binding.source {
|
||||
SurfaceSource::Builtin(source) => {
|
||||
let id = &source.id;
|
||||
wire["flip_v"] = json!(true);
|
||||
for (field, role) in [
|
||||
("base_color_srgb", "base_color"),
|
||||
("normal_opengl", "normal_open_gl"),
|
||||
("roughness_linear", "roughness"),
|
||||
("metallic_linear", "metallic"),
|
||||
] {
|
||||
wire[field] = json!(format!("builtin://{id}/{role}"));
|
||||
}
|
||||
}
|
||||
SurfaceSource::Embedded(source) => {
|
||||
wire["flip_v"] = json!(source.flip_v);
|
||||
for (field, id) in [
|
||||
("base_color_srgb", source.base_color),
|
||||
("normal_opengl", source.normal_open_gl),
|
||||
("roughness_linear", source.roughness),
|
||||
("metallic_linear", source.metallic),
|
||||
] {
|
||||
if let Some(id) = id {
|
||||
s.doc.render.texture_resources.required(&id).map_err(|e| bad(command, e.to_string()))?;
|
||||
wire[field] = json!(format!("embedded://{id}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push(serde_json::from_value(wire).map_err(|e| bad(command, e.to_string()))?);
|
||||
}
|
||||
Ok(result)
|
||||
@@ -135,8 +163,8 @@ pub(super) fn bind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
return Err(bad(command, "setup patch cannot erase or replace saved surface bindings; use explicit bind/unbind commands"));
|
||||
}
|
||||
|
||||
if !matches!(setup.job, Some(RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "persistent authored maps require an explicit GPU setup"));
|
||||
if !matches!(setup.job, Some(RenderJob::CpuRaytrace { .. } | RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "persistent authored maps require an explicit CPU raytrace or GPU path setup"));
|
||||
}
|
||||
let prepared = super::library::prepare_surface(s, &input.surface)?;
|
||||
let id = prepared["id"].as_str().ok_or_else(|| bad(command, "missing prepared surface identifier"))?;
|
||||
@@ -154,7 +182,7 @@ pub(super) fn bind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
setup
|
||||
.surfaces
|
||||
.set(
|
||||
SavedSurface { target, source: source(id, command)?, local_frame: transform(frame, &inverse, command)?, normal_strength: 1.0 },
|
||||
SavedSurface { target, source: source(id, command)?.into(), local_frame: transform(frame, &inverse, command)?, normal_strength: 1.0 },
|
||||
input.replace,
|
||||
)
|
||||
.map_err(|e| bad(command, e.to_string()))?;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Capture source maps atomically, retain exact bytes, and bind a local physical frame.
|
||||
use crate::schema::render_surface_resources::{
|
||||
ImportParams, ImportResult, ListParams, ListResult, RemoveParams, RemoveResult, ResourceInfo, TextureFile,
|
||||
};
|
||||
use crate::{Result, Session, params::bad, schema::TypedContract};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{
|
||||
render_resources::HdrId,
|
||||
render_settings::RenderJob,
|
||||
render_surface::{EmbeddedSurfaceKind, EmbeddedSurfaceSource, SavedSurface, SurfaceSource},
|
||||
render_texture_resources::{MAX_TEXTURE_RESOURCE_BYTES, TextureProvenance, TextureResource, TextureResources},
|
||||
};
|
||||
use solidcraft_render::job::textures::PlanarFrame;
|
||||
use solidcraft_render::texture_input::MapRole;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) const IMPORT_CONTRACT: TypedContract<ImportParams> = TypedContract::new();
|
||||
pub(super) const LIST_CONTRACT: TypedContract<ListParams> = TypedContract::new();
|
||||
pub(super) const REMOVE_CONTRACT: TypedContract<RemoveParams> = TypedContract::new();
|
||||
|
||||
fn id(value: &str, command: &str) -> Result<HdrId> {
|
||||
serde_json::from_value(json!(value)).map_err(|e| bad(command, e.to_string()))
|
||||
}
|
||||
fn capture(
|
||||
input: Option<&TextureFile>,
|
||||
role: MapRole,
|
||||
resources: &mut TextureResources,
|
||||
provenance: &TextureProvenance,
|
||||
command: &str,
|
||||
) -> Result<Option<HdrId>> {
|
||||
let Some(input) = input else { return Ok(None) };
|
||||
if input.path.is_empty() || input.path.len() > 4096 || input.path.chars().any(char::is_control) {
|
||||
return Err(bad(command, "texture import requires a bounded scoped local path"));
|
||||
}
|
||||
let expected = input.expected_sha256.as_deref().map(|value| id(value, command)).transpose()?;
|
||||
let bytes = solidcraft_io::vfs::read(&input.path, solidcraft_render::texture_input::MAX_TEXTURE_BYTES)
|
||||
.map_err(|e| bad(command, format!("could not capture texture: {e}")))?;
|
||||
if expected.is_some_and(|expected| expected != HdrId::of(&bytes)) {
|
||||
return Err(bad(command, "texture file changed since selection; inspect and select it again"));
|
||||
}
|
||||
// Validation and all resource inserts affect the private settings copy only.
|
||||
let resource = TextureResource::new(bytes, provenance.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
resource.validate_role(role).map_err(|e| bad(command, e.to_string()))?;
|
||||
resources.insert(resource).map(Some).map_err(|e| bad(command, e.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn import(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.surface.import";
|
||||
let input: ImportParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
if !matches!(setup.job, Some(RenderJob::CpuRaytrace { .. } | RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "imported surface maps require an explicit CPU raytrace or GPU path setup"));
|
||||
}
|
||||
if s.doc.render.setups.get(&input.name).is_some_and(|previous| previous.surfaces != setup.surfaces) {
|
||||
return Err(bad(command, "setup patch cannot erase saved surface bindings; use explicit unbind"));
|
||||
}
|
||||
let frame = PlanarFrame { origin: input.frame.origin, u: input.frame.u, v: input.frame.v };
|
||||
frame.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if !input.normal_strength.is_finite() || !(0.0..=1.0).contains(&input.normal_strength) {
|
||||
return Err(bad(command, "normal strength must be finite in 0..1"));
|
||||
}
|
||||
if input.maps.base_color_srgb.is_none()
|
||||
&& input.maps.normal_opengl.is_none()
|
||||
&& input.maps.roughness_linear.is_none()
|
||||
&& input.maps.metallic_linear.is_none()
|
||||
{
|
||||
return Err(bad(command, "import at least one explicitly typed surface map"));
|
||||
}
|
||||
let scene = crate::product_scene::extract(s)?;
|
||||
let body = scene
|
||||
.meshes
|
||||
.get(input.body_index)
|
||||
.filter(|body| body.name == input.body_name)
|
||||
.ok_or_else(|| bad(command, "body ordinal and name must both match render.scene"))?;
|
||||
if body.opacity != 1.0 || body.transmission.is_some() || body.faces.iter().any(|face| face.opacity != 1.0 || face.transmission.is_some()) {
|
||||
return Err(bad(command, "imported maps support opaque bodies only"));
|
||||
}
|
||||
let bounds = body.mesh.bounds();
|
||||
for x in [bounds.min.x, bounds.max.x] {
|
||||
for y in [bounds.min.y, bounds.max.y] {
|
||||
for z in [bounds.min.z, bounds.max.z] {
|
||||
frame.uv(solidcore_record::Vec3::new(x, y, z)).map_err(|e| bad(command, e.to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let targets = super::surface_bindings::targets(s, command)?;
|
||||
let mut matching = targets.iter().filter(|(_, name)| **name == input.body_name);
|
||||
let target = matching.next().map(|(target, _)| target.clone()).ok_or_else(|| bad(command, "surface target has no stable identity"))?;
|
||||
if matching.next().is_some() {
|
||||
return Err(bad(command, "surface target has ambiguous identity"));
|
||||
}
|
||||
if !input.replace && setup.surfaces.iter().any(|binding| binding.target == target) {
|
||||
return Err(bad(command, "surface target already has a binding; explicit replacement required"));
|
||||
}
|
||||
let world = s.world_state()?;
|
||||
let computed = world.bodies.iter().find(|body| body.name == input.body_name).ok_or_else(|| bad(command, "surface world body disappeared"))?;
|
||||
let inverse = solidcraft_doc::mat_inverse(&computed.source_transform).ok_or_else(|| bad(command, "surface placement is not invertible"))?;
|
||||
let local_frame = super::surface_bindings::transform(frame, &inverse, command)?;
|
||||
let provenance = TextureProvenance { author: input.provenance.author, source: input.provenance.source, license: input.provenance.license };
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
let source = EmbeddedSurfaceSource {
|
||||
kind: EmbeddedSurfaceKind::Embedded,
|
||||
base_color: capture(input.maps.base_color_srgb.as_ref(), MapRole::BaseColor, &mut settings.texture_resources, &provenance, command)?,
|
||||
normal_open_gl: capture(input.maps.normal_opengl.as_ref(), MapRole::NormalOpenGl, &mut settings.texture_resources, &provenance, command)?,
|
||||
roughness: capture(input.maps.roughness_linear.as_ref(), MapRole::Roughness, &mut settings.texture_resources, &provenance, command)?,
|
||||
metallic: capture(input.maps.metallic_linear.as_ref(), MapRole::Metallic, &mut settings.texture_resources, &provenance, command)?,
|
||||
flip_v: input.flip_v,
|
||||
};
|
||||
setup
|
||||
.surfaces
|
||||
.set(
|
||||
SavedSurface { target, source: SurfaceSource::Embedded(source.clone()), local_frame, normal_strength: input.normal_strength },
|
||||
input.replace,
|
||||
)
|
||||
.map_err(|e| bad(command, e.to_string()))?;
|
||||
settings.setups.insert(input.name.clone(), setup.clone());
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings.texture_resources.total_bytes() > MAX_TEXTURE_RESOURCE_BYTES {
|
||||
return Err(bad(command, "encoded texture resources exceed the design budget"));
|
||||
}
|
||||
let resources = [source.base_color, source.normal_open_gl, source.roughness, source.metallic]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|digest| digest.to_string())
|
||||
.collect();
|
||||
let result = ImportResult {
|
||||
name: input.name,
|
||||
setup: serde_json::to_value(setup).map_err(|e| bad(command, e.to_string()))?,
|
||||
resources,
|
||||
self_contained: true,
|
||||
mapping_space: "definition_local_mm".into(),
|
||||
};
|
||||
if settings != *s.doc.render {
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
}
|
||||
serde_json::to_value(result).map_err(|e| bad(command, e.to_string()))
|
||||
}
|
||||
|
||||
fn users(s: &Session, id: HdrId) -> Vec<String> {
|
||||
s.doc
|
||||
.render
|
||||
.setups
|
||||
.iter()
|
||||
.filter(|(_, setup)| {
|
||||
setup.surfaces.iter().any(|binding| match &binding.source {
|
||||
SurfaceSource::Embedded(source) => [source.base_color, source.normal_open_gl, source.roughness, source.metallic].contains(&Some(id)),
|
||||
SurfaceSource::Builtin(_) => false,
|
||||
})
|
||||
})
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect()
|
||||
}
|
||||
pub(super) fn list(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.texture.list";
|
||||
let _: ListParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let resources = s
|
||||
.doc
|
||||
.render
|
||||
.texture_resources
|
||||
.iter()
|
||||
.map(|(id, resource)| {
|
||||
Ok(ResourceInfo {
|
||||
sha256: id.to_string(),
|
||||
encoded_bytes: resource.bytes().len(),
|
||||
dimensions: resource.dimensions(),
|
||||
provenance: serde_json::to_value(resource.provenance()).map_err(|e| bad(command, e.to_string()))?,
|
||||
users: users(s, *id),
|
||||
})
|
||||
})
|
||||
.collect::<Result<_>>()?;
|
||||
let encoded_bytes = s.doc.render.texture_resources.iter().map(|(_, resource)| resource.bytes().len()).sum();
|
||||
serde_json::to_value(ListResult { resources, encoded_bytes }).map_err(|e| bad(command, e.to_string()))
|
||||
}
|
||||
pub(super) fn remove(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.texture.remove";
|
||||
let input: RemoveParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let id = id(&input.sha256, command)?;
|
||||
let users = users(s, id);
|
||||
if !users.is_empty() {
|
||||
return Err(bad(command, format!("texture is referenced by render setups: {}; unbind it first", users.join(", "))));
|
||||
}
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
let removed = settings.texture_resources.remove(&id).is_some();
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if removed {
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
}
|
||||
serde_json::to_value(RemoveResult { sha256: input.sha256, removed }).map_err(|e| bad(command, e.to_string()))
|
||||
}
|
||||
@@ -38,6 +38,7 @@ fn load(
|
||||
remaining: &mut u64,
|
||||
command: &str,
|
||||
captured: &mut std::collections::BTreeMap<(String, u8), TextureMap>,
|
||||
resources: Option<&solidcraft_doc::render_texture_resources::TextureResources>,
|
||||
) -> Result<TextureMap> {
|
||||
if path.is_empty() || path.len() > 4096 || path.chars().any(char::is_control) {
|
||||
return Err(bad(command, "texture path must be a bounded local path"));
|
||||
@@ -54,7 +55,20 @@ fn load(
|
||||
return Ok(map.clone());
|
||||
}
|
||||
let maximum = (*remaining).min(solidcraft_render::texture_input::MAX_TEXTURE_BYTES as u64);
|
||||
let bytes = if let Some(key) = path.strip_prefix("builtin://") {
|
||||
let bytes = if let Some(key) = path.strip_prefix("embedded://") {
|
||||
let id: solidcraft_doc::render_resources::HdrId = serde_json::from_value(serde_json::json!(key)).map_err(|e| bad(command, e.to_string()))?;
|
||||
let resource = resources
|
||||
.ok_or_else(|| bad(command, "embedded texture resources are unavailable"))?
|
||||
.required(&id)
|
||||
.map_err(|e| bad(command, e.to_string()))?;
|
||||
if resource.retained_bytes() as u64 > maximum {
|
||||
return Err(bad(command, "embedded texture exceeds captured source budget"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
bytes.try_reserve_exact(resource.bytes().len()).map_err(|_| bad(command, "could not reserve captured texture"))?;
|
||||
bytes.extend_from_slice(resource.bytes());
|
||||
bytes
|
||||
} else if let Some(key) = path.strip_prefix("builtin://") {
|
||||
super::library::texture_bytes(key, role, maximum, command)?
|
||||
} else {
|
||||
solidcraft_io::vfs::read(path, maximum as usize).map_err(|e| bad(command, format!("could not capture texture: {e}")))?
|
||||
@@ -62,7 +76,8 @@ fn load(
|
||||
if bytes.is_empty() {
|
||||
return Err(bad(command, "texture file must not be empty"));
|
||||
}
|
||||
let map = TextureMap::new(bytes, role).map_err(|e| bad(command, e.to_string()))?;
|
||||
let map = if path.starts_with("embedded://") { TextureMap::new_portable(bytes, role) } else { TextureMap::new(bytes, role) }
|
||||
.map_err(|e| bad(command, e.to_string()))?;
|
||||
*remaining = remaining.checked_sub(map.retained_bytes() as u64).ok_or_else(|| bad(command, "encoded texture capacity exceeds budget"))?;
|
||||
captured.insert(key, map.clone());
|
||||
Ok(map)
|
||||
@@ -96,7 +111,16 @@ pub(super) fn validate_targets(scene: &ProductScene, overrides: &[Override], com
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(super) fn capture(scene: &ProductScene, overrides: &[Override], command: &str) -> Result<Option<Arc<RuntimeTextures>>> {
|
||||
capture_with_resources(scene, overrides, None, command)
|
||||
}
|
||||
pub(super) fn capture_with_resources(
|
||||
scene: &ProductScene,
|
||||
overrides: &[Override],
|
||||
resources: Option<&solidcraft_doc::render_texture_resources::TextureResources>,
|
||||
command: &str,
|
||||
) -> Result<Option<Arc<RuntimeTextures>>> {
|
||||
if overrides.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -119,10 +143,14 @@ pub(super) fn capture(scene: &ProductScene, overrides: &[Override], command: &st
|
||||
if body.transmission.is_some() || body.opacity != 1.0 || body.faces.iter().any(|f| f.transmission.is_some() || f.opacity != 1.0) {
|
||||
return Err(bad(command, "runtime maps support opaque bodies only"));
|
||||
}
|
||||
let base_color = input.base_color_srgb.as_ref().map(|p| load(p, MapRole::BaseColor, &mut remaining, command, &mut captured)).transpose()?;
|
||||
let normal = input.normal_opengl.as_ref().map(|p| load(p, MapRole::NormalOpenGl, &mut remaining, command, &mut captured)).transpose()?;
|
||||
let roughness = input.roughness_linear.as_ref().map(|p| load(p, MapRole::Roughness, &mut remaining, command, &mut captured)).transpose()?;
|
||||
let metallic = input.metallic_linear.as_ref().map(|p| load(p, MapRole::Metallic, &mut remaining, command, &mut captured)).transpose()?;
|
||||
let base_color =
|
||||
input.base_color_srgb.as_ref().map(|p| load(p, MapRole::BaseColor, &mut remaining, command, &mut captured, resources)).transpose()?;
|
||||
let normal =
|
||||
input.normal_opengl.as_ref().map(|p| load(p, MapRole::NormalOpenGl, &mut remaining, command, &mut captured, resources)).transpose()?;
|
||||
let roughness =
|
||||
input.roughness_linear.as_ref().map(|p| load(p, MapRole::Roughness, &mut remaining, command, &mut captured, resources)).transpose()?;
|
||||
let metallic =
|
||||
input.metallic_linear.as_ref().map(|p| load(p, MapRole::Metallic, &mut remaining, command, &mut captured, resources)).transpose()?;
|
||||
bindings.push(TextureBinding {
|
||||
body_index: input.body_index,
|
||||
body_name: input.body_name.clone(),
|
||||
|
||||
@@ -82,6 +82,7 @@ pub mod render_library;
|
||||
pub mod render_lights;
|
||||
pub mod render_shots;
|
||||
pub mod render_stage;
|
||||
pub mod render_surface_resources;
|
||||
pub mod render_ui;
|
||||
pub mod section;
|
||||
pub mod sheet;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Explicit import of opaque appearance maps into a design-owned render setup.
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
fn patch() -> Value {
|
||||
serde_json::json!({})
|
||||
}
|
||||
fn strength() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TextureFile {
|
||||
/// Scoped local image path, read once and never saved in the design.
|
||||
pub path: String,
|
||||
/// Optional lowercase SHA-256 from an earlier selection or inspection.
|
||||
pub expected_sha256: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TextureFiles {
|
||||
/// Encoded sRGB color; normalized before interpolation.
|
||||
pub base_color_srgb: Option<TextureFile>,
|
||||
/// OpenGL tangent normal; linear data, never a color image.
|
||||
pub normal_opengl: Option<TextureFile>,
|
||||
pub roughness_linear: Option<TextureFile>,
|
||||
pub metallic_linear: Option<TextureFile>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Provenance {
|
||||
pub author: String,
|
||||
/// Inert source locator; importing never fetches this address.
|
||||
pub source: String,
|
||||
/// User-declared license or ownership; does not certify permission.
|
||||
pub license: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SurfaceFrame {
|
||||
pub origin: [f64; 3],
|
||||
/// One physical repeat in world millimetres.
|
||||
pub u: [f64; 3],
|
||||
pub v: [f64; 3],
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ImportParams {
|
||||
pub name: String,
|
||||
#[serde(default = "patch")]
|
||||
pub set: Value,
|
||||
pub body_index: usize,
|
||||
pub body_name: String,
|
||||
pub frame: SurfaceFrame,
|
||||
pub maps: TextureFiles,
|
||||
pub provenance: Provenance,
|
||||
#[serde(default = "strength")]
|
||||
pub normal_strength: f64,
|
||||
#[serde(default)]
|
||||
pub flip_v: bool,
|
||||
#[serde(default)]
|
||||
pub replace: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ListParams {}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RemoveParams {
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ImportResult {
|
||||
pub name: String,
|
||||
pub setup: Value,
|
||||
pub resources: Vec<String>,
|
||||
pub self_contained: bool,
|
||||
pub mapping_space: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ResourceInfo {
|
||||
pub sha256: String,
|
||||
pub encoded_bytes: usize,
|
||||
pub dimensions: [u32; 2],
|
||||
pub provenance: Value,
|
||||
pub users: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ListResult {
|
||||
pub resources: Vec<ResourceInfo>,
|
||||
pub encoded_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct RemoveResult {
|
||||
pub sha256: String,
|
||||
pub removed: bool,
|
||||
}
|
||||
@@ -51,7 +51,7 @@ fn aging_mask(layer: AgingLayer, point:vec3<f32>, normal:vec3<f32>)->vec2<f32> {
|
||||
return vec2<f32>(clamp(coverage*layer.mask.x,0.0,1.0),field);
|
||||
}
|
||||
fn aged_surface(original:Triangle,point:vec3<f32>,outward:vec3<f32>)->Triangle {
|
||||
if original.base.w>0.0 || original.interior.z>0.0 {return original;}
|
||||
if original.base.w>0.0 || (u32(original.interior.z)&1u)!=0u {return original;}
|
||||
var result=original;
|
||||
for(var index=0u;index<8u;index+=1u) {
|
||||
let layer=u.aging[index];
|
||||
|
||||
@@ -35,7 +35,7 @@ impl Pipelines {
|
||||
layout: Some(&guide_layout),
|
||||
module: &guide_shader,
|
||||
entry_point: Some("denoise_guides"),
|
||||
compilation_options: Default::default(),
|
||||
compilation_options: wgpu::PipelineCompilationOptions { constants: &[("HAS_TEXTURES", 1.0)], ..Default::default() },
|
||||
cache: None,
|
||||
});
|
||||
let entries: Vec<_> = (0..4)
|
||||
|
||||
@@ -16,17 +16,20 @@ fn denoise_guides(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if u.environment.w>0.0 && emitter_hit_bounded(ray.origin,ray.direction,u.lighting.x).distance<hit.distance { return; }
|
||||
var t=triangle(hit.triangle);
|
||||
let position=surface_point(t,hit.bary);
|
||||
t=texture_surface(t,position);
|
||||
var geometric=unit(cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz));
|
||||
if dot(geometric,-ray.direction)<0.0 { geometric=-geometric; }
|
||||
let map_record=texture_record(t.optics.w);
|
||||
let gradients=texture_gradients(map_record,geometric,ray.direction,hit.distance,length(position-ray.origin),0u);
|
||||
let mapped_surface=texture_surface(map_record,t.base.xyz,t.parameters.x,t.parameters.y,position,gradients,(u32(t.interior.z)&2u)!=0u);
|
||||
t.base=vec4<f32>(mapped_surface.color,t.base.w);t.parameters=vec4<f32>(mapped_surface.metallic,mapped_surface.roughness,t.parameters.zw);
|
||||
t=aged_surface(t,position,unit(cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz)));
|
||||
// Single first-hit guides cannot describe refracted or sharp reflected images.
|
||||
if t.base.w>0.0 || (t.parameters.x>0.5 && t.parameters.y<0.25) || (t.coat.x>0.0 && t.coat.y<0.05) { return; }
|
||||
var geometric=unit(cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz));
|
||||
if dot(geometric,-ray.direction)<0.0 { geometric=-geometric; }
|
||||
var n=unit(t.n0.xyz*hit.bary.x+t.n1.xyz*hit.bary.y+t.n2.xyz*hit.bary.z);
|
||||
if dot(n,geometric)<0.0 { n=-n; }
|
||||
if dot(n,-ray.direction)<=0.0 { n=geometric; }
|
||||
let mapped=texture_normal(t,position,n,geometric);
|
||||
if dot(mapped,-ray.direction)>0.0 { n=mapped; }
|
||||
let mapped_normal=texture_normal(map_record,mapped_surface.normal,n,geometric);
|
||||
if dot(mapped_normal,-ray.direction)>0.0 { n=mapped_normal; }
|
||||
let depth=dot(position-u.eye.xyz,-u.back.xyz);
|
||||
var footprint=2.0*length(u.up.xyz)/f32(u.image.y);
|
||||
if u.eye.w>0.5 { footprint*=max(depth,0.0)/u.back.w; }
|
||||
|
||||
+105
-21
@@ -28,20 +28,32 @@ mod scene;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod textures;
|
||||
const SHADER: &str = concat!(
|
||||
include_str!("path.wgsl"),
|
||||
"\n",
|
||||
include_str!("shadows.wgsl"),
|
||||
"\n",
|
||||
include_str!("textures.wgsl"),
|
||||
"
|
||||
",
|
||||
include_str!("volume.wgsl"),
|
||||
"
|
||||
",
|
||||
include_str!("aging.wgsl")
|
||||
);
|
||||
// Disjoint glass admits at most one active medium. Fixed local array sizes let
|
||||
// native compilers discard the unused fifteen entries without changing nested
|
||||
// transport's sixteen-medium limit. Both programs share every shader operation.
|
||||
macro_rules! path_shader {
|
||||
($capacity:literal) => {
|
||||
concat!(
|
||||
"const MEDIUM_CAPACITY: u32 = ",
|
||||
$capacity,
|
||||
"u;\n",
|
||||
include_str!("path.wgsl"),
|
||||
"\n",
|
||||
include_str!("shadows.wgsl"),
|
||||
"\n",
|
||||
include_str!("textures.wgsl"),
|
||||
"\n",
|
||||
include_str!("volume.wgsl"),
|
||||
"\n",
|
||||
include_str!("aging.wgsl")
|
||||
)
|
||||
};
|
||||
}
|
||||
const SHADER: &str = path_shader!(16);
|
||||
const SINGLE_MEDIUM_SHADER: &str = path_shader!(1);
|
||||
const MAX_TRIANGLES: u64 = MAX_GPU_PATH_TRIANGLES;
|
||||
const PATH_WORKGROUP_SIZE: u32 = 32;
|
||||
const SCENE_PIPELINE_CACHE_SLOTS: usize = 5;
|
||||
const PARAMETER_BYTES: usize = 1184;
|
||||
// Eight u32 words: count low, pixel, bounce, sample, code, count high, first-writer flag, spare.
|
||||
const FAILURE_BYTES: u64 = 32;
|
||||
@@ -166,6 +178,9 @@ struct Context {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
// Plain opaque and non-nested glass are ready before the host is published.
|
||||
// The four map/nested variants are prepared lazily in the remaining slots.
|
||||
scene_pipelines: [std::sync::OnceLock<wgpu::ComputePipeline>; SCENE_PIPELINE_CACHE_SLOTS],
|
||||
// Built and warmed by the first denoised job, then shared by later ones.
|
||||
denoise: std::sync::OnceLock<denoise::Pipelines>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
@@ -184,6 +199,47 @@ impl Context {
|
||||
fn wait(&self, submission: wgpu::SubmissionIndex, timeout: Duration) -> Result<()> {
|
||||
wait_device(&self.device, &self.healthy, submission, timeout)
|
||||
}
|
||||
fn prepared_scene_pipeline(&self, opaque: bool, maps: bool, nested: bool) -> Result<&wgpu::ComputePipeline> {
|
||||
let Some(index) = scene_pipeline_variant(opaque, maps, nested) else { return Ok(&self.pipeline) };
|
||||
self.scene_pipelines.get(index).and_then(std::sync::OnceLock::get).ok_or_else(|| gpu("captured scene has no prepared pipeline"))
|
||||
}
|
||||
fn prepare_scene_pipeline(&self, opaque: bool, maps: bool, nested: bool) -> Result<&wgpu::ComputePipeline> {
|
||||
self.ensure_available()?;
|
||||
let Some(index) = scene_pipeline_variant(opaque, maps, nested) else { return Ok(&self.pipeline) };
|
||||
let slot = self.scene_pipelines.get(index).ok_or_else(|| gpu("scene pipeline variant exceeds its finite cache"))?;
|
||||
if let Some(pipeline) = slot.get() {
|
||||
return Ok(pipeline);
|
||||
}
|
||||
let _compiling = one_compile_at_a_time();
|
||||
if let Some(pipeline) = slot.get() {
|
||||
return Ok(pipeline);
|
||||
}
|
||||
let scopes = scopes(&self.device);
|
||||
let shader = self.device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("gpu-path captured scene"),
|
||||
source: wgpu::ShaderSource::Wgsl(if opaque || nested { SHADER } else { SINGLE_MEDIUM_SHADER }.into()),
|
||||
});
|
||||
let layout = self.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("gpu-path captured scene"),
|
||||
bind_group_layouts: &[Some(&self.layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = self.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("gpu-path captured scene"),
|
||||
layout: Some(&layout),
|
||||
module: &shader,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[("OPAQUE_SCENE", f64::from(u8::from(opaque))), ("HAS_TEXTURES", f64::from(u8::from(maps)))],
|
||||
..Default::default()
|
||||
},
|
||||
cache: None,
|
||||
});
|
||||
checked(scopes)?;
|
||||
warm_up(&self.device, &self.queue, &pipeline, &placeholder_bind(&self.device, &self.layout, 7)?, &self.healthy)?;
|
||||
let _ = slot.set(pipeline);
|
||||
slot.get().ok_or_else(|| gpu("scene pipeline publication failed"))
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_device(device: &wgpu::Device, healthy: &AtomicBool, submission: wgpu::SubmissionIndex, timeout: Duration) -> Result<()> {
|
||||
@@ -248,13 +304,25 @@ impl Executor {
|
||||
layout: Some(&pipeline_layout),
|
||||
module: &shader,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
compilation_options: wgpu::PipelineCompilationOptions { constants: &[("OPAQUE_SCENE", 1.0)], ..Default::default() },
|
||||
cache: None,
|
||||
});
|
||||
checked(scopes)?;
|
||||
drop(compiling);
|
||||
warm_up(&device, &queue, &pipeline, &placeholder_bind(&device, &layout, 7)?, &healthy)?;
|
||||
Ok(Self { context: Arc::new(Context { healthy, device, queue, pipeline, layout, denoise: std::sync::OnceLock::new() }) })
|
||||
let context = Arc::new(Context {
|
||||
healthy,
|
||||
device,
|
||||
queue,
|
||||
pipeline,
|
||||
layout,
|
||||
denoise: std::sync::OnceLock::new(),
|
||||
scene_pipelines: std::array::from_fn(|_| std::sync::OnceLock::new()),
|
||||
});
|
||||
// The original host prepared glass before advertising readiness. Keep the
|
||||
// common no-map/non-nested program out of the first image job's work.
|
||||
context.prepare_scene_pipeline(false, false, false)?;
|
||||
Ok(Self { context })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +393,16 @@ fn descriptor() -> Result<ExecutorDescriptor> {
|
||||
})
|
||||
}
|
||||
|
||||
fn scene_pipeline_variant(opaque: bool, maps: bool, nested: bool) -> Option<usize> {
|
||||
if opaque { maps.then_some(0) } else { Some(1 + 2 * usize::from(maps) + usize::from(nested)) }
|
||||
}
|
||||
|
||||
fn use_opaque_pipeline(scene: &ProductScene, options: JobOptions) -> bool {
|
||||
!scene.has_transmission()
|
||||
&& !options.volume.is_some_and(|volume| volume.active())
|
||||
&& scene.meshes.iter().all(|body| body.opacity == 1.0 && body.faces.iter().all(|face| face.opacity == 1.0))
|
||||
}
|
||||
|
||||
fn admission(scene: &ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission> {
|
||||
if request.integrator != solidcraft_render::job::Integrator::Raster || request.raytrace.is_some() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "GPU path rendering does not accept CPU raytrace options"));
|
||||
@@ -486,10 +564,7 @@ impl ImageExecutor for Executor {
|
||||
let a = admission(scene, request, options)?;
|
||||
let limit = self.context.device.limits().max_storage_buffer_binding_size;
|
||||
let geometry_bytes = 16 + a.triangles.max(1) * 100 + scene.meshes.iter().map(|m| m.mesh.positions.len() as u64 * 32).sum::<u64>();
|
||||
let texture_bytes = request
|
||||
.textures
|
||||
.as_ref()
|
||||
.map_or(16, |t| t.summary().linear_pixel_bytes + 4 * (5 + scene.meshes.len() as u64 + t.bindings().len() as u64 * 24));
|
||||
let texture_bytes = request.textures.as_ref().map_or(16, |t| textures::packed_bytes(t, scene.meshes.len()));
|
||||
let environment_bytes =
|
||||
request.environment.as_ref().map_or(48, |source| 16 + u64::from(source.info().width) * u64::from(source.info().height) * 32);
|
||||
let node_bytes = a.triangles.max(1) * 64;
|
||||
@@ -542,6 +617,8 @@ struct Task {
|
||||
context: Arc<Context>,
|
||||
request: ImageRequest,
|
||||
options: JobOptions,
|
||||
opaque_scene: bool,
|
||||
has_textures: bool,
|
||||
cancel: CancelToken,
|
||||
uniform: wgpu::Buffer,
|
||||
accumulation: wgpu::Buffer,
|
||||
@@ -571,6 +648,10 @@ struct Task {
|
||||
}
|
||||
impl Task {
|
||||
fn new(context: Arc<Context>, scene: ProductScene, mut request: ImageRequest, options: JobOptions, cancel: CancelToken) -> Result<Self> {
|
||||
cancel.check()?;
|
||||
let opaque_scene = use_opaque_pipeline(&scene, options);
|
||||
let has_textures = request.textures.is_some();
|
||||
context.prepare_scene_pipeline(opaque_scene, has_textures, options.nested_media)?;
|
||||
cancel.check()?;
|
||||
let start = Instant::now();
|
||||
let aging = options.aging.gpu_layers([request.camera.target.x, request.camera.target.y, request.camera.target.z])?;
|
||||
@@ -655,6 +736,8 @@ impl Task {
|
||||
cancel.check()?;
|
||||
Ok(Self {
|
||||
dispatch_budget: Default::default(),
|
||||
opaque_scene,
|
||||
has_textures,
|
||||
context,
|
||||
request,
|
||||
options,
|
||||
@@ -793,9 +876,10 @@ impl Task {
|
||||
let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("path tile") });
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("path tile"), timestamp_writes: None });
|
||||
pass.set_pipeline(&context.pipeline);
|
||||
let pipeline = context.prepared_scene_pipeline(self.opaque_scene, self.has_textures, self.options.nested_media)?;
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, &self.bind, &[]);
|
||||
pass.dispatch_workgroups(count.div_ceil(64), 1, 1);
|
||||
pass.dispatch_workgroups(count.div_ceil(PATH_WORKGROUP_SIZE), 1, 1);
|
||||
}
|
||||
let submission = context.queue.submit([encoder.finish()]);
|
||||
checked(scopes)?;
|
||||
|
||||
@@ -7,7 +7,7 @@ struct Triangle {
|
||||
// optics.xyz: extinction per mm, optics.w: body/medium identity (1..16384).
|
||||
base: vec4<f32>, parameters: vec4<f32>, optics: vec4<f32>,
|
||||
coat: vec4<f32>, // weight, perceptual roughness, IOR, shadow transmission flag
|
||||
interior: vec4<f32>, // scattering/mm, HG g, reserved, immediate parent triangle+1
|
||||
interior: vec4<f32>, // scattering/mm, HG g, flags (stage=1, scalar face=2), immediate parent triangle+1
|
||||
};
|
||||
struct Node { lo: vec3<f32>, first: u32, hi: vec3<f32>, count: u32 };
|
||||
struct AreaLight { center: vec4<f32>, u: vec4<f32>, v: vec4<f32>, radiance: vec4<f32> };
|
||||
@@ -57,6 +57,8 @@ fn medium_failure(radiance: vec3<f32>, coverage: f32, pixel: u32, bounce: u32, c
|
||||
}
|
||||
const PI: f32 = 3.141592653589793;
|
||||
const FAR: f32 = 1e30;
|
||||
override OPAQUE_SCENE: bool = false;
|
||||
override HAS_TEXTURES: bool = false;
|
||||
|
||||
// Header: triangle count, vertex offset, material offset, planar-patch offset (all words).
|
||||
// Indexed triangles contain three vertex indices and one resolved material ID.
|
||||
@@ -101,7 +103,7 @@ struct Medium { id: f32, ior: f32, sigma: vec3<f32>, scatter: vec2<f32> };
|
||||
// Establish the complete certified containment stack even when the camera starts
|
||||
// inside a liquid or a ray starts on an opaque object inside a container. Parent
|
||||
// references are immutable CPU-certified material references, never AABB guesses.
|
||||
fn initialize_media(hit_index:u32,d:vec3<f32>,stack:ptr<function,array<Medium,16>>,count:ptr<function,u32>) -> bool {
|
||||
fn initialize_media(hit_index:u32,d:vec3<f32>,stack:ptr<function,array<Medium,MEDIUM_CAPACITY>>,count:ptr<function,u32>) -> bool {
|
||||
let first=triangle(hit_index);
|
||||
if first.base.w<=0.0 || first.parameters.w>=0.0 {return true;}
|
||||
let outward=unit(cross(first.p1.xyz-first.p0.xyz,first.p2.xyz-first.p0.xyz));
|
||||
@@ -109,7 +111,7 @@ fn initialize_media(hit_index:u32,d:vec3<f32>,stack:ptr<function,array<Medium,16
|
||||
var reference=select(hit_index+1u,u32(first.interior.w),entering);
|
||||
var length=0u;
|
||||
while reference>0u {
|
||||
if length>=16u {return false;}
|
||||
if length>=MEDIUM_CAPACITY {return false;}
|
||||
let t=triangle(reference-1u);
|
||||
(*stack)[length]=Medium(t.optics.w,t.parameters.z,t.optics.xyz,t.interior.xy);
|
||||
reference=u32(t.interior.w);
|
||||
@@ -833,14 +835,15 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
var coverage = 0.0;
|
||||
var previous_pdf = 0.0;
|
||||
var previous_delta = true;
|
||||
var media: array<Medium,16>;
|
||||
var media: array<Medium,MEDIUM_CAPACITY>;
|
||||
var medium_count = 0u;
|
||||
var texture_path_length = 0.0;
|
||||
for (var bounce = 0u; bounce < u.sampling.y; bounce += 1u) {
|
||||
// Closed-glass continuations set an excluded producing triangle; every
|
||||
// other launch (camera, opaque or thin-sheet surface) does not.
|
||||
let continued = ray_excluded != 0xffffffffu;
|
||||
let hit = trace_precise(o,o_low,d,false,ray_excluded,ray_minimum,medium_count>0u);
|
||||
if u.lighting.w>0.0 && medium_count==0u && hit.triangle!=0xffffffffu {
|
||||
if !OPAQUE_SCENE && u.lighting.w>0.0 && medium_count==0u && hit.triangle!=0xffffffffu {
|
||||
if !initialize_media(hit.triangle,d,&media,&medium_count) {return medium_failure(radiance,coverage,pixel,bounce,4u);}
|
||||
}
|
||||
if medium_count>0u && media[medium_count-1u].scatter.x>0.0 {
|
||||
@@ -848,6 +851,7 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
let boundary=min(hit.distance,emitter_hit_precise(o,o_low,d,ray_minimum).distance);
|
||||
let travel=-log(1.0-random(&rng))/current.scatter.x;
|
||||
if travel<boundary {
|
||||
texture_path_length+=travel;
|
||||
let position=o+o_low+d*travel;
|
||||
let traversed=precise_point_delta(position,vec3<f32>(0.0),previous_position,previous_position_low);
|
||||
throughput*=exp(-current.sigma*length(traversed));
|
||||
@@ -871,6 +875,7 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
if length>0.0 {
|
||||
let travel=-log(1.0-random(&rng))/u.volume_min.w;
|
||||
if travel<length {
|
||||
texture_path_length+=segment.x+travel;
|
||||
let position=o+o_low+d*(segment.x+travel);
|
||||
coverage=1.0;
|
||||
throughput*=u.volume_albedo.xyz;
|
||||
@@ -932,41 +937,37 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
}
|
||||
coverage = 1.0;
|
||||
ray_excluded = 0xffffffffu; ray_minimum = u.lighting.x;
|
||||
var t = triangle(hit.triangle); let v = -d;
|
||||
var geometric = unit(cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz));
|
||||
let entering = dot(geometric,v)>=0.0;
|
||||
if !entering { geometric = -geometric; }
|
||||
let captured = triangle(hit.triangle); let v = -d;
|
||||
let outward = unit(cross(captured.p1.xyz-captured.p0.xyz,captured.p2.xyz-captured.p0.xyz));
|
||||
let entering = dot(outward,v)>=0.0;
|
||||
let geometric = select(-outward,outward,entering);
|
||||
var ni = 1.0;
|
||||
if medium_count > 0u {
|
||||
let current = media[medium_count-1u];
|
||||
ni = current.ior;
|
||||
}
|
||||
var n = unit(t.n0.xyz*hit.bary.x+t.n1.xyz*hit.bary.y+t.n2.xyz*hit.bary.z);
|
||||
if dot(n,geometric)<0.0 { n = -n; }
|
||||
if dot(n,v)<=0.0 { n = geometric; }
|
||||
// Recover the surface point from barycentrics. A distant ray's f32 distance
|
||||
// loses precision on skinny triangles; o+d*t can land behind the surface
|
||||
// and make the next shadow ray intersect the same face.
|
||||
var position = surface_point(t,hit.bary);
|
||||
if medium_count > 0u {ni = media[medium_count-1u].ior;}
|
||||
var position = surface_point(captured,hit.bary);
|
||||
var position_low = vec3<f32>(0.0);
|
||||
if t.base.w>0.0 && t.parameters.w<0.0 {
|
||||
let point=surface_point_precise(t,hit.bary);
|
||||
position=point.hi; position_low=point.lo;
|
||||
if captured.base.w>0.0 && captured.parameters.w<0.0 {
|
||||
let point=surface_point_precise(captured,hit.bary);
|
||||
position=point.hi;position_low=point.lo;
|
||||
}
|
||||
let shading_position = position+position_low;
|
||||
t = texture_surface(t,shading_position);
|
||||
t = aged_surface(t,shading_position,unit(cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz)));
|
||||
let original_normal = n;
|
||||
n = texture_normal(t,shading_position,n,geometric);
|
||||
if dot(n,v)<=0.0 { n=original_normal; }
|
||||
let shading_position=position+position_low;
|
||||
texture_path_length+=length(precise_point_delta(position,position_low,o,o_low));
|
||||
if medium_count > 0u {
|
||||
let segment=precise_point_delta(position,position_low,previous_position,previous_position_low);
|
||||
throughput *= exp(-media[medium_count-1u].sigma*length(segment));
|
||||
}
|
||||
previous_position = position; previous_position_low=position_low;
|
||||
o = position + geometric*(u.lighting.x*2.0); o_low=vec3<f32>(0.0);
|
||||
radiance += throughput*direct_light(t,position,o,n,geometric,v,u.sampling.y-bounce-1u,&rng);
|
||||
if t.base.w > 0.0 && random(&rng) < t.base.w {
|
||||
// Captured dielectric flags are exactly one, and opaque flags zero.
|
||||
// Maps are admitted only on opaque bodies; aging also leaves glass
|
||||
// unchanged. Resolve glass before any sampler temporaries become live.
|
||||
if !OPAQUE_SCENE && captured.base.w > 0.0 && random(&rng) < captured.base.w {
|
||||
let t=captured;
|
||||
let interpolated=unit(captured.n0.xyz*hit.bary.x+captured.n1.xyz*hit.bary.y+captured.n2.xyz*hit.bary.z);
|
||||
let winding_normal=select(interpolated,-interpolated,dot(interpolated,geometric)<0.0);
|
||||
let n=select(geometric,winding_normal,dot(winding_normal,v)>0.0);
|
||||
// The dielectric flag makes every direct-light closure weight zero;
|
||||
// direct_light consumes no random values on this zero-weight branch.
|
||||
var optical_normal=n;
|
||||
var nt = t.parameters.z;
|
||||
if t.parameters.w < 0.0 && entering && medium_count > 0u && u.lighting.w==0.0 {
|
||||
return medium_failure(radiance,coverage,pixel,bounce,4u);
|
||||
@@ -994,14 +995,14 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
nt = 1.0;
|
||||
if medium_count > 1u { nt = media[medium_count-2u].ior; }
|
||||
}
|
||||
var f = dielectric(clamp(dot(n,v),0.0,1.0),ni,nt);
|
||||
var transmitted = refract(d,n,ni/nt);
|
||||
var f = dielectric(clamp(dot(optical_normal,v),0.0,1.0),ni,nt);
|
||||
var transmitted = refract(d,optical_normal,ni/nt);
|
||||
// A bent shading normal must not send a transmitted ray into the wrong
|
||||
// geometric hemisphere. Fall back to the geometric interface there.
|
||||
if (f.z >= 0.0 && dot(transmitted,geometric) >= 0.0) || dot(reflect(d,n),geometric) <= 0.0 {
|
||||
n = geometric;
|
||||
f = dielectric(clamp(dot(n,v),0.0,1.0),ni,nt);
|
||||
transmitted = refract(d,n,ni/nt);
|
||||
if (f.z >= 0.0 && dot(transmitted,geometric) >= 0.0) || dot(reflect(d,optical_normal),geometric) <= 0.0 {
|
||||
optical_normal = geometric;
|
||||
f = dielectric(clamp(dot(optical_normal,v),0.0,1.0),ni,nt);
|
||||
transmitted = refract(d,optical_normal,ni/nt);
|
||||
}
|
||||
// The first Fresnel choice is stratified conditionally on the same
|
||||
// primary/prefix path. Each stratum is weighted equally; later random
|
||||
@@ -1021,7 +1022,7 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
if branch < probability {
|
||||
previous_delta=true; previous_scatter_position=position; previous_scatter_position_low=position_low; suppress_direct=false;
|
||||
throughput *= weights.reflection/probability;
|
||||
d = reflect(d,n);
|
||||
d = reflect(d,optical_normal);
|
||||
} else {
|
||||
throughput *= weights.transmission/(1.0-probability);
|
||||
if t.coat.w>0.0 { previous_pdf*=1.0-probability; }
|
||||
@@ -1033,13 +1034,13 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
}
|
||||
} else if branch < (f.x+f.y)*0.5 {
|
||||
previous_delta=true; previous_scatter_position=position; previous_scatter_position_low=position_low; suppress_direct=false;
|
||||
d = reflect(d,n);
|
||||
d = reflect(d,optical_normal);
|
||||
} else {
|
||||
if t.coat.w>0.0 { suppress_direct=suppress_direct || !previous_delta; }
|
||||
else { suppress_direct=false; }
|
||||
previous_delta=true; previous_scatter_position=position; previous_scatter_position_low=position_low;
|
||||
if entering {
|
||||
if medium_count >= 16u { return medium_failure(radiance,coverage,pixel,bounce,4u); }
|
||||
if medium_count >= MEDIUM_CAPACITY { return medium_failure(radiance,coverage,pixel,bounce,4u); }
|
||||
media[medium_count] = Medium(t.optics.w,nt,t.optics.xyz,t.interior.xy);
|
||||
medium_count += 1u;
|
||||
} else { medium_count -= 1u; }
|
||||
@@ -1063,6 +1064,18 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let map_record=texture_record(captured.optics.w);
|
||||
let gradients=texture_gradients(map_record,geometric,d,hit.distance,texture_path_length,bounce);
|
||||
let mapped_surface=texture_surface(map_record,captured.base.xyz,captured.parameters.x,captured.parameters.y,shading_position,gradients,(u32(captured.interior.z)&2u)!=0u);
|
||||
// Retain immutable geometry and medium data across optical map evaluation.
|
||||
let textured=Triangle(captured.p0,captured.p1,captured.p2,captured.n0,captured.n1,captured.n2,vec4<f32>(mapped_surface.color,captured.base.w),vec4<f32>(mapped_surface.metallic,mapped_surface.roughness,captured.parameters.zw),captured.optics,captured.coat,captured.interior);
|
||||
let t=aged_surface(textured,shading_position,outward);
|
||||
let interpolated=unit(captured.n0.xyz*hit.bary.x+captured.n1.xyz*hit.bary.y+captured.n2.xyz*hit.bary.z);
|
||||
let winding_normal=select(interpolated,-interpolated,dot(interpolated,geometric)<0.0);
|
||||
let original_normal=select(geometric,winding_normal,dot(winding_normal,v)>0.0);
|
||||
let mapped_normal=texture_normal(map_record,mapped_surface.normal,original_normal,geometric);
|
||||
let n=select(original_normal,mapped_normal,dot(mapped_normal,v)>0.0);
|
||||
radiance += throughput*direct_light(t,position,o,n,geometric,v,u.sampling.y-bounce-1u,&rng);
|
||||
d = sample_surface(t,n,v,&rng);
|
||||
let nl = dot(n,d);
|
||||
if nl<=0.0 || dot(geometric,d)<=0.0 { break; }
|
||||
@@ -1106,7 +1119,7 @@ fn adaptive_target(mean: vec4<f32>, m2: vec4<f32>) -> u32 {
|
||||
return clamp(u32(max(max(need.x,need.y),max(need.z,need.w))),u.adaptive_budget.y,maximum);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
@compute @workgroup_size(32)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let pixel = u.image.w + gid.x;
|
||||
let pixels = u.image.x*u.image.y;
|
||||
@@ -1168,6 +1181,8 @@ fn camera_reference() {
|
||||
// Bias signed test coordinates for the radiance readback's nonnegative contract.
|
||||
accumulation[0] = vec4<f32>(ray.origin+vec3<f32>(16.0),1.0);
|
||||
accumulation[1] = vec4<f32>(ray.direction+vec3<f32>(2.0),1.0);
|
||||
accumulation[2] = vec4<f32>(texture_camera_direction_delta(ray.direction,2.0*u.right.xyz/f32(u.image.x))+vec3<f32>(16.0),1.0);
|
||||
accumulation[3] = vec4<f32>(texture_camera_direction_delta(ray.direction,-2.0*u.up.xyz/f32(u.image.y))+vec3<f32>(16.0),1.0);
|
||||
}
|
||||
|
||||
// Deterministic image-space ball-lens check. Uses the production BVH/intersection
|
||||
|
||||
@@ -123,6 +123,7 @@ mod tests {
|
||||
surface: OpaqueSurface::new(Vec3::ZERO, 0.0, 0.5, 1.5).unwrap(),
|
||||
transmission: None,
|
||||
medium: body,
|
||||
scalar_face: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ pub(super) struct Triangle {
|
||||
pub surface: OpaqueSurface,
|
||||
pub transmission: Option<Transmission>,
|
||||
pub medium: u32,
|
||||
pub scalar_face: bool,
|
||||
}
|
||||
type MediumEdge = (usize, usize, i32);
|
||||
pub(super) fn medium_preparation_bytes(scene: &ProductScene) -> u64 {
|
||||
@@ -156,7 +157,14 @@ pub(super) fn flatten_with_media(scene: &ProductScene, origin: Vec3, cancel: &Ca
|
||||
if transmission.is_some() && surface.metallic() != 0.0 {
|
||||
return Err(invalid("transmission requires a nonmetallic surface"));
|
||||
}
|
||||
triangles.push(Triangle { p, indices: global_indices, surface, transmission, medium: body_index as u32 + 1 });
|
||||
triangles.push(Triangle {
|
||||
p,
|
||||
indices: global_indices,
|
||||
surface,
|
||||
transmission,
|
||||
medium: body_index as u32 + 1,
|
||||
scalar_face: look.is_some(),
|
||||
});
|
||||
}
|
||||
validate_medium(&triangles[body_start..], cancel)?;
|
||||
let body_triangles = &triangles[body_start..];
|
||||
@@ -235,11 +243,8 @@ pub(super) fn flatten_with_media(scene: &ProductScene, origin: Vec3, cancel: &Ca
|
||||
Vec3::new(
|
||||
scatter.map_or(0.0, |s| s.scattering_per_mm),
|
||||
scatter.map_or(0.0, |s| s.anisotropy),
|
||||
if scene.meshes.get(t.medium.saturating_sub(1) as usize).is_some_and(|body| body.name.starts_with("__render_stage__/")) {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
(u32::from(scene.meshes.get(t.medium.saturating_sub(1) as usize).is_some_and(|body| body.name.starts_with("__render_stage__/")))
|
||||
| (u32::from(t.scalar_face) << 1)) as f64,
|
||||
),
|
||||
parent_triangle as f32,
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ fn decades(count: usize) -> Vec<Triangle> {
|
||||
surface,
|
||||
transmission: None,
|
||||
medium: 0,
|
||||
scalar_face: false,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -33,6 +33,7 @@ fn shadow_transmittance(origin: vec3<f32>, direction: vec3<f32>, limit: f32) ->
|
||||
return result;
|
||||
}
|
||||
if step==64u { return blocked_shadow(); }
|
||||
if OPAQUE_SCENE {return blocked_shadow();}
|
||||
let t = triangle(hit.triangle);
|
||||
if t.base.w<=0.0 || t.coat.w<=0.0 { return blocked_shadow(); }
|
||||
var position=surface_point(t,hit.bary);
|
||||
|
||||
+623
-10
@@ -308,9 +308,9 @@ fn gpu_thin_lens_focus_and_defocus_match_geometric_oracle() {
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
for (yaw, pitch, roll) in [(0.0, 0.0, 0.0), (-0.45, 0.5, 0.7)] {
|
||||
for (yaw, pitch, roll, fov) in [(0.0, 0.0, 0.0, 0.7), (-0.45, 0.5, 0.7, 0.7), (-0.45, 0.5, 0.7, 2.0)] {
|
||||
let mut request = request(2);
|
||||
request.camera = Camera { distance: 12.0, fov: 0.7, yaw, pitch, roll, ..Default::default() };
|
||||
request.camera = Camera { distance: 12.0, fov, yaw, pitch, roll, ..Default::default() };
|
||||
let camera = request.camera;
|
||||
let lens = solidcraft_render::job::ThinLens { aperture_radius_mm: 0.4, focus_distance_mm: 9.0 };
|
||||
let options = JobOptions { lens: Some(lens), ..Default::default() };
|
||||
@@ -340,6 +340,20 @@ fn gpu_thin_lens_focus_and_defocus_match_geometric_oracle() {
|
||||
let direction = vec(rays[1]) - Vec3::new(2.0, 2.0, 2.0);
|
||||
let disk = (right * (azimuth * std::f64::consts::TAU).cos() + up * (azimuth * std::f64::consts::TAU).sin())
|
||||
* (radial.sqrt() * lens.aperture_radius_mm);
|
||||
// Differentiate independently constructed f64 rays with the lens
|
||||
// point fixed; the production helper keeps no camera live state.
|
||||
let direction_at = |x: f64, y: f64| {
|
||||
((right * x + up * y) * (camera.half_height() * lens.focus_distance_mm / camera.distance) - back * lens.focus_distance_mm - disk)
|
||||
.normalized()
|
||||
.unwrap()
|
||||
};
|
||||
let h = 1e-3;
|
||||
for (slot, dx, dy) in [(2, h, 0.), (3, 0., -h)] {
|
||||
// request(2) makes one pixel exactly one unit of normalized UV.
|
||||
let expected = (direction_at(ux + dx, uy + dy) - direction_at(ux - dx, uy - dy)) / (2.0 * h);
|
||||
let actual = vec(rays[slot]) - Vec3::new(16., 16., 16.);
|
||||
assert!((actual - expected).len() < 4e-6, "fov={fov},uv=({ux},{uy}),slot={slot}: {actual:?} vs {expected:?}");
|
||||
}
|
||||
assert!((origin - eye - disk).len() < 3e-6);
|
||||
let focus_hit = origin + direction * ((focal - origin).dot(back) / direction.dot(back));
|
||||
assert!((focus_hit - focal).len() < 5e-6, "focus {focus_hit:?} != {focal:?}");
|
||||
@@ -525,11 +539,14 @@ fn gpu_solid_and_thin_slabs_match_absorbing_interreflection_oracle() {
|
||||
|
||||
#[test]
|
||||
fn shader_validates_without_a_device() {
|
||||
let module = naga::front::wgsl::parse_str(SHADER).unwrap();
|
||||
let parameters = module.types.iter().find(|(_, ty)| ty.name.as_deref() == Some("Params")).unwrap().1;
|
||||
let naga::TypeInner::Struct { span, .. } = ¶meters.inner else { panic!("Params must be a struct") };
|
||||
assert_eq!(*span as usize, PARAMETER_BYTES);
|
||||
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap();
|
||||
for source in [SHADER, SINGLE_MEDIUM_SHADER] {
|
||||
let module = naga::front::wgsl::parse_str(source).unwrap();
|
||||
let parameters = module.types.iter().find(|(_, ty)| ty.name.as_deref() == Some("Params")).unwrap().1;
|
||||
let naga::TypeInner::Struct { span, .. } = ¶meters.inner else { panic!("Params must be a struct") };
|
||||
assert_eq!(*span as usize, PARAMETER_BYTES);
|
||||
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap();
|
||||
assert_eq!(module.entry_points.iter().find(|entry| entry.name == "main").unwrap().workgroup_size, [PATH_WORKGROUP_SIZE, 1, 1]);
|
||||
}
|
||||
}
|
||||
|
||||
/// #6705: native compilers inline every call. With 33 inlined BVH walks in `main` (three path
|
||||
@@ -1852,6 +1869,13 @@ fn gpu_hdr_and_sun_shadow_mis_preserve_index_matched_energy() {
|
||||
}
|
||||
|
||||
fn texture_fixture(frame: solidcraft_render::job::textures::PlanarFrame) -> Arc<solidcraft_render::job::textures::RuntimeTextures> {
|
||||
texture_fixture_filtering(frame, false)
|
||||
}
|
||||
|
||||
fn texture_fixture_filtering(
|
||||
frame: solidcraft_render::job::textures::PlanarFrame,
|
||||
portable: bool,
|
||||
) -> Arc<solidcraft_render::job::textures::RuntimeTextures> {
|
||||
use image::ImageEncoder;
|
||||
use solidcraft_render::{
|
||||
job::textures::{RuntimeTextures, TextureBinding, TextureMap},
|
||||
@@ -1860,7 +1884,7 @@ fn texture_fixture(frame: solidcraft_render::job::textures::PlanarFrame) -> Arc<
|
||||
let map = |pixels: &[u8], role| {
|
||||
let mut bytes = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut bytes).write_image(pixels, 2, 2, image::ExtendedColorType::Rgba8).unwrap();
|
||||
TextureMap::new(bytes, role).unwrap()
|
||||
if portable { TextureMap::new_portable(bytes, role).unwrap() } else { TextureMap::new(bytes, role).unwrap() }
|
||||
};
|
||||
Arc::new(
|
||||
RuntimeTextures::new(vec![TextureBinding {
|
||||
@@ -1899,6 +1923,77 @@ fn texture_admission_rejects_glass_and_stale_identity_and_honors_cancellation()
|
||||
assert!(admission(&scene, &input, JobOptions::default()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn host_readiness_includes_unmapped_non_nested_glass() {
|
||||
let Some(executor) = executor() else { return };
|
||||
let context = &executor.context;
|
||||
assert!(context.prepared_scene_pipeline(true, false, false).is_ok());
|
||||
assert!(context.prepared_scene_pipeline(false, false, false).is_ok(), "a ready host must already prepare ordinary unmapped glass");
|
||||
let plain = scene_pipeline_variant(false, false, false).unwrap();
|
||||
assert!(context.scene_pipelines[plain].get().is_some());
|
||||
for (opaque, maps, nested) in [(true, true, false), (false, false, true), (false, true, false), (false, true, true)] {
|
||||
let variant = scene_pipeline_variant(opaque, maps, nested).unwrap();
|
||||
assert!(context.scene_pipelines[variant].get().is_none(), "uncommon map/nesting programs stay lazy");
|
||||
}
|
||||
let mut glass = sphere(white());
|
||||
glass.meshes[0].transmission = Some(clear_glass());
|
||||
let options = JobOptions { samples: 1, max_depth: 3, ..Default::default() };
|
||||
let mut task = Task::new(context.clone(), glass, request(2), options, CancelToken::default()).unwrap();
|
||||
assert!(!task.opaque_scene && !task.has_textures);
|
||||
task.complete_sample().unwrap();
|
||||
let pixels = task.read_linear().unwrap();
|
||||
assert_eq!(pixels.len(), 4);
|
||||
assert!(pixels.iter().flatten().all(|value| value.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_scene_specialization_preserves_body_face_and_volume_media() {
|
||||
let mut variants = std::collections::BTreeSet::new();
|
||||
for opaque in [false, true] {
|
||||
for maps in [false, true] {
|
||||
for nested in [false, true] {
|
||||
let slot = scene_pipeline_variant(opaque, maps, nested);
|
||||
assert_eq!(slot.is_none(), opaque && !maps, "only opaque/no-map uses the eager base");
|
||||
assert!(slot.is_none_or(|index| index < SCENE_PIPELINE_CACHE_SLOTS));
|
||||
variants.insert(slot);
|
||||
}
|
||||
if opaque {
|
||||
assert_eq!(scene_pipeline_variant(opaque, maps, false), scene_pipeline_variant(opaque, maps, true));
|
||||
} else {
|
||||
assert_ne!(
|
||||
scene_pipeline_variant(opaque, maps, false),
|
||||
scene_pipeline_variant(opaque, maps, true),
|
||||
"single and nested medium stacks require distinct programs"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(variants.len(), SCENE_PIPELINE_CACHE_SLOTS + 1);
|
||||
let mut scene = sphere(white());
|
||||
let options = JobOptions::default();
|
||||
assert!(use_opaque_pipeline(&scene, options));
|
||||
scene.meshes[0].transmission = Some(clear_glass());
|
||||
assert!(!use_opaque_pipeline(&scene, options));
|
||||
scene.meshes[0].transmission = None;
|
||||
scene.meshes[0].faces.push(solidcraft_render::FaceAppearance {
|
||||
face: 0,
|
||||
color: [255; 3],
|
||||
opacity: 1.0,
|
||||
surface: Some(white()),
|
||||
transmission: Some(clear_glass()),
|
||||
});
|
||||
assert!(!use_opaque_pipeline(&scene, options));
|
||||
scene.meshes[0].faces[0].transmission = None;
|
||||
assert!(use_opaque_pipeline(&scene, options));
|
||||
scene.meshes[0].faces[0].opacity = 0.5;
|
||||
assert!(!use_opaque_pipeline(&scene, options));
|
||||
scene.meshes[0].faces.clear();
|
||||
assert!(!use_opaque_pipeline(&scene, JobOptions { volume: Some(Default::default()), ..options }));
|
||||
assert!(use_opaque_pipeline(
|
||||
&scene,
|
||||
JobOptions { volume: Some(solidcraft_render::job::volume::HomogeneousVolume { extinction_per_mm: 0.0, ..Default::default() }), ..options }
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn gpu_planar_color_and_normal_maps_match_cpu_sampling_oracle() {
|
||||
use solidcraft_render::job::textures::{PlanarFrame, sample_map};
|
||||
let Some(executor) = executor() else { return };
|
||||
@@ -1914,7 +2009,7 @@ fn gpu_planar_color_and_normal_maps_match_cpu_sampling_oracle() {
|
||||
layout: Some(&layout),
|
||||
module: &shader,
|
||||
entry_point: Some("texture_reference"),
|
||||
compilation_options: Default::default(),
|
||||
compilation_options: wgpu::PipelineCompilationOptions { constants: &[("HAS_TEXTURES", 1.0)], ..Default::default() },
|
||||
cache: None,
|
||||
});
|
||||
for handed in [1., -1.] {
|
||||
@@ -2928,6 +3023,517 @@ fn identical_maps_pack_once_for_multiple_gpu_bodies() {
|
||||
assert_ne!(a, b, "bindings keep separate coordinate records");
|
||||
assert_eq!(word(a + 3), word(b + 3), "one color payload");
|
||||
assert_eq!(word(a + 12), word(b + 12), "one normal payload");
|
||||
assert_eq!(textures::packed_bytes(&input, 2), bytes.len() as u64, "admission does not charge unused mip tails or duplicate legacy maps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_legacy_and_portable_gpu_maps_keep_distinct_policies_in_both_orders() {
|
||||
use image::ImageEncoder;
|
||||
use solidcraft_render::{
|
||||
job::textures::{PlanarFrame, RuntimeTextures, TextureMap},
|
||||
texture_input::MapRole,
|
||||
};
|
||||
let mut encoded = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut encoded).write_image(&[128, 64, 32, 255].repeat(15), 5, 3, image::ExtendedColorType::Rgba8).unwrap();
|
||||
let legacy = TextureMap::new(encoded.clone(), MapRole::BaseColor).unwrap();
|
||||
let portable = TextureMap::new_portable(encoded, MapRole::BaseColor).unwrap();
|
||||
let original = texture_fixture(PlanarFrame { origin: [0.; 3], u: [1., 0., 0.], v: [0., 1., 0.] });
|
||||
for maps in [[legacy.clone(), portable.clone()], [portable.clone(), legacy.clone()]] {
|
||||
let bindings = maps
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, map)| {
|
||||
let mut binding = original.bindings()[0].clone();
|
||||
binding.body_index = index;
|
||||
binding.body_name = format!("Policy {index}");
|
||||
binding.base_color = Some(map.clone());
|
||||
binding.normal = None;
|
||||
binding
|
||||
})
|
||||
.collect();
|
||||
let input = RuntimeTextures::new(bindings).unwrap();
|
||||
let bytes = textures::flatten(Some(&input), 2, Vec3::ZERO, &CancelToken::default()).unwrap();
|
||||
let word = |i: usize| u32::from_le_bytes(bytes[i * 4..i * 4 + 4].try_into().unwrap()) as usize;
|
||||
let descriptors = [word(word(5) + 3), word(word(6) + 3)];
|
||||
assert_ne!(descriptors[0], descriptors[1], "same bytes and role retain separate filtering policies");
|
||||
// Header + two body records, then exactly one legacy base and one 5x3 -> 2x1 -> 1x1 chain.
|
||||
assert_eq!(bytes.len(), 8 * 4 + 2 * 24 * 4 + (8 * 4 + 15 * 16) + (16 * 4 + 18 * 16));
|
||||
assert_eq!(textures::packed_bytes(&input, 2), bytes.len() as u64);
|
||||
for (descriptor, map) in descriptors.into_iter().zip(maps) {
|
||||
assert_eq!(word(descriptor), if map.gpu_mip_filtering() { 3 } else { 1 });
|
||||
assert_eq!(word(descriptor + 2), usize::from(map.gpu_mip_filtering()));
|
||||
let base = word(descriptor + 4) * 4;
|
||||
let expected: Vec<_> = map.decode(&CancelToken::default()).unwrap().iter().flat_map(|value| value.to_le_bytes()).collect();
|
||||
assert_eq!(&bytes[base..base + expected.len()], expected, "level-zero encoded-role interpretation stays exact");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_gpu_mips_preserve_odd_size_linear_color_and_data_means() {
|
||||
use image::ImageEncoder;
|
||||
use solidcraft_render::{
|
||||
job::textures::{PlanarFrame, RuntimeTextures, TextureMap},
|
||||
texture_input::MapRole,
|
||||
};
|
||||
let mut encoded = Vec::new();
|
||||
let pixels: Vec<u8> = (0..15).flat_map(|i| [if i & 1 == 0 { 64 } else { 192 }, 128, 255, 0]).collect();
|
||||
image::codecs::png::PngEncoder::new(&mut encoded).write_image(&pixels, 5, 3, image::ExtendedColorType::Rgba8).unwrap();
|
||||
let original = texture_fixture(PlanarFrame { origin: [0.; 3], u: [1., 0., 0.], v: [0., 1., 0.] });
|
||||
let mut binding = original.bindings()[0].clone();
|
||||
binding.base_color = Some(TextureMap::new_portable(encoded.clone(), MapRole::BaseColor).unwrap());
|
||||
binding.normal = None;
|
||||
binding.roughness = Some(TextureMap::new_portable(encoded, MapRole::Roughness).unwrap());
|
||||
let input = RuntimeTextures::new(vec![binding]).unwrap();
|
||||
let bytes = textures::flatten(Some(&input), 1, Vec3::ZERO, &CancelToken::default()).unwrap();
|
||||
let word = |i: usize| u32::from_le_bytes(bytes[i * 4..i * 4 + 4].try_into().unwrap()) as usize;
|
||||
let record = word(5);
|
||||
let color = word(record + 3);
|
||||
let data = word(record + 16);
|
||||
assert_ne!(color, data, "the same encoded source retains distinct role interpretation");
|
||||
for (descriptor, linear) in [(color, true), (data, false)] {
|
||||
assert_eq!(word(descriptor), 3, "5x3 -> 2x1 -> 1x1");
|
||||
let last = descriptor + 4 + 2 * 4;
|
||||
assert_eq!((word(last + 1), word(last + 2)), (1, 1));
|
||||
let expected = (0..15)
|
||||
.map(|i| {
|
||||
let value = if i & 1 == 0 { 64 } else { 192 };
|
||||
if linear { solidcraft_render::surface::srgb8_to_linear(value) } else { f64::from(value) / 255. }
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ 15.;
|
||||
let actual = f32::from_le_bytes(bytes[word(last) * 4..word(last) * 4 + 4].try_into().unwrap());
|
||||
assert!((f64::from(actual) - expected).abs() < 1e-7, "{actual} vs {expected}");
|
||||
}
|
||||
assert!((bytes.capacity() as u64) < input.working_bytes(), "the admitted preparation includes the full packed chain capacity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_trilinear_minification_matches_singular_value_lod_and_repeat_oracle() {
|
||||
use solidcraft_render::{
|
||||
job::textures::PlanarFrame,
|
||||
texture_lod::select_isotropic_lod,
|
||||
texture_sampling::{
|
||||
TextureView,
|
||||
mips::{MAX_MIP_TAIL_BYTES, MipChain},
|
||||
},
|
||||
};
|
||||
let Some(executor) = executor() else { return };
|
||||
let context = &executor.context;
|
||||
let shader = context.device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
|
||||
let layout = context.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &[Some(&context.layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = context.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
module: &shader,
|
||||
entry_point: Some("texture_mip_reference"),
|
||||
compilation_options: wgpu::PipelineCompilationOptions { constants: &[("HAS_TEXTURES", 1.0)], ..Default::default() },
|
||||
cache: None,
|
||||
});
|
||||
let frame = PlanarFrame { origin: [0.; 3], u: [2., 0., 0.], v: [0., 2., 0.] };
|
||||
let source = texture_fixture_filtering(frame, true);
|
||||
let pixels = source.bindings()[0].base_color.as_ref().unwrap().decode(&CancelToken::default()).unwrap();
|
||||
let base = TextureView::new(&pixels, [2, 2]).unwrap();
|
||||
let chain = MipChain::prepare(base, MAX_MIP_TAIL_BYTES, || false).unwrap();
|
||||
for portable in [true, false] {
|
||||
let source = texture_fixture_filtering(frame, portable);
|
||||
for (point, dx, dy) in [
|
||||
(Vec3::new(0.5, 0.5, 0.), [0., 0.], [0., 0.]),
|
||||
(Vec3::new(-0.01, 0.125, 0.), [0.75, 0.], [0., 0.25]),
|
||||
(Vec3::new(0.01, 0.125, 0.), [0.75, 0.], [0., 0.25]),
|
||||
(Vec3::new(-0.25, 1.125, 0.), [0.4, -0.5], [0.5, 0.25]),
|
||||
(Vec3::new(0., 0., 0.), [4., 0.], [0., 4.]),
|
||||
// Legacy sampling must bypass LOD arithmetic even when a finite gradient
|
||||
// would overflow its texel-space multiplication. This is not a portable-footprint case.
|
||||
(Vec3::new(0.5, 0.5, 0.), [3e38, 0.], [0., 3e38]),
|
||||
] {
|
||||
let extreme = dx[0] > 1e30;
|
||||
if portable && extreme {
|
||||
continue;
|
||||
}
|
||||
let mut input = request(2);
|
||||
input.textures = Some(source.clone());
|
||||
let mut task = Task::new(context.clone(), sphere(white()), input, JobOptions::default(), CancelToken::default()).unwrap();
|
||||
let mut params = vec![0; 32];
|
||||
scene::vector(&mut params, point, 0.);
|
||||
scene::vector(&mut params, Vec3::new(dx[0], dx[1], 0.), 0.);
|
||||
scene::vector(&mut params, Vec3::new(dy[0], dy[1], 0.), 0.);
|
||||
scene::vector(&mut params, Vec3::Z, 0.);
|
||||
context.queue.write_buffer(&task.uniform, 0, ¶ms);
|
||||
let mut encoder = context.device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &task.bind, &[]);
|
||||
pass.dispatch_workgroups(1, 1, 1);
|
||||
}
|
||||
context.queue.submit([encoder.finish()]);
|
||||
task.sample = 1;
|
||||
let actual = task.read_linear().unwrap();
|
||||
let lod = if portable { select_isotropic_lod([2, 2], dx, dy).unwrap().lod } else { 0. };
|
||||
let expected = if portable {
|
||||
chain.sample_repeat_lod_opaque(frame.uv(point).unwrap(), lod).unwrap()
|
||||
} else {
|
||||
base.sample_repeat_opaque(frame.uv(point).unwrap()).unwrap()
|
||||
};
|
||||
assert!((f64::from(actual[1][0]) - lod).abs() < 1e-6, "GPU LOD {actual:?} vs {lod}");
|
||||
for (a, b) in actual[0].iter().zip(expected) {
|
||||
assert!((f64::from(*a) - b).abs() < 2e-6, "GPU portable={portable} {actual:?} vs {expected:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_scalar_faces_preserve_legacy_roles_and_suppress_only_portable_roles() {
|
||||
use image::ImageEncoder;
|
||||
use solidcraft_render::{
|
||||
FaceAppearance,
|
||||
job::textures::{PlanarFrame, RuntimeTextures, TextureMap, sample_map},
|
||||
texture_input::MapRole,
|
||||
};
|
||||
let Some(executor) = executor() else { return };
|
||||
let context = &executor.context;
|
||||
let shader = context.device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
|
||||
let layout = context.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &[Some(&context.layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = |entry_point| {
|
||||
context.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
module: &shader,
|
||||
entry_point: Some(entry_point),
|
||||
compilation_options: wgpu::PipelineCompilationOptions { constants: &[("HAS_TEXTURES", 1.0)], ..Default::default() },
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
let color_normal_pipeline = pipeline("texture_reference");
|
||||
let scalar_pipeline = pipeline("texture_scalar_reference");
|
||||
let frame = PlanarFrame { origin: [0.; 3], u: [2., 0., 0.], v: [0., 2., 0.] };
|
||||
let point = Vec3::new(-0.25, 1.125, 0.);
|
||||
let uv = frame.uv(point).unwrap();
|
||||
let scalar = |value, role, portable| {
|
||||
let mut bytes = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut bytes).write_image(&[value, value, value, 255], 1, 1, image::ExtendedColorType::Rgba8).unwrap();
|
||||
if portable { TextureMap::new_portable(bytes, role).unwrap() } else { TextureMap::new(bytes, role).unwrap() }
|
||||
};
|
||||
for color_portable in [false, true] {
|
||||
for normal_portable in [false, true] {
|
||||
let fixture = texture_fixture_filtering(frame, color_portable);
|
||||
let normal_fixture = texture_fixture_filtering(frame, normal_portable);
|
||||
let mut binding = fixture.bindings()[0].clone();
|
||||
binding.normal = normal_fixture.bindings()[0].normal.clone();
|
||||
binding.roughness = Some(scalar(192, MapRole::Roughness, color_portable));
|
||||
binding.metallic = Some(scalar(64, MapRole::Metallic, normal_portable));
|
||||
let color = sample_map(&binding.base_color.as_ref().unwrap().decode(&CancelToken::default()).unwrap(), [2, 2], uv).unwrap();
|
||||
let normal = sample_map(&binding.normal.as_ref().unwrap().decode(&CancelToken::default()).unwrap(), [2, 2], uv).unwrap();
|
||||
let mut input = request(2);
|
||||
input.textures = Some(Arc::new(RuntimeTextures::new(vec![binding]).unwrap()));
|
||||
let face_color = Vec3::new(0.2, 0.4, 0.6);
|
||||
let face = OpaqueSurface::new(face_color, 0.3, 0.2, 1.7).unwrap();
|
||||
let mut scene = sphere(white());
|
||||
scene.meshes[0].faces.push(FaceAppearance { face: 0, color: [255; 3], opacity: 1., surface: Some(face), transmission: None });
|
||||
let mut task = Task::new(context.clone(), scene, input, JobOptions::default(), CancelToken::default()).unwrap();
|
||||
let mut params = vec![0; 32];
|
||||
scene::vector(&mut params, point, 0.);
|
||||
scene::vector(&mut params, Vec3::ZERO, 0.);
|
||||
scene::vector(&mut params, Vec3::Z, 0.);
|
||||
scene::vector(&mut params, Vec3::Z, 0.);
|
||||
context.queue.write_buffer(&task.uniform, 0, ¶ms);
|
||||
let run = |task: &mut Task, pipeline: &wgpu::ComputePipeline| {
|
||||
let mut encoder = context.device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, &task.bind, &[]);
|
||||
pass.dispatch_workgroups(1, 1, 1);
|
||||
}
|
||||
context.queue.submit([encoder.finish()]);
|
||||
task.sample = 1;
|
||||
task.read_linear().unwrap()
|
||||
};
|
||||
let actual = run(&mut task, &color_normal_pipeline);
|
||||
let expected = [face_color.x, face_color.y, face_color.z];
|
||||
for (channel, value) in expected.into_iter().enumerate() {
|
||||
let expected = if color_portable { value } else { value * color[channel] };
|
||||
assert!((f64::from(actual[0][channel]) - expected).abs() < 2e-6, "mixed face color {color_portable}/{normal_portable}: {actual:?}");
|
||||
}
|
||||
let expected = if normal_portable { Vec3::Z } else { frame.mapped_normal(Vec3::Z, Vec3::Z, normal, 0.75).unwrap() };
|
||||
for (actual, expected) in actual[1].iter().zip([expected.x + 2., expected.y + 2., expected.z + 2.]) {
|
||||
assert!((f64::from(*actual) - expected).abs() < 3e-6, "mixed face normal {color_portable}/{normal_portable}");
|
||||
}
|
||||
let actual = run(&mut task, &scalar_pipeline);
|
||||
let expected = [if normal_portable { 0.3 } else { 64. / 255. }, if color_portable { 0.2 } else { 192. / 255. }, 1.7];
|
||||
for (actual, expected) in actual[0].iter().zip(expected) {
|
||||
assert!((f64::from(*actual) - expected).abs() < 2e-6, "mixed face scalars {color_portable}/{normal_portable}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_wood_minification_converges_to_mean_and_preserves_scalar_face_override() {
|
||||
use image::ImageEncoder;
|
||||
use solidcraft_render::{
|
||||
FaceAppearance,
|
||||
job::textures::{BaseColorMode, PlanarFrame, RuntimeTextures, TextureBinding, TextureMap},
|
||||
texture_input::MapRole,
|
||||
};
|
||||
let Some(executor) = executor() else { return };
|
||||
// Reuse the established closed slab fixture as an opaque wooden plank.
|
||||
let mut scene = glass_slab(false);
|
||||
scene.meshes[0].name = "wood diagnostic".into();
|
||||
scene.meshes[0].surface = Some(white());
|
||||
scene.meshes[0].transmission = None;
|
||||
let mesh = Arc::make_mut(&mut scene.meshes[0].mesh);
|
||||
for position in &mut mesh.positions {
|
||||
position.z -= 0.5;
|
||||
}
|
||||
mesh.tri_face = (0..mesh.triangles.len() as u32).collect();
|
||||
scene.bounds = mesh.bounds();
|
||||
let pixels: Vec<u8> = (0..64)
|
||||
.flat_map(|i| {
|
||||
let v = if i & 1 == 0 { 0 } else { 255 };
|
||||
[v, v, v, 255]
|
||||
})
|
||||
.collect();
|
||||
let mut encoded = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut encoded).write_image(&pixels, 8, 8, image::ExtendedColorType::Rgba8).unwrap();
|
||||
let map = TextureMap::new_portable(encoded, MapRole::BaseColor).unwrap();
|
||||
let binding = |repeat| TextureBinding {
|
||||
body_index: 0,
|
||||
body_name: "wood diagnostic".into(),
|
||||
frame: PlanarFrame { origin: [0.; 3], u: [repeat, 0., 0.], v: [0., repeat, 0.] },
|
||||
normal_strength: 1.,
|
||||
base_color: Some(map.clone()),
|
||||
normal: None,
|
||||
roughness: None,
|
||||
metallic: None,
|
||||
base_color_mode: BaseColorMode::Replace,
|
||||
flip_v: false,
|
||||
};
|
||||
let render = |scene: ProductScene, source: Option<TextureBinding>, fov: f64, pitch: f64, lens: bool| {
|
||||
let mut image = request(16);
|
||||
image.camera.fov = fov;
|
||||
image.camera.pitch = pitch;
|
||||
image.textures = source.map(|b| Arc::new(RuntimeTextures::new(vec![b]).unwrap()));
|
||||
let options = JobOptions {
|
||||
samples: 1,
|
||||
max_depth: 1,
|
||||
lens: lens.then_some(solidcraft_render::job::ThinLens { aperture_radius_mm: 0.15, focus_distance_mm: 4.0 }),
|
||||
..Default::default()
|
||||
};
|
||||
let mut task = Task::new(executor.context.clone(), scene, image, options, CancelToken::default()).unwrap();
|
||||
task.complete_sample().unwrap();
|
||||
let pixels = task.read_linear().unwrap();
|
||||
assert!(pixels.iter().all(|pixel| pixel[3] == 1.0), "the plank must fill every output pixel");
|
||||
pixels
|
||||
};
|
||||
let mut flat = scene.clone();
|
||||
flat.meshes[0].surface = Some(OpaqueSurface::new(Vec3::new(0.5, 0.5, 0.5), 0., 1., 1.).unwrap());
|
||||
for (fov, pitch, lens) in [(0., std::f64::consts::FRAC_PI_2, false), (0.7, 0.65, false), (0.7, 0.65, true)] {
|
||||
let expected = render(flat.clone(), None, fov, pitch, lens);
|
||||
let minified = render(scene.clone(), Some(binding(0.25)), fov, pitch, lens);
|
||||
for (pixel, (actual, expected)) in minified.iter().zip(&expected).enumerate() {
|
||||
for (a, b) in actual.iter().take(3).zip(expected) {
|
||||
assert!((a - b).abs() < 2e-5, "pixel={pixel}, fov={fov}, pitch={pitch},lens={lens}: minified {actual:?} vs {expected:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let close = render(scene.clone(), Some(binding(4.)), 0., std::f64::consts::FRAC_PI_2, false);
|
||||
let spread = close.iter().map(|p| p[0]).fold(f32::NEG_INFINITY, f32::max) - close.iter().map(|p| p[0]).fold(f32::INFINITY, f32::min);
|
||||
assert!(spread > 0.1, "magnified grain must retain contrast: {spread}");
|
||||
let mut overridden = scene;
|
||||
overridden.meshes[0].faces = vec![FaceAppearance {
|
||||
face: 0,
|
||||
color: [64, 128, 192],
|
||||
opacity: 1.,
|
||||
surface: Some(OpaqueSurface::new(Vec3::new(0.1, 0.2, 0.3), 0., 1., 1.).unwrap()),
|
||||
transmission: None,
|
||||
}];
|
||||
let scalar = render(overridden.clone(), None, 0., std::f64::consts::FRAC_PI_2, false);
|
||||
let mapped = render(overridden, Some(binding(0.25)), 0., std::f64::consts::FRAC_PI_2, false);
|
||||
let explicit: Vec<_> = scalar.iter().zip(&mapped).filter(|(a, _)| a[2] > a[0] * 2.).collect();
|
||||
assert!(explicit.len() > 32, "the diagnostic must cover explicit face pixels");
|
||||
for (a, b) in explicit {
|
||||
assert_eq!(a, b, "a scalar face suppresses inherited maps");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_mapped_wood_preserves_index_matched_sheet_and_solid_transport() {
|
||||
use image::ImageEncoder;
|
||||
use solidcraft_render::{
|
||||
job::textures::{BaseColorMode, PlanarFrame, RuntimeTextures, TextureBinding, TextureMap},
|
||||
texture_input::MapRole,
|
||||
};
|
||||
let Some(executor) = executor() else { return };
|
||||
let mut board = glass_slab(false).meshes.remove(0);
|
||||
board.name = "mixed wood board".into();
|
||||
board.surface = Some(white());
|
||||
board.transmission = None;
|
||||
for position in &mut Arc::make_mut(&mut board.mesh).positions {
|
||||
position.z -= 0.5;
|
||||
}
|
||||
let pixels: Vec<u8> = (0..64)
|
||||
.flat_map(|i| {
|
||||
let c = if i & 1 == 0 { 0 } else { 255 };
|
||||
[c, c, c, 255]
|
||||
})
|
||||
.collect();
|
||||
let mut encoded = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut encoded).write_image(&pixels, 8, 8, image::ExtendedColorType::Rgba8).unwrap();
|
||||
let resources = Arc::new(
|
||||
RuntimeTextures::new(vec![TextureBinding {
|
||||
body_index: 0,
|
||||
body_name: board.name.clone(),
|
||||
frame: PlanarFrame { origin: [0.; 3], u: [0.25, 0., 0.], v: [0., 0.25, 0.] },
|
||||
normal_strength: 1.,
|
||||
base_color: Some(TextureMap::new_portable(encoded, MapRole::BaseColor).unwrap()),
|
||||
normal: None,
|
||||
roughness: None,
|
||||
metallic: None,
|
||||
base_color_mode: BaseColorMode::Replace,
|
||||
flip_v: false,
|
||||
}])
|
||||
.unwrap(),
|
||||
);
|
||||
// At IOR1, zero extinction and no scatter, either glass boundary is the
|
||||
// exact identity. The mean map reflectance .5 receives three fixed lights.
|
||||
let expected = 0.5 * (3.0 * 0.8 + 1.0 * 0.6 + 1.5 * 0.8) / std::f64::consts::PI;
|
||||
for mapped in [false, true] {
|
||||
for thin in [true, false] {
|
||||
let mut glass = glass_slab(thin).meshes.remove(0);
|
||||
glass.name = "index matched glazing".into();
|
||||
for position in &mut Arc::make_mut(&mut glass.mesh).positions {
|
||||
position.z += 1.0;
|
||||
}
|
||||
glass.surface = Some(OpaqueSurface::new(Vec3::ZERO, 0., 1., 1.).unwrap());
|
||||
glass.transmission =
|
||||
Some(solidcraft_render::transmission::Transmission::new(Vec3::ZERO, thin.then_some(0.)).unwrap().with_shadow_transmission(true));
|
||||
let mut scene = ProductScene { bounds: board.mesh.bounds().union(&glass.mesh.bounds()), meshes: vec![board.clone(), glass] };
|
||||
if !mapped {
|
||||
scene.meshes[0].surface = Some(OpaqueSurface::new(Vec3::new(0.5, 0.5, 0.5), 0., 1., 1.).unwrap());
|
||||
}
|
||||
let mut image = request(16);
|
||||
image.textures = mapped.then(|| resources.clone());
|
||||
let mut task = Task::new(
|
||||
executor.context.clone(),
|
||||
scene,
|
||||
image,
|
||||
JobOptions { samples: 1, max_depth: if thin { 2 } else { 3 }, ..Default::default() },
|
||||
CancelToken::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!task.opaque_scene);
|
||||
assert_eq!(task.has_textures, mapped);
|
||||
task.complete_sample().unwrap_or_else(|error| panic!("thin={thin},mapped={mapped}: {error:?}"));
|
||||
for (pixel, value) in task.read_linear().unwrap_or_else(|error| panic!("thin={thin},mapped={mapped}: {error:?}")).iter().enumerate() {
|
||||
assert_eq!(value[3], 1.0, "thin={thin},mapped={mapped},pixel={pixel}");
|
||||
for actual in value.iter().take(3) {
|
||||
assert!((f64::from(*actual) - expected).abs() < 2e-5, "thin={thin},mapped={mapped},pixel={pixel}: {value:?} vs {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_mapped_wood_keeps_fine_detail_behind_large_index_matched_solid_glass() {
|
||||
use image::ImageEncoder;
|
||||
use solidcraft_render::{
|
||||
job::textures::{BaseColorMode, PlanarFrame, RuntimeTextures, TextureBinding, TextureMap},
|
||||
texture_input::MapRole,
|
||||
};
|
||||
let Some(executor) = executor() else { return };
|
||||
let mut board = glass_slab(false).meshes.remove(0);
|
||||
board.name = "fine wood diagnostic".into();
|
||||
board.surface = Some(white());
|
||||
board.transmission = None;
|
||||
for position in &mut Arc::make_mut(&mut board.mesh).positions {
|
||||
position.z -= 2.0;
|
||||
}
|
||||
let mut glass = glass_slab(false).meshes.remove(0);
|
||||
glass.name = "large index matched glazing".into();
|
||||
for position in &mut Arc::make_mut(&mut glass.mesh).positions {
|
||||
position.x *= 50.0;
|
||||
position.y *= 50.0;
|
||||
}
|
||||
glass.surface = Some(OpaqueSurface::new(Vec3::ZERO, 0., 1., 1.).unwrap());
|
||||
glass.transmission = Some(solidcraft_render::transmission::Transmission::new(Vec3::ZERO, None).unwrap().with_shadow_transmission(true));
|
||||
// Each 2x2-texel block has a flat bilinear centre. The two camera patches
|
||||
// below see adjacent white/black details, not the texture's averaged mean.
|
||||
let pixels: Vec<u8> = (0..16)
|
||||
.flat_map(|y| {
|
||||
(0..16).flat_map(move |x| {
|
||||
let value = if (x / 2 + y / 2) & 1 == 0 { 255 } else { 0 };
|
||||
[value, value, value, 255]
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut encoded = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut encoded).write_image(&pixels, 16, 16, image::ExtendedColorType::Rgba8).unwrap();
|
||||
let resources = Arc::new(
|
||||
RuntimeTextures::new(vec![TextureBinding {
|
||||
body_index: 0,
|
||||
body_name: board.name.clone(),
|
||||
frame: PlanarFrame { origin: [-0.125, -0.125, 0.], u: [2., 0., 0.], v: [0., 2., 0.] },
|
||||
normal_strength: 1.,
|
||||
base_color: Some(TextureMap::new_portable(encoded, MapRole::BaseColor).unwrap()),
|
||||
normal: None,
|
||||
roughness: None,
|
||||
metallic: None,
|
||||
base_color_mode: BaseColorMode::Replace,
|
||||
flip_v: false,
|
||||
}])
|
||||
.unwrap(),
|
||||
);
|
||||
let render = |with_glass, centre_x| {
|
||||
let mut scene = ProductScene { bounds: board.mesh.bounds(), meshes: vec![board.clone()] };
|
||||
if with_glass {
|
||||
scene.bounds = scene.bounds.union(&glass.mesh.bounds());
|
||||
scene.meshes.push(glass.clone());
|
||||
}
|
||||
let mut image = request(32);
|
||||
image.camera.target = Vec3::new(centre_x, 0., -1.5);
|
||||
image.camera.fov = 0.02;
|
||||
image.textures = Some(resources.clone());
|
||||
let mut task = Task::new(
|
||||
executor.context.clone(),
|
||||
scene,
|
||||
image,
|
||||
JobOptions { samples: 1, max_depth: if with_glass { 3 } else { 1 }, seed: 1599, ..Default::default() },
|
||||
CancelToken::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(task.opaque_scene, !with_glass);
|
||||
task.complete_sample().unwrap();
|
||||
task.read_linear().unwrap()
|
||||
};
|
||||
// The camera-to-board path is about 4mm, including a 1mm glass/wood gap.
|
||||
// Closed-glass hit origins split a +/-500mm vertex anchor from the full
|
||||
// barycentric displacement. Ignoring that low component invents roughly
|
||||
// 1400mm of travel and selects coarse checker mips instead of these details.
|
||||
let unit_reflectance = (3.0 * 0.8 + 1.0 * 0.6 + 1.5 * 0.8) / std::f64::consts::PI;
|
||||
for (centre_x, reflectance) in [(0., 1.), (0.25, 0.)] {
|
||||
let direct = render(false, centre_x);
|
||||
let through_glass = render(true, centre_x);
|
||||
for (pixel, (actual, expected)) in through_glass.iter().zip(&direct).enumerate() {
|
||||
assert_eq!(expected[3], 1.0, "direct patch {centre_x}, pixel={pixel}");
|
||||
assert_eq!(actual[3], 1.0, "glazed patch {centre_x}, pixel={pixel}");
|
||||
for (actual, expected) in actual.iter().take(3).zip(expected) {
|
||||
assert!((f64::from(*expected) - reflectance * unit_reflectance).abs() < 2e-5, "direct patch {centre_x}, pixel={pixel}: {expected}");
|
||||
assert!((actual - expected).abs() < 2e-5, "glazed patch {centre_x}, pixel={pixel}: {actual} vs {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2963,7 +3569,7 @@ fn gpu_scalar_maps_match_linear_reference_without_touching_ior() {
|
||||
layout: Some(&layout),
|
||||
module: &shader,
|
||||
entry_point: Some("texture_scalar_reference"),
|
||||
compilation_options: Default::default(),
|
||||
compilation_options: wgpu::PipelineCompilationOptions { constants: &[("HAS_TEXTURES", 1.0)], ..Default::default() },
|
||||
cache: None,
|
||||
});
|
||||
let mut input = request(2);
|
||||
@@ -3281,6 +3887,13 @@ fn engine_exports_raw_master_from_the_exact_completed_job_without_png_conversion
|
||||
drop(handle);
|
||||
assert!(host.admitted_bytes() > 0);
|
||||
drop(frame);
|
||||
// Completion notification precedes worker-owned resource destruction.
|
||||
// Require the actual reservation to drain after the final frame lease drops.
|
||||
let released_by = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while host.admitted_bytes() != 0 {
|
||||
assert!(std::time::Instant::now() < released_by, "worker did not release its exported frame reservation");
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
//! One read-only storage buffer: body lookup, planar records and linear RGBA pixels.
|
||||
//! One read-only storage buffer: body lookup, planar records and linear RGBA mip chains.
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_render::job::{
|
||||
CancelToken, ErrorCode, JobError, Result,
|
||||
textures::{BaseColorMode, RuntimeTextures, TextureMap},
|
||||
};
|
||||
use solidcraft_render::texture_sampling::{
|
||||
TextureView,
|
||||
mips::{MAX_MIP_TAIL_BYTES, MipChain},
|
||||
};
|
||||
|
||||
fn vector(a: [f64; 3]) -> Vec3 {
|
||||
Vec3::new(a[0], a[1], a[2])
|
||||
@@ -30,12 +34,44 @@ fn pixels(
|
||||
return Ok(*record);
|
||||
}
|
||||
let rgba = map.decode(cancel)?;
|
||||
let view = TextureView::new(&rgba, map.dimensions()).map_err(|error| JobError::new(ErrorCode::InvalidRequest, error.to_string()))?;
|
||||
// Decode roles before averaging: reflectance is linear sRGB; scalar and
|
||||
// tangent-normal channels remain linear data. Alpha never participates.
|
||||
// Existing temporary and saved built-in maps keep their level-zero sampler.
|
||||
// Do not generate or retain an unused tail for those legacy inputs.
|
||||
let mips = if map.gpu_mip_filtering() {
|
||||
Some(MipChain::prepare(view, MAX_MIP_TAIL_BYTES, || cancel.is_cancelled()).map_err(|error| {
|
||||
JobError::new(if cancel.is_cancelled() { ErrorCode::Cancelled } else { ErrorCode::InvalidRequest }, error.to_string())
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let level_count = mips.as_ref().map_or(1, MipChain::level_count);
|
||||
let offset = bytes.len() / 4;
|
||||
for (index, value) in rgba.into_iter().enumerate() {
|
||||
if index % 4096 == 0 {
|
||||
cancel.check()?;
|
||||
let descriptor_words = 4 + level_count * 4;
|
||||
bytes.resize(bytes.len() + descriptor_words * 4, 0);
|
||||
write_word(bytes, offset, level_count as u32)?;
|
||||
write_word(bytes, offset + 1, 2)?;
|
||||
write_word(bytes, offset + 2, u32::from(map.gpu_mip_filtering()))?;
|
||||
for level in 0..level_count {
|
||||
let (pixels, dimensions) = match &mips {
|
||||
Some(mips) => (
|
||||
mips.level(level).ok_or_else(|| JobError::new(ErrorCode::Internal, "missing prepared GPU mip pixels"))?,
|
||||
mips.dimensions(level).ok_or_else(|| JobError::new(ErrorCode::Internal, "missing prepared GPU mip dimensions"))?,
|
||||
),
|
||||
None => (view, map.dimensions()),
|
||||
};
|
||||
let descriptor = offset + 4 + level * 4;
|
||||
let pixel_offset = u32::try_from(bytes.len() / 4).map_err(|_| JobError::new(ErrorCode::LimitExceeded, "GPU mip buffer offset overflow"))?;
|
||||
write_word(bytes, descriptor, pixel_offset)?;
|
||||
write_word(bytes, descriptor + 1, dimensions[0])?;
|
||||
write_word(bytes, descriptor + 2, dimensions[1])?;
|
||||
for (index, value) in pixels.rgba().iter().enumerate() {
|
||||
if index % 4096 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
let record = (u32::try_from(offset).map_err(|_| JobError::new(ErrorCode::LimitExceeded, "texture buffer offset overflow"))?, map.dimensions());
|
||||
shared.insert(map.identity(), record);
|
||||
@@ -45,9 +81,11 @@ pub(super) fn flatten(resources: Option<&RuntimeTextures>, body_count: usize, or
|
||||
let Some(resources) = resources else {
|
||||
return Ok(vec![0; 16]);
|
||||
};
|
||||
let header = 4 + body_count + 1;
|
||||
let header = (4 + body_count + 1).next_multiple_of(4);
|
||||
let record_words = resources.bindings().len() * 24;
|
||||
let mut bytes = super::scene::reserved((header + record_words) * 4 + resources.summary().linear_pixel_bytes as usize)?;
|
||||
let capacity = usize::try_from(packed_bytes(resources, body_count))
|
||||
.map_err(|_| JobError::new(ErrorCode::LimitExceeded, "GPU mip buffer capacity overflow"))?;
|
||||
let mut bytes = super::scene::reserved(capacity)?;
|
||||
bytes.resize((header + record_words) * 4, 0);
|
||||
write_word(&mut bytes, 0, (body_count + 1) as u32)?;
|
||||
let mut shared = std::collections::BTreeMap::new();
|
||||
@@ -70,6 +108,15 @@ pub(super) fn flatten(resources: Option<&RuntimeTextures>, body_count: usize, or
|
||||
let (normal, normal_size) = pixels(&mut bytes, binding.normal.as_ref(), cancel, &mut shared)?;
|
||||
let (roughness, roughness_size) = pixels(&mut bytes, binding.roughness.as_ref(), cancel, &mut shared)?;
|
||||
let (metallic, metallic_size) = pixels(&mut bytes, binding.metallic.as_ref(), cancel, &mut shared)?;
|
||||
let roles = if metallic != 0 {
|
||||
4
|
||||
} else if roughness != 0 {
|
||||
3
|
||||
} else if normal != 0 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let flags = u32::from(binding.flip_v) | (u32::from(binding.base_color_mode == BaseColorMode::Replace) << 1);
|
||||
for (offset, value) in [
|
||||
(3, base),
|
||||
@@ -86,6 +133,7 @@ pub(super) fn flatten(resources: Option<&RuntimeTextures>, body_count: usize, or
|
||||
(20, metallic),
|
||||
(21, metallic_size[0]),
|
||||
(22, metallic_size[1]),
|
||||
(23, roles),
|
||||
] {
|
||||
write_word(&mut bytes, record + offset, value)?;
|
||||
}
|
||||
@@ -93,3 +141,31 @@ pub(super) fn flatten(resources: Option<&RuntimeTextures>, body_count: usize, or
|
||||
cancel.check()?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Exact packed storage admission for the admitted dimensions and filtering policy.
|
||||
/// Count each content/role/policy once, including only level zero for legacy maps.
|
||||
pub(super) fn packed_bytes(resources: &RuntimeTextures, body_count: usize) -> u64 {
|
||||
let bindings = resources.bindings().len() as u64;
|
||||
let mut bytes = 4 * ((5 + body_count as u64).next_multiple_of(4) + bindings * 24);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for binding in resources.bindings() {
|
||||
for map in [&binding.base_color, &binding.normal, &binding.roughness, &binding.metallic].into_iter().flatten() {
|
||||
if !seen.insert(map.identity()) {
|
||||
continue;
|
||||
}
|
||||
let [mut width, mut height] = map.dimensions().map(u64::from);
|
||||
let mut levels = 1;
|
||||
let mut pixels = width * height;
|
||||
if map.gpu_mip_filtering() {
|
||||
while width > 1 || height > 1 {
|
||||
width = (width / 2).max(1);
|
||||
height = (height / 2).max(1);
|
||||
pixels += width * height;
|
||||
levels += 1;
|
||||
}
|
||||
}
|
||||
bytes += (4 + levels * 4) * 4 + pixels * 16;
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
@@ -2,19 +2,24 @@
|
||||
fn texture_vector(word:u32)->vec3<f32> {
|
||||
return bitcast<vec3<f32>>(vec3<u32>(texture_words[word],texture_words[word+1u],texture_words[word+2u]));
|
||||
}
|
||||
fn texture_record(t:Triangle)->u32 {
|
||||
let body=u32(t.optics.w);
|
||||
fn texture_record(body_id:f32)->u32 {
|
||||
if !HAS_TEXTURES {return 0u;}
|
||||
let body=u32(body_id);
|
||||
if body>=texture_words[0] {return 0u;}
|
||||
return texture_words[4u+body];
|
||||
}
|
||||
fn texture_pixel(offset:u32,width:u32,x:u32,y:u32)->vec3<f32> {
|
||||
return texture_vector(offset+4u*(y*width+x));
|
||||
}
|
||||
fn texture_sample(offset:u32,size:vec2<u32>,uv:vec2<f32>)->vec3<f32> {
|
||||
fn texture_bilinear(offset:u32,size:vec2<u32>,uv:vec2<f32>)->vec3<f32> {
|
||||
let position=fract(uv)*vec2<f32>(size)-vec2<f32>(0.5);
|
||||
let lower=vec2<i32>(floor(position));
|
||||
let first=vec2<u32>((lower+vec2<i32>(size))%vec2<i32>(size));
|
||||
let second=(first+vec2<u32>(1u))%size;
|
||||
// fract restricts lower to[-1,size-1]. Avoid integer modulo after a BVH
|
||||
// walk: native shader compilers have already failed on that pattern.
|
||||
let bounded=vec2<u32>(max(lower,vec2<i32>(0)));
|
||||
let first=select(bounded,size-vec2<u32>(1u),lower<vec2<i32>(0));
|
||||
let next=first+vec2<u32>(1u);
|
||||
let second=select(next,vec2<u32>(0u),next>=size);
|
||||
let fraction=fract(position);
|
||||
let a=texture_pixel(offset,size.x,first.x,first.y);
|
||||
let b=texture_pixel(offset,size.x,second.x,first.y);
|
||||
@@ -22,36 +27,120 @@ fn texture_sample(offset:u32,size:vec2<u32>,uv:vec2<f32>)->vec3<f32> {
|
||||
let d=texture_pixel(offset,size.x,second.x,second.y);
|
||||
return mix(mix(a,b,fraction.x),mix(c,d,fraction.x),fraction.y);
|
||||
}
|
||||
struct TextureGradients { dx:vec2<f32>, dy:vec2<f32> };
|
||||
fn texture_lod(size:vec2<u32>,gradients:TextureGradients,last:u32)->f32 {
|
||||
// Same largest-singular-value policy as select_isotropic_lod. Differentiate
|
||||
// unwrapped coordinates: crossing a repeat seam never enlarges a footprint.
|
||||
let columns=vec4<f32>(gradients.dx*vec2<f32>(size),gradients.dy*vec2<f32>(size));
|
||||
let magnitude=abs(columns);
|
||||
let scale=max(max(magnitude.x,magnitude.y),max(magnitude.z,magnitude.w));
|
||||
if scale<=0.0 {return 0.0;}
|
||||
let q=columns/scale;
|
||||
let a=dot(q.xy,q.xy);let b=dot(q.zw,q.zw);let c=dot(q.xy,q.zw);
|
||||
let radius=scale*sqrt(max(0.0,0.5*(a+b+length(vec2<f32>(a-b,2.0*c)))));
|
||||
return clamp(log2(max(radius,1.0)),0.0,f32(last));
|
||||
}
|
||||
fn texture_level(descriptor:u32,level:u32,uv:vec2<f32>)->vec3<f32> {
|
||||
let record=descriptor+4u+level*4u;
|
||||
return texture_bilinear(texture_words[record],vec2<u32>(texture_words[record+1u],texture_words[record+2u]),uv);
|
||||
}
|
||||
fn texture_sample(descriptor:u32,size:vec2<u32>,uv:vec2<f32>,gradients:TextureGradients)->vec3<f32> {
|
||||
let last=texture_words[descriptor]-1u;
|
||||
var lod=0.0;
|
||||
if last>0u {lod=texture_lod(size,gradients,last);}
|
||||
let low=u32(floor(lod));let high=min(low+1u,last);
|
||||
let count=min(texture_words[descriptor+1u],select(2u,1u,low==high || fract(lod)==0.0));
|
||||
var color=vec3<f32>(0.0);
|
||||
// One bilinear call site keeps native compilation bounded when this sampler
|
||||
// shares a path with glass traversal. The admitted descriptor allows 1..2 levels.
|
||||
for(var sample=0u;sample<count;sample+=1u) {
|
||||
let rgb=texture_level(descriptor,select(low,high,sample>0u),uv);
|
||||
if sample==0u {color=rgb;} else {color=mix(color,rgb,fract(lod));}
|
||||
}
|
||||
return color;
|
||||
}
|
||||
fn texture_uv(record:u32,position:vec3<f32>)->vec2<f32> {
|
||||
let delta=position-texture_vector(record);
|
||||
let uv=vec2<f32>(dot(delta,texture_vector(record+4u)),dot(delta,texture_vector(record+8u)));
|
||||
return vec2<f32>(uv.x,select(uv.y,-uv.y,(texture_words[record+19u]&1u)!=0u));
|
||||
}
|
||||
fn texture_surface(t:Triangle,position:vec3<f32>)->Triangle {
|
||||
let record=texture_record(t);var result=t;
|
||||
if record==0u {return result;}
|
||||
let uv=texture_uv(record,position);
|
||||
if texture_words[record+3u]!=0u {
|
||||
let rgb=texture_sample(texture_words[record+3u],vec2<u32>(texture_words[record+7u],texture_words[record+11u]),uv);
|
||||
let replace=(texture_words[record+19u]&2u)!=0u;
|
||||
result.base=vec4<f32>(select(t.base.xyz*rgb,rgb,replace),t.base.w);
|
||||
}
|
||||
// Scalar maps are linear data, never sRGB. They replace the corresponding
|
||||
// captured optical parameter; alpha, IOR, coat and medium identity survive.
|
||||
if texture_words[record+16u]!=0u {
|
||||
let roughness=texture_sample(texture_words[record+16u],vec2<u32>(texture_words[record+17u],texture_words[record+18u]),uv).x;
|
||||
result.parameters.y=clamp(roughness,0.045,1.0);
|
||||
}
|
||||
if texture_words[record+20u]!=0u {
|
||||
let metallic=texture_sample(texture_words[record+20u],vec2<u32>(texture_words[record+21u],texture_words[record+22u]),uv).x;
|
||||
result.parameters.x=clamp(metallic,0.0,1.0);
|
||||
}
|
||||
return result;
|
||||
fn texture_plane_differential(normal:vec3<f32>,direction:vec3<f32>,delta:vec3<f32>)->vec3<f32> {
|
||||
return delta-direction*(dot(normal,delta)/dot(normal,direction));
|
||||
}
|
||||
fn texture_normal(t:Triangle,position:vec3<f32>,n:vec3<f32>,geometric:vec3<f32>)->vec3<f32> {
|
||||
let record=texture_record(t);
|
||||
if record==0u || texture_words[record+12u]==0u {return n;}
|
||||
let rgb=texture_sample(texture_words[record+12u],vec2<u32>(texture_words[record+13u],texture_words[record+14u]),texture_uv(record,position));
|
||||
fn texture_camera_direction_delta(direction:vec3<f32>,delta:vec3<f32>)->vec3<f32> {
|
||||
// Fixed-lens focus_scale/length(q) equals forward cosine / camera distance.
|
||||
// Divide the pixel delta first to avoid a large intermediate reciprocal.
|
||||
let scaled=delta/u.back.w;
|
||||
return (scaled-direction*dot(direction,scaled))*dot(direction,-unit(u.back.xyz));
|
||||
}
|
||||
fn texture_world_gradients(record:u32,dx:vec3<f32>,dy:vec3<f32>)->TextureGradients {
|
||||
let inverse_u=texture_vector(record+4u);let inverse_v=texture_vector(record+8u);
|
||||
let sign_v=select(1.0,-1.0,(texture_words[record+19u]&1u)!=0u);
|
||||
return TextureGradients(vec2<f32>(dot(dx,inverse_u),dot(dx,inverse_v)*sign_v),vec2<f32>(dot(dy,inverse_u),dot(dy,inverse_v)*sign_v));
|
||||
}
|
||||
fn texture_gradients(record:u32,normal:vec3<f32>,direction:vec3<f32>,distance:f32,path_length:f32,bounce:u32)->TextureGradients {
|
||||
if record==0u {return TextureGradients(vec2<f32>(0.0),vec2<f32>(0.0));}
|
||||
if bounce==0u && abs(dot(normal,direction))>1e-6 {
|
||||
var dx=2.0*u.right.xyz/f32(u.image.x);
|
||||
var dy=-2.0*u.up.xyz/f32(u.image.y);
|
||||
if u.eye.w>0.5 {
|
||||
// For a fixed lens sample, focus_scale/length(q) is exactly the
|
||||
// forward direction cosine / camera distance. Derive at this hit;
|
||||
// no camera differential stays live across the medium stack.
|
||||
dx=texture_camera_direction_delta(direction,dx)*distance;
|
||||
dy=texture_camera_direction_delta(direction,dy)*distance;
|
||||
}
|
||||
dx=texture_plane_differential(normal,direction,dx);
|
||||
dy=texture_plane_differential(normal,direction,dy);
|
||||
return texture_world_gradients(record,dx,dy);
|
||||
}
|
||||
// Secondary paths use a finite camera-pixel cone, expanded by total path
|
||||
// length and grazing angle; focused specular differentials are not tracked.
|
||||
let pixel_span=2.0*max(length(u.right.xyz)/f32(u.image.x),length(u.up.xyz)/f32(u.image.y));
|
||||
let distance_scale=select(1.0,path_length/max(u.back.w,1e-6),u.eye.w>0.5);
|
||||
let span=min(pixel_span*distance_scale/max(abs(dot(normal,direction)),1e-6),1e20);
|
||||
let axis=select(vec3<f32>(1.0,0.0,0.0),vec3<f32>(0.0,0.0,1.0),abs(normal.z)<0.9);
|
||||
let tangent=unit(cross(normal,axis));
|
||||
return texture_world_gradients(record,tangent*span,cross(normal,tangent)*span);
|
||||
}
|
||||
// Sampling changes RGB, metallic and roughness only. Transmission, IOR and
|
||||
// thickness never cross the sampler boundary, even in mixed glass/map scenes.
|
||||
struct TextureSurface { color:vec3<f32>, metallic:f32, roughness:f32, normal:vec4<f32> };
|
||||
fn texture_surface(record:u32,color:vec3<f32>,metallic:f32,roughness:f32,position:vec3<f32>,gradients:TextureGradients,scalar_face:bool)->TextureSurface {
|
||||
if record==0u {return TextureSurface(color,metallic,roughness,vec4<f32>(0.5,0.5,1.0,0.0));}
|
||||
var mapped_color=color;
|
||||
var mapped_metallic=metallic;
|
||||
var mapped_roughness=roughness;
|
||||
var mapped_normal=vec4<f32>(0.5,0.5,1.0,0.0);
|
||||
let uv=texture_uv(record,position);
|
||||
// A single sampling call site keeps the native path compiler from inlining
|
||||
// four complete trilinear samplers while its traversal/medium state is live.
|
||||
// The runtime bound excludes unused trailing roles and prevents unroll.
|
||||
for(var role=0u;role<min(texture_words[record+23u],4u);role+=1u) {
|
||||
var descriptor=texture_words[record+3u];
|
||||
var size=vec2<u32>(texture_words[record+7u],texture_words[record+11u]);
|
||||
if role==1u {descriptor=texture_words[record+12u];size=vec2<u32>(texture_words[record+13u],texture_words[record+14u]);}
|
||||
if role==2u {descriptor=texture_words[record+16u];size=vec2<u32>(texture_words[record+17u],texture_words[record+18u]);}
|
||||
if role==3u {descriptor=texture_words[record+20u];size=vec2<u32>(texture_words[record+21u],texture_words[record+22u]);}
|
||||
if descriptor==0u {continue;}
|
||||
// Only newly imported maps yield to explicit scalar face appearances.
|
||||
// Existing built-in/temporary roles retain their previous precedence,
|
||||
// including a request that mixes legacy and portable roles.
|
||||
if scalar_face && texture_words[descriptor+2u]!=0u {continue;}
|
||||
let rgb=texture_sample(descriptor,size,uv,gradients);
|
||||
if role==0u {
|
||||
let replace=(texture_words[record+19u]&2u)!=0u;
|
||||
mapped_color=select(color*rgb,rgb,replace);
|
||||
} else if role==1u {mapped_normal=vec4<f32>(rgb,1.0);}
|
||||
// Scalars are linear data. Alpha, IOR, coat and medium identity survive.
|
||||
else if role==2u {mapped_roughness=clamp(rgb.x,0.045,1.0);}
|
||||
else {mapped_metallic=clamp(rgb.x,0.0,1.0);}
|
||||
}
|
||||
return TextureSurface(mapped_color,mapped_metallic,mapped_roughness,mapped_normal);
|
||||
}
|
||||
fn texture_normal(record:u32,sampled:vec4<f32>,n:vec3<f32>,geometric:vec3<f32>)->vec3<f32> {
|
||||
if record==0u || sampled.w==0.0 {return n;}
|
||||
let rgb=sampled.xyz;
|
||||
let u=unit(texture_vector(record+4u));
|
||||
let tangent=u-n*dot(n,u);
|
||||
if dot(tangent,tangent)<1e-12 {return n;}
|
||||
@@ -66,13 +155,30 @@ fn texture_normal(t:Triangle,position:vec3<f32>,n:vec3<f32>,geometric:vec3<f32>)
|
||||
@compute @workgroup_size(1)
|
||||
fn texture_reference() {
|
||||
let t=triangle(0u);
|
||||
let mapped=texture_surface(t,u.eye.xyz);
|
||||
accumulation[0]=vec4<f32>(mapped.base.xyz,1.0);
|
||||
accumulation[1]=vec4<f32>(texture_normal(t,u.eye.xyz,u.up.xyz,u.back.xyz)+vec3<f32>(2.0),1.0);
|
||||
let record=texture_record(t.optics.w);
|
||||
let gradients=TextureGradients(vec2<f32>(0.0),vec2<f32>(0.0));
|
||||
let mapped=texture_surface(record,t.base.xyz,t.parameters.x,t.parameters.y,u.eye.xyz,gradients,(u32(t.interior.z)&2u)!=0u);
|
||||
accumulation[0]=vec4<f32>(mapped.color,1.0);
|
||||
accumulation[1]=vec4<f32>(texture_normal(record,mapped.normal,u.up.xyz,u.back.xyz)+vec3<f32>(2.0),1.0);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(1)
|
||||
fn texture_scalar_reference() {
|
||||
let t=texture_surface(triangle(0u),u.eye.xyz);
|
||||
accumulation[0]=vec4<f32>(t.parameters.xyz,1.0);
|
||||
let t=triangle(0u);
|
||||
let mapped=texture_surface(texture_record(t.optics.w),t.base.xyz,t.parameters.x,t.parameters.y,u.eye.xyz,TextureGradients(vec2<f32>(0.0),vec2<f32>(0.0)),(u32(t.interior.z)&2u)!=0u);
|
||||
accumulation[0]=vec4<f32>(vec3<f32>(mapped.metallic,mapped.roughness,t.parameters.z),1.0);
|
||||
}
|
||||
|
||||
// Validation invokes the production trilinear sampler with explicit unwrapped
|
||||
// gradients, independently of camera inference.
|
||||
@compute @workgroup_size(1)
|
||||
fn texture_mip_reference() {
|
||||
let t=triangle(0u);let record=texture_record(t.optics.w);
|
||||
let gradients=TextureGradients(u.right.xy,u.up.xy);
|
||||
let mapped=texture_surface(record,t.base.xyz,t.parameters.x,t.parameters.y,u.eye.xyz,gradients,(u32(t.interior.z)&2u)!=0u);
|
||||
accumulation[0]=vec4<f32>(mapped.color,1.0);
|
||||
let last=texture_words[texture_words[record+3u]]-1u;
|
||||
var lod=0.0;
|
||||
if last>0u {lod=texture_lod(vec2<u32>(texture_words[record+7u],texture_words[record+11u]),gradients,last);}
|
||||
accumulation[1]=vec4<f32>(lod,0.0,0.0,1.0);
|
||||
}
|
||||
+237
-7
@@ -334,13 +334,25 @@ fn write_container(
|
||||
if !clouds.is_empty() {
|
||||
replacements.insert("point_clouds".into(), Value::Array(clouds));
|
||||
}
|
||||
if !doc.render.resources.is_empty() {
|
||||
let mut resources = Map::new();
|
||||
for (id, resource) in doc.render.resources.iter() {
|
||||
let blob = writer.blob(resource.bytes(), "hdr", false)?;
|
||||
resources.insert(id.to_string(), json!({"kind":"radiance_hdr","data":"","hdr_blob":blob}));
|
||||
if !doc.render.resources.is_empty() || !doc.render.texture_resources.is_empty() {
|
||||
let mut changes = Map::new();
|
||||
if !doc.render.resources.is_empty() {
|
||||
let mut resources = Map::new();
|
||||
for (id, resource) in doc.render.resources.iter() {
|
||||
let blob = writer.blob(resource.bytes(), "hdr", false)?;
|
||||
resources.insert(id.to_string(), json!({"kind":"radiance_hdr","data":"","hdr_blob":blob}));
|
||||
}
|
||||
changes.insert("resources".into(), Value::Object(resources));
|
||||
}
|
||||
let render = replace(doc.render.as_ref(), fields([("resources", Value::Object(resources))])).map_err(invalid)?;
|
||||
if !doc.render.texture_resources.is_empty() {
|
||||
let mut resources = Map::new();
|
||||
for (id, resource) in doc.render.texture_resources.iter() {
|
||||
let blob = writer.blob(resource.bytes(), "texture", false)?;
|
||||
resources.insert(id.to_string(), json!({"kind":"opaque_texture","data":"","texture_blob":blob,"provenance":resource.provenance()}));
|
||||
}
|
||||
changes.insert("texture_resources".into(), Value::Object(resources));
|
||||
}
|
||||
let render = replace(doc.render.as_ref(), changes).map_err(invalid)?;
|
||||
replacements.insert("render".into(), render);
|
||||
}
|
||||
let metadata = replace(doc, replacements).map_err(invalid)?;
|
||||
@@ -821,12 +833,14 @@ fn read_document<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<Docume
|
||||
}
|
||||
let bytes = reader.document(max_design())?;
|
||||
check_hdr_manifest(&bytes)?;
|
||||
check_texture_manifest(&bytes)?;
|
||||
let mut metadata: Value = serde_json::from_slice(&bytes).map_err(invalid)?;
|
||||
// HDR's typed JSON representation is deliberately strict: no empty placeholders or
|
||||
// container-only blob fields may reach the document deserializer.
|
||||
let hydrated_hdr = hydrate_hdr_resources(&mut metadata, reader)?;
|
||||
let hydrated_textures = hydrate_texture_resources(&mut metadata, reader)?;
|
||||
let hydrated_text;
|
||||
let text = if hydrated_hdr {
|
||||
let text = if hydrated_hdr || hydrated_textures {
|
||||
hydrated_text = serde_json::to_string(&metadata).map_err(invalid)?;
|
||||
&hydrated_text
|
||||
} else {
|
||||
@@ -992,6 +1006,135 @@ fn hydrate_hdr_resources<R: Read + Seek>(metadata: &mut Value, reader: &mut Pack
|
||||
Ok(!resources.is_empty())
|
||||
}
|
||||
|
||||
/// Texture manifests are checked before Value allocation, including duplicate keys,
|
||||
/// bounded provenance and strict empty-payload/blob markers.
|
||||
fn check_texture_manifest(bytes: &[u8]) -> Result<()> {
|
||||
use serde::{
|
||||
Deserialize,
|
||||
de::{MapAccess, Visitor},
|
||||
};
|
||||
use solidcraft_doc::{
|
||||
render_resources::HdrId,
|
||||
render_texture_resources::{MAX_TEXTURE_RESOURCES, TextureProvenance},
|
||||
};
|
||||
#[derive(Default, Deserialize)]
|
||||
struct Render {
|
||||
#[serde(default, deserialize_with = "resources")]
|
||||
texture_resources: (),
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Metadata {
|
||||
#[serde(default)]
|
||||
render: Render,
|
||||
}
|
||||
fn marker<'de, D: serde::Deserializer<'de>>(d: D, field: &'static str) -> std::result::Result<(), D::Error> {
|
||||
let raw = <&serde_json::value::RawValue>::deserialize(d)?;
|
||||
let maximum = match field {
|
||||
"kind" => 14,
|
||||
"data" => 0,
|
||||
_ => 78,
|
||||
};
|
||||
if raw.get().len() > maximum * 6 + 2 {
|
||||
return Err(serde::de::Error::custom("texture metadata marker exceeds its budget"));
|
||||
}
|
||||
let value: String = serde_json::from_str(raw.get()).map_err(serde::de::Error::custom)?;
|
||||
let valid = match field {
|
||||
"kind" => value == "opaque_texture",
|
||||
"data" => value.is_empty(),
|
||||
_ => value
|
||||
.strip_prefix("blobs/")
|
||||
.and_then(|v| v.strip_suffix(".texture"))
|
||||
.is_some_and(|v| v.len() == 64 && v.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))),
|
||||
};
|
||||
if valid { Ok(()) } else { Err(serde::de::Error::custom("invalid texture metadata marker")) }
|
||||
}
|
||||
fn kind<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<(), D::Error> {
|
||||
marker(d, "kind")
|
||||
}
|
||||
fn data<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<(), D::Error> {
|
||||
marker(d, "data")
|
||||
}
|
||||
fn blob<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<(), D::Error> {
|
||||
marker(d, "blob")
|
||||
}
|
||||
fn provenance<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<(), D::Error> {
|
||||
let raw = <&serde_json::value::RawValue>::deserialize(d)?;
|
||||
if raw.get().len() > 48 * 1024 {
|
||||
return Err(serde::de::Error::custom("texture provenance exceeds its budget"));
|
||||
}
|
||||
let value: TextureProvenance = serde_json::from_str(raw.get()).map_err(serde::de::Error::custom)?;
|
||||
value.validate().map_err(serde::de::Error::custom)
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Entry {
|
||||
#[serde(deserialize_with = "kind")]
|
||||
kind: (),
|
||||
#[serde(deserialize_with = "data")]
|
||||
data: (),
|
||||
#[serde(deserialize_with = "blob")]
|
||||
texture_blob: (),
|
||||
#[serde(deserialize_with = "provenance")]
|
||||
provenance: (),
|
||||
}
|
||||
fn resources<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<(), D::Error> {
|
||||
struct Resources;
|
||||
impl<'de> Visitor<'de> for Resources {
|
||||
type Value = ();
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("unique bounded texture blob references")
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> std::result::Result<(), A::Error> {
|
||||
let mut keys = BTreeSet::new();
|
||||
while let Some(id) = map.next_key::<HdrId>()? {
|
||||
if keys.len() >= MAX_TEXTURE_RESOURCES || !keys.insert(id) {
|
||||
return Err(serde::de::Error::custom("too many or duplicate embedded texture resources"));
|
||||
}
|
||||
let Entry { kind: (), data: (), texture_blob: (), provenance: () } = map.next_value()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
d.deserialize_map(Resources)
|
||||
}
|
||||
let Metadata { render: Render { texture_resources: () } } = serde_json::from_slice(bytes).map_err(invalid)?;
|
||||
Ok(())
|
||||
}
|
||||
fn hydrate_texture_resources<R: Read + Seek>(metadata: &mut Value, reader: &mut PackageReader<R>) -> Result<bool> {
|
||||
use solidcraft_doc::{
|
||||
render_resources::{HdrId, encode_hdr_base64},
|
||||
render_texture_resources::{MAX_TEXTURE_BYTES, MAX_TEXTURE_RESOURCE_BYTES, MAX_TEXTURE_RESOURCES},
|
||||
};
|
||||
let Some(resources) = metadata.get_mut("render").and_then(|render| render.get_mut("texture_resources")) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let resources = resources.as_object_mut().ok_or_else(|| invalid("texture resources must be an object"))?;
|
||||
if resources.len() > MAX_TEXTURE_RESOURCES {
|
||||
return Err(invalid("at most 32 embedded texture resources"));
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for (key, value) in resources.iter_mut() {
|
||||
let id: HdrId = serde_json::from_value(Value::String(key.clone())).map_err(invalid)?;
|
||||
if !value.as_object().is_some_and(|fields| fields.len() == 4)
|
||||
|| value.get("kind").and_then(Value::as_str) != Some("opaque_texture")
|
||||
|| value.get("data").and_then(Value::as_str) != Some("")
|
||||
{
|
||||
return Err(invalid("texture metadata requires opaque_texture, empty data, provenance and one blob"));
|
||||
}
|
||||
let remaining = MAX_TEXTURE_RESOURCE_BYTES.saturating_sub(total).min(MAX_TEXTURE_BYTES);
|
||||
let bytes = reader.limited_blob(value, "texture_blob", "texture", remaining)?;
|
||||
if HdrId::of(bytes) != id {
|
||||
return Err(invalid("texture resource key does not match its blob hash"));
|
||||
}
|
||||
total = total.checked_add(bytes.len()).ok_or_else(|| invalid("texture resource size overflow"))?;
|
||||
let encoded = encode_hdr_base64(bytes).map_err(invalid)?;
|
||||
let fields = value.as_object_mut().ok_or_else(|| invalid("invalid texture metadata"))?;
|
||||
fields.remove("texture_blob");
|
||||
fields.insert("data".into(), Value::String(encoded));
|
||||
}
|
||||
Ok(!resources.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2263,6 +2406,93 @@ mod tests {
|
||||
assert!(crate::read_design(&package(&entries)).unwrap_err().to_string().contains("duplicate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_texture_container_keeps_exact_blobs_provenance_and_offline_bindings() {
|
||||
use solidcraft_doc::{
|
||||
render_settings::{GpuJob, RenderJob, RenderSetup},
|
||||
render_surface::{EmbeddedSurfaceKind, EmbeddedSurfaceSource, RenderBodyTarget, SavedSurface, SurfaceSource},
|
||||
render_texture_resources::{TextureProvenance, TextureResource},
|
||||
};
|
||||
let source = include_bytes!("../../../assets/render/starter/materials/exports/SURF-027/base_color.png").to_vec();
|
||||
let resource = TextureResource::new(
|
||||
source.clone(),
|
||||
TextureProvenance {
|
||||
author: "Original SolidCraft test".into(),
|
||||
source: "original bundled diagnostic".into(),
|
||||
license: "MIT OR Apache-2.0".into(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let id = resource.id();
|
||||
let mut doc = document();
|
||||
let render = Arc::make_mut(&mut doc.render);
|
||||
render.texture_resources.insert(resource).unwrap();
|
||||
let mut setup = RenderSetup {
|
||||
job: Some(RenderJob::GpuPath { transparent_background: false, options: Box::new(GpuJob::default()) }),
|
||||
..Default::default()
|
||||
};
|
||||
setup
|
||||
.surfaces
|
||||
.set(
|
||||
SavedSurface {
|
||||
target: RenderBodyTarget { definition: "Wood".into(), feature: 7, component: 0, occurrence_path: vec![] },
|
||||
source: SurfaceSource::Embedded(EmbeddedSurfaceSource {
|
||||
kind: EmbeddedSurfaceKind::Embedded,
|
||||
base_color: Some(id),
|
||||
normal_open_gl: None,
|
||||
roughness: Some(id),
|
||||
metallic: None,
|
||||
flip_v: true,
|
||||
}),
|
||||
local_frame: serde_json::from_value(serde_json::json!({"origin":[0.,0.,0.],"u":[240.,0.,0.],"v":[0.,480.,0.]})).unwrap(),
|
||||
normal_strength: 0.75,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
render.setups.insert("Wood".into(), setup);
|
||||
let bytes = write_design_container(&doc).unwrap();
|
||||
assert_eq!(crate::read_design(&bytes).unwrap(), doc);
|
||||
assert_eq!(crate::read_design(&crate::write_design(&doc)).unwrap(), doc);
|
||||
let original = parts(&bytes);
|
||||
let blob = format!("blobs/{id}.texture");
|
||||
assert_eq!(original.keys().filter(|name| name.ends_with(".texture")).count(), 1);
|
||||
assert_eq!(original[&blob], source);
|
||||
let metadata: Value = serde_json::from_slice(&original["document.json"]).unwrap();
|
||||
let entry = &metadata["render"]["texture_resources"][id.to_string()];
|
||||
assert_eq!(entry["data"], "");
|
||||
assert_eq!(entry["texture_blob"], blob);
|
||||
assert_eq!(entry["provenance"]["author"], "Original SolidCraft test");
|
||||
assert!(Document::from_json(&metadata.to_string()).is_err());
|
||||
let mut missing = original.clone();
|
||||
missing.remove(&blob);
|
||||
assert!(crate::read_design(&package(&missing)).unwrap_err().to_string().contains("required part"));
|
||||
let mut corrupt = original.clone();
|
||||
corrupt.get_mut(&blob).unwrap()[0] ^= 1;
|
||||
assert!(crate::read_design(&package(&corrupt)).unwrap_err().to_string().contains("hash mismatch"));
|
||||
for (field, value) in
|
||||
[("kind", json!("png")), ("data", json!("AAAA")), ("path", json!("wood.png")), ("texture_blob", json!(format!("blobs/{id}.hdr")))]
|
||||
{
|
||||
let mut entries = original.clone();
|
||||
let mut changed = metadata.clone();
|
||||
changed["render"]["texture_resources"][id.to_string()][field] = value;
|
||||
entries.insert("document.json".into(), serde_json::to_vec(&changed).unwrap());
|
||||
assert!(crate::read_design(&package(&entries)).is_err(), "{field}");
|
||||
}
|
||||
let duplicate = format!("{{\"render\":{{\"texture_resources\":{{\"{id}\":{entry},\"{id}\":{entry}}}}}}}");
|
||||
assert!(check_texture_manifest(duplicate.as_bytes()).unwrap_err().to_string().contains("duplicate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_manifest_refuses_escaped_inline_payload_before_value_materialization() {
|
||||
let id = "0".repeat(64);
|
||||
let escaped = "\\u0041".repeat(4096);
|
||||
let wire = format!(
|
||||
"{{\"render\":{{\"texture_resources\":{{\"{id}\":{{\"kind\":\"opaque_texture\",\"data\":\"{escaped}\",\"texture_blob\":\"blobs/{id}.texture\",\"provenance\":{{}}}}}}}}}}"
|
||||
);
|
||||
assert!(check_texture_manifest(wire.as_bytes()).unwrap_err().to_string().contains("marker exceeds its budget"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_hdr_container_refuses_oversized_part_before_reading_payload() {
|
||||
use solidcraft_doc::render_resources::{HdrId, MAX_HDR_BYTES};
|
||||
|
||||
@@ -696,7 +696,8 @@ impl Running {
|
||||
let request = work.request.ok_or_else(|| JobError::new(ErrorCode::Internal, "missing registered executor request"))?;
|
||||
executor.begin(work.scene, request, work.handle.0.options, work.handle.0.cancel.clone()).map(WorkTask::Registered)
|
||||
} else {
|
||||
WorkTask::new(work.scene, work.handle.0.request.clone(), work.handle.0.cancel.clone(), work.environment)
|
||||
let request = work.request.unwrap_or_else(|| work.handle.0.request.clone());
|
||||
WorkTask::new(work.scene, request, work.handle.0.cancel.clone(), work.environment)
|
||||
}
|
||||
}))
|
||||
.unwrap_or_else(|_| Err(JobError::new(ErrorCode::Internal, "image executor failed while preparing the job")));
|
||||
@@ -1319,7 +1320,7 @@ impl ImageHost {
|
||||
let request_reservation = if request_bytes == 0 { None } else { Some(self.0.shared.budget.reserve(request_bytes)?) };
|
||||
// Preserve the original CPU option capacities in the retained request and its lease.
|
||||
// Registered executors instead move resource Arcs into work and keep a small public copy.
|
||||
let (public_request, work_request) = if self.0.executor.is_some() {
|
||||
let (public_request, work_request) = if self.0.executor.is_some() || request.textures.is_some() {
|
||||
(ImageRequest { environment: None, textures: None, ..request.clone() }, Some(request))
|
||||
} else {
|
||||
(request, None)
|
||||
|
||||
@@ -820,3 +820,89 @@ fn registered_large_pool_is_explicit_finite_and_accounted() {
|
||||
drop(lease);
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn actual_cpu_texture_worker_releases_sources_after_completion_or_cancel_while_status_is_retained() {
|
||||
use crate::job::textures::{PlanarFrame, RuntimeTextures, TextureBinding, TextureMap};
|
||||
use image::ImageEncoder;
|
||||
for cancelled in [false, true] {
|
||||
// Real native admission and ImageTask, manually pumped to avoid timing races.
|
||||
let host = ImageHost(Arc::new(Owner {
|
||||
executor: None,
|
||||
shared: CpuHost::shared(MEMORY_LIMIT),
|
||||
kind: HostKind::NativeWorker,
|
||||
running: Mutex::new(None),
|
||||
_worker: None,
|
||||
}));
|
||||
let mut encoded = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut encoded).write_image(&[128, 64, 32, 255], 1, 1, image::ExtendedColorType::Rgba8).unwrap();
|
||||
let resources = Arc::new(
|
||||
RuntimeTextures::new(vec![TextureBinding {
|
||||
body_index: 0,
|
||||
body_name: "Portable wood".into(),
|
||||
frame: PlanarFrame { origin: [0.; 3], u: [1., 0., 0.], v: [0., 1., 0.] },
|
||||
normal_strength: 1.,
|
||||
base_color: Some(TextureMap::new(encoded, crate::texture_input::MapRole::BaseColor).unwrap()),
|
||||
normal: None,
|
||||
roughness: None,
|
||||
metallic: None,
|
||||
base_color_mode: Default::default(),
|
||||
flip_v: false,
|
||||
}])
|
||||
.unwrap(),
|
||||
);
|
||||
let weak = Arc::downgrade(&resources);
|
||||
let mesh = Mesh {
|
||||
positions: vec![solidcore_record::Vec3::ZERO, solidcore_record::Vec3::X, solidcore_record::Vec3::Y],
|
||||
triangles: vec![[0, 1, 2]],
|
||||
..Default::default()
|
||||
};
|
||||
let scene = ProductScene {
|
||||
bounds: mesh.bounds(),
|
||||
meshes: vec![crate::ProductMesh {
|
||||
name: "Portable wood".into(),
|
||||
mesh: Arc::new(mesh),
|
||||
color: [255; 3],
|
||||
opacity: 1.,
|
||||
surface: None,
|
||||
transmission: None,
|
||||
faces: vec![],
|
||||
}],
|
||||
};
|
||||
let input = ImageRequest {
|
||||
integrator: Integrator::Raytrace,
|
||||
raytrace: Some(Default::default()),
|
||||
textures: Some(resources),
|
||||
width: 2,
|
||||
height: 2,
|
||||
..request()
|
||||
};
|
||||
let job = host.submit(id(1599, 1), 1, scene, input).unwrap();
|
||||
let status = job.status().unwrap();
|
||||
assert!(status.request.textures.is_none());
|
||||
assert_eq!(status.textures.as_ref().unwrap().bodies, 1);
|
||||
let work = host.0.shared.queue.lock().unwrap().pop_front().unwrap();
|
||||
let mut running = Running::begin(work).unwrap();
|
||||
assert!(weak.upgrade().is_some());
|
||||
assert!(running.guarded_step(1));
|
||||
if cancelled {
|
||||
job.cancel().unwrap();
|
||||
}
|
||||
let mut finished = false;
|
||||
for _ in 0..10_000 {
|
||||
if !running.guarded_step(4) {
|
||||
finished = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(finished);
|
||||
drop(running);
|
||||
assert!(weak.upgrade().is_none(), "terminal CPU status cannot retain encoded texture payloads");
|
||||
assert_eq!(job.status().unwrap().progress.phase, if cancelled { Phase::Cancelled } else { Phase::Completed });
|
||||
assert_eq!(job.frame().unwrap().is_some(), !cancelled);
|
||||
drop(status);
|
||||
drop(job);
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
}
|
||||
@@ -53,8 +53,8 @@ fn add_bytes(sum: &mut u64, count: usize, size: usize, maximum: u64) -> Result<(
|
||||
/// counted even when another owner already holds them. Allocator and codec internals are estimates.
|
||||
/// Includes the CPU raster work estimate and its 384 MiB per-job check.
|
||||
pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission> {
|
||||
if request.environment.is_some() || request.textures.is_some() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "captured GPU environments and textures require a registered image executor"));
|
||||
if request.environment.is_some() || (request.textures.is_some() && request.integrator != super::Integrator::Raytrace) {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "captured GPU environments and raster textures require a registered image executor"));
|
||||
}
|
||||
match request.integrator {
|
||||
super::Integrator::Raster => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
//! Immutable request-only planar opaque textures. Codecs run on the image worker.
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
pub(crate) mod cpu_cache;
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
pub mod cpu_preparation;
|
||||
use super::{CancelToken, ErrorCode, JobError, Result};
|
||||
use crate::{
|
||||
ProductScene,
|
||||
@@ -89,6 +93,7 @@ pub struct TextureMap {
|
||||
source: TextureSource,
|
||||
retained_bytes: usize,
|
||||
digest: [u8; 32],
|
||||
gpu_mip_filtering: bool,
|
||||
}
|
||||
impl std::fmt::Debug for TextureMap {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -96,19 +101,29 @@ impl std::fmt::Debug for TextureMap {
|
||||
}
|
||||
}
|
||||
impl TextureMap {
|
||||
/// Keep the established single-level GPU sampler for temporary and built-in maps.
|
||||
pub fn new(bytes: Vec<u8>, role: MapRole) -> Result<Self> {
|
||||
Self::admit(bytes, role, false)
|
||||
}
|
||||
/// New design-owned images opt into footprint-filtered GPU mip chains.
|
||||
/// Portable metadata validation runs before opening the image codec.
|
||||
pub fn new_portable(bytes: Vec<u8>, role: MapRole) -> Result<Self> {
|
||||
Self::admit(bytes, role, true)
|
||||
}
|
||||
fn admit(bytes: Vec<u8>, role: MapRole, gpu_mip_filtering: bool) -> Result<Self> {
|
||||
if !matches!(role, MapRole::BaseColor | MapRole::NormalOpenGl | MapRole::Roughness | MapRole::Metallic) {
|
||||
return Err(invalid("only sRGB base color, OpenGL normal, linear roughness and linear metallic maps are supported"));
|
||||
}
|
||||
let retained_bytes = bytes.capacity();
|
||||
let digest = Sha256::digest(&bytes).into();
|
||||
let source = TextureSource::new(bytes, role).map_err(|e| invalid(e.to_string()))?;
|
||||
let source = if gpu_mip_filtering { TextureSource::new_portable(bytes, role) } else { TextureSource::new(bytes, role) }
|
||||
.map_err(|e| invalid(e.to_string()))?;
|
||||
if source.info().width > 1024 || source.info().height > 1024 {
|
||||
return Err(invalid("runtime texture maps are limited to 1024x1024"));
|
||||
}
|
||||
Ok(Self { source, retained_bytes, digest })
|
||||
Ok(Self { source, retained_bytes, digest, gpu_mip_filtering })
|
||||
}
|
||||
/// Content plus interpretation identifies immutable pixels within one request.
|
||||
/// Content, role and GPU filtering policy identify immutable maps within one request.
|
||||
/// No file is reopened after its first capture.
|
||||
pub fn identity(&self) -> ([u8; 32], u8) {
|
||||
let role = match self.role() {
|
||||
@@ -118,7 +133,11 @@ impl TextureMap {
|
||||
MapRole::Metallic => 3,
|
||||
MapRole::AmbientOcclusion => 4,
|
||||
};
|
||||
(self.digest, role)
|
||||
(self.digest, role | if self.gpu_mip_filtering { 0x80 } else { 0 })
|
||||
}
|
||||
/// The portable GPU policy adds mip filtering and scalar-face precedence.
|
||||
pub fn gpu_mip_filtering(&self) -> bool {
|
||||
self.gpu_mip_filtering
|
||||
}
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
self.retained_bytes
|
||||
@@ -126,6 +145,10 @@ impl TextureMap {
|
||||
pub fn dimensions(&self) -> [u32; 2] {
|
||||
[self.source.info().width, self.source.info().height]
|
||||
}
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
pub(crate) fn source(&self) -> &TextureSource {
|
||||
&self.source
|
||||
}
|
||||
pub fn role(&self) -> MapRole {
|
||||
self.source.info().role
|
||||
}
|
||||
@@ -248,6 +271,26 @@ impl RuntimeTextures {
|
||||
pub fn summary(&self) -> TextureSummary {
|
||||
TextureSummary { bodies: self.bindings.len(), encoded_bytes: self.source_bytes, linear_pixel_bytes: self.pixel_bytes }
|
||||
}
|
||||
/// Captured source and control capacities, retained by the same host lease.
|
||||
pub fn captured_bytes(&self) -> Result<u64> {
|
||||
let slots: usize = self
|
||||
.bindings
|
||||
.iter()
|
||||
.map(|binding| {
|
||||
[&binding.base_color, &binding.normal, &binding.roughness, &binding.metallic].into_iter().filter(|map| map.is_some()).count()
|
||||
})
|
||||
.sum();
|
||||
(size_of::<Self>() as u64 + 2 * size_of::<usize>() as u64)
|
||||
.checked_add(self.bindings.capacity() as u64 * size_of::<TextureBinding>() as u64)
|
||||
.and_then(|bytes| bytes.checked_add(self.bindings.iter().map(|b| b.body_name.capacity() as u64).sum::<u64>()))
|
||||
.and_then(|bytes| bytes.checked_add(self.source_bytes))
|
||||
.and_then(|bytes| bytes.checked_add(slots as u64 * (size_of::<Vec<u8>>() + 2 * size_of::<usize>()) as u64))
|
||||
.ok_or_else(|| invalid("CPU texture source metadata overflow"))
|
||||
}
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
pub(crate) fn cpu_source_bytes(&self) -> Result<u64> {
|
||||
self.captured_bytes()
|
||||
}
|
||||
/// Source snapshots, codec scratch, decoded bytes, staging and device buffer,
|
||||
/// plus lookup records. Conservatively includes overlapping preparation.
|
||||
pub fn working_bytes(&self) -> u64 {
|
||||
@@ -276,6 +319,22 @@ impl RuntimeTextures {
|
||||
}
|
||||
}
|
||||
|
||||
/// CPU image maps use one mapped closure for every supported direct-light path.
|
||||
/// Unsupported optical continuations refuse before source preparation.
|
||||
pub fn validate_cpu_options(options: &super::RaytraceOptions) -> Result<()> {
|
||||
options.validate()?;
|
||||
if !options.dielectrics.is_empty()
|
||||
|| options.anisotropic_faces.iter().any(Option::is_some)
|
||||
|| options.reflections.as_ref().is_some_and(|r| !r.bodies.is_empty())
|
||||
|| options.surface_effects.as_ref().is_some_and(|effects| !effects.is_empty())
|
||||
{
|
||||
return Err(invalid(
|
||||
"CPU surface maps support opaque direct lighting; use GPU or explicitly remove incompatible Glass, selected reflections, anisotropy or procedural surface effects",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Independent f64 bilinear reference over the worker's normalized pixels.
|
||||
pub fn sample_map(rgba: &[f32], dimensions: [u32; 2], uv: [f64; 2]) -> Result<[f64; 3]> {
|
||||
TextureView::new(rgba, dimensions).and_then(|v| v.sample_repeat_opaque(uv)).map_err(|e| invalid(e.to_string()))
|
||||
@@ -404,6 +463,42 @@ mod tests {
|
||||
assert_ne!(first.identity(), data.identity(), "same encoded bytes with different interpretation must not share decoded pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_and_portable_sampling_policies_survive_deduplication_in_both_orders() {
|
||||
let bytes = png(2, 1, &[0, 0, 0, 255, 255, 255, 255, 255]);
|
||||
let legacy = TextureMap::new(bytes.clone(), MapRole::BaseColor).unwrap();
|
||||
let portable = TextureMap::new_portable(bytes, MapRole::BaseColor).unwrap();
|
||||
assert!(!legacy.gpu_mip_filtering());
|
||||
assert!(portable.gpu_mip_filtering());
|
||||
assert_ne!(legacy.identity(), portable.identity(), "different filtering policies must not canonicalize to one map");
|
||||
assert_eq!(legacy.decode(&CancelToken::default()).unwrap(), portable.decode(&CancelToken::default()).unwrap());
|
||||
for maps in [[legacy.clone(), portable.clone()], [portable.clone(), legacy.clone()]] {
|
||||
let entries = maps
|
||||
.iter()
|
||||
.chain(&maps)
|
||||
.enumerate()
|
||||
.map(|(index, map)| TextureBinding {
|
||||
body_index: index,
|
||||
body_name: format!("Policy {index}"),
|
||||
base_color: Some(map.clone()),
|
||||
..binding()
|
||||
})
|
||||
.collect();
|
||||
let resources = RuntimeTextures::new(entries).unwrap();
|
||||
assert_eq!(resources.summary().linear_pixel_bytes, 2 * 2 * 16, "each policy is budgeted once");
|
||||
assert_eq!(resources.summary().encoded_bytes, (legacy.retained_bytes() + portable.retained_bytes()) as u64);
|
||||
for (index, map) in maps.iter().enumerate() {
|
||||
let a = resources.bindings()[index].base_color.as_ref().unwrap();
|
||||
let b = resources.bindings()[index + 2].base_color.as_ref().unwrap();
|
||||
assert_eq!(a.identity(), map.identity());
|
||||
assert!(a.source.shares_encoded_storage(&b.source), "same-policy duplicates share captured ownership");
|
||||
}
|
||||
let a = resources.bindings()[0].base_color.as_ref().unwrap();
|
||||
let b = resources.bindings()[1].base_color.as_ref().unwrap();
|
||||
assert!(!a.source.shares_encoded_storage(&b.source), "mixed policies retain distinct immutable map records");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_image_up_coordinates_do_not_invert_the_normal_basis() {
|
||||
let mut entry = binding();
|
||||
@@ -416,4 +511,579 @@ mod tests {
|
||||
assert_eq!(encoded, "replace");
|
||||
assert!(serde_json::from_str::<BaseColorMode>("\"unspecified\"").is_err());
|
||||
}
|
||||
// Independent source-structure cost: one header and each4096-byte CRC
|
||||
// block; EXIF adds its header, each IFD entry, and one final transition.
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn cpu_metadata_work(map: &TextureMap) -> u64 {
|
||||
let bytes = map.source().png_encoded().unwrap();
|
||||
let mut at = 8;
|
||||
let mut work = 0;
|
||||
while at < bytes.len() {
|
||||
let length = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
|
||||
work += 1 + (length as u64 + 4).div_ceil(4096);
|
||||
if &bytes[at + 4..at + 8] == b"eXIf" {
|
||||
let data = &bytes[at + 8..at + 8 + length];
|
||||
let little = &data[..2] == b"II";
|
||||
let offset =
|
||||
if little { u32::from_le_bytes(data[4..8].try_into().unwrap()) } else { u32::from_be_bytes(data[4..8].try_into().unwrap()) }
|
||||
as usize;
|
||||
let count = if little {
|
||||
u16::from_le_bytes(data[offset..offset + 2].try_into().unwrap())
|
||||
} else {
|
||||
u16::from_be_bytes(data[offset..offset + 2].try_into().unwrap())
|
||||
};
|
||||
work += u64::from(count) + 2;
|
||||
}
|
||||
at += length + 12;
|
||||
}
|
||||
work
|
||||
}
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn prepare_cpu_map(map: TextureMap, quantum: usize) -> (cpu_preparation::PreparedMap, u64, u64) {
|
||||
let bytes = cpu_preparation::MapTask::required_bytes(&map).unwrap();
|
||||
let mut task = cpu_preparation::MapTask::new(map, bytes, CancelToken::default()).unwrap();
|
||||
assert_eq!(task.working_bytes(), bytes);
|
||||
let mut work = 0;
|
||||
for _ in 0..100_000 {
|
||||
for _ in 0..quantum {
|
||||
if task
|
||||
.step(&mut |units| {
|
||||
assert_eq!(units, 1);
|
||||
work += units;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap()
|
||||
{
|
||||
return (task.finish().unwrap(), work, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("bounded CPU-map preparation did not finish");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn cpu_map_rows_match_explicit_color_roles_and_opaque_alpha_semantics() {
|
||||
// This independent transfer equation is not the implementation's lookup.
|
||||
let srgb = |byte: u8| {
|
||||
let value = f64::from(byte) / 255.0;
|
||||
if value <= 0.04045 { value / 12.92 } else { ((value + 0.055) / 1.055).powf(2.4) }
|
||||
};
|
||||
let bytes = png(2, 2, &[128, 64, 32, 0, 255, 0, 128, 255, 0, 255, 64, 0, 32, 128, 255, 255]);
|
||||
for role in [MapRole::BaseColor, MapRole::NormalOpenGl, MapRole::Roughness, MapRole::Metallic] {
|
||||
let map = TextureMap::new(bytes.clone(), role).unwrap();
|
||||
let gpu_reference = map.decode(&CancelToken::default()).unwrap();
|
||||
let metadata_work = cpu_metadata_work(&map);
|
||||
let (prepared, work, admitted) = prepare_cpu_map(map.clone(), 1);
|
||||
assert_eq!(prepared.identity().0, map.identity());
|
||||
assert_eq!(prepared.identity().1, crate::texture_sampling::mips::MIP_RECIPE_ID);
|
||||
assert!(prepared.retained_bytes() <= admitted);
|
||||
assert_eq!(prepared.dimensions(0), Some([2, 2]));
|
||||
assert_eq!(prepared.dimensions(1), Some([1, 1]));
|
||||
assert_eq!(prepared.level_count(), 2);
|
||||
assert_eq!(
|
||||
work,
|
||||
metadata_work + 5 + 2 + 2 + 3,
|
||||
"metadata checks add real bounded work; existing pixel/row/transition debit is unchanged"
|
||||
);
|
||||
for (index, pixel) in [[128_u8, 64, 32], [255, 0, 128], [0, 255, 64], [32, 128, 255]].into_iter().enumerate() {
|
||||
let uv = [(index % 2) as f64 / 2.0 + 0.25, (index / 2) as f64 / 2.0 + 0.25];
|
||||
let actual = prepared.sample(uv, 0.0).unwrap();
|
||||
for channel in 0..3 {
|
||||
let expected = if role == MapRole::BaseColor { srgb(pixel[channel]) } else { f64::from(pixel[channel]) / 255.0 };
|
||||
assert!((actual[channel] - expected).abs() < 1e-7);
|
||||
assert_eq!(
|
||||
actual[channel],
|
||||
f64::from(gpu_reference[index * 4 + channel]),
|
||||
"level-zero source-role semantics match the existing GPU upload reference"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Alpha-zero RGB still contributes to this explicitly opaque sampler.
|
||||
let expected: [f64; 3] =
|
||||
std::array::from_fn(|channel| gpu_reference.as_chunks::<4>().0.iter().map(|pixel| f64::from(pixel[channel])).sum::<f64>() / 4.0);
|
||||
for (actual, expected) in prepared.sample([0.3, -0.7], 1.0).unwrap().into_iter().zip(expected) {
|
||||
assert!((actual - expected).abs() < 1e-7);
|
||||
}
|
||||
let (other, other_work, _) = prepare_cpu_map(map, 7);
|
||||
assert_eq!(work, other_work);
|
||||
for lod in [0.0, 0.25, 0.5, 1.0] {
|
||||
assert_eq!(prepared.sample([-2.75, 4.25], lod).unwrap(), other.sample([-2.75, 4.25], lod).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn cpu_map_rows_preserve_all_static_eight_bit_png_channel_layouts() {
|
||||
for (color, pixels, expected) in [
|
||||
(image::ExtendedColorType::L8, vec![64, 192], [[64_u8, 64, 64], [192, 192, 192]]),
|
||||
(image::ExtendedColorType::La8, vec![64, 0, 192, 255], [[64, 64, 64], [192, 192, 192]]),
|
||||
(image::ExtendedColorType::Rgb8, vec![64, 128, 192, 192, 128, 64], [[64, 128, 192], [192, 128, 64]]),
|
||||
(image::ExtendedColorType::Rgba8, vec![64, 128, 192, 0, 192, 128, 64, 255], [[64, 128, 192], [192, 128, 64]]),
|
||||
] {
|
||||
let mut bytes = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut bytes).write_image(&pixels, 1, 2, color).unwrap();
|
||||
let map = TextureMap::new(bytes, MapRole::Roughness).unwrap();
|
||||
let metadata_work = cpu_metadata_work(&map);
|
||||
let (prepared, work, _) = prepare_cpu_map(map, 1);
|
||||
assert_eq!(work, metadata_work + 3 + 2 + 2 + 3);
|
||||
for (row, expected) in expected.into_iter().enumerate() {
|
||||
let actual = prepared.sample([0.5, (row as f64 + 0.5) / 2.0], 0.0).unwrap();
|
||||
for channel in 0..3 {
|
||||
assert_eq!(actual[channel], f64::from(f32::from(expected[channel]) / 255.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn cpu_map_core_prepares_existing_rgb_and_gray_builtin_resources_without_a_second_loader() {
|
||||
let resources: [(MapRole, &[u8]); 4] = [
|
||||
(MapRole::BaseColor, include_bytes!("../../../../assets/render/starter/materials/exports/SURF-001/base_color.png")),
|
||||
(MapRole::NormalOpenGl, include_bytes!("../../../../assets/render/starter/materials/exports/SURF-001/normal_open_gl.png")),
|
||||
(MapRole::Roughness, include_bytes!("../../../../assets/render/starter/materials/exports/SURF-001/roughness.png")),
|
||||
(MapRole::Metallic, include_bytes!("../../../../assets/render/starter/materials/exports/SURF-001/metallic.png")),
|
||||
];
|
||||
for (role, encoded) in resources {
|
||||
let map = TextureMap::new(encoded.to_vec(), role).unwrap();
|
||||
let existing = map.decode(&CancelToken::default()).unwrap();
|
||||
let metadata_work = cpu_metadata_work(&map);
|
||||
let (prepared, work, admitted) = prepare_cpu_map(map, 7);
|
||||
assert_eq!(prepared.dimensions(0), Some([512, 512]));
|
||||
assert_eq!(prepared.level_count(), 10);
|
||||
assert_eq!(work, metadata_work + 349_525 + 512 + 10 + 3);
|
||||
assert!(prepared.retained_bytes() < admitted);
|
||||
for [x, y] in [[0, 0], [257, 129], [511, 511]] {
|
||||
let actual = prepared.sample([(x as f64 + 0.5) / 512.0, (y as f64 + 0.5) / 512.0], 0.0).unwrap();
|
||||
let offset = (y * 512 + x) * 4;
|
||||
for channel in 0..3 {
|
||||
assert_eq!(actual[channel], f64::from(existing[offset + channel]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn cpu_map_owned_mips_keep_odd_edge_area_and_exact_capacity_accounting() {
|
||||
let mut pixels = [0, 0, 0, 0].repeat(5);
|
||||
pixels[16] = 255;
|
||||
let map = TextureMap::new(png(5, 1, &pixels), MapRole::Roughness).unwrap();
|
||||
let metadata_work = cpu_metadata_work(&map);
|
||||
let (prepared, work, admitted) = prepare_cpu_map(map, 1);
|
||||
assert_eq!(prepared.dimensions(0), Some([5, 1]));
|
||||
assert_eq!(prepared.dimensions(1), Some([2, 1]));
|
||||
assert_eq!(prepared.dimensions(2), Some([1, 1]));
|
||||
assert_eq!(prepared.dimensions(3), None);
|
||||
assert_eq!(work, metadata_work + 8 + 1 + 3 + 3);
|
||||
assert!(prepared.retained_bytes() < admitted);
|
||||
let at = prepared.sample([0.75, 0.5], 1.0).unwrap();
|
||||
assert!((at[0] - 0.4).abs() < 1e-7);
|
||||
assert_eq!(at[1..], [0.0, 0.0]);
|
||||
assert!((prepared.sample([0.0, 0.0], 2.0).unwrap()[0] - 0.2).abs() < 1e-7);
|
||||
let a = prepared.sample([0.75, 0.5], 0.0).unwrap()[0];
|
||||
let b = prepared.sample([0.75, 0.5], 1.0).unwrap()[0];
|
||||
assert!((prepared.sample([0.75, 0.5], 0.5).unwrap()[0] - (a + b) * 0.5).abs() < 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn cpu_map_material_roles_preserve_coat_ior_and_apply_normal_frame_and_flip_explicitly() {
|
||||
use crate::{clear_coat::ClearCoat, surface::OpaqueSurface, surface_patterns::ShadingSurface};
|
||||
let source = OpaqueSurface::new(Vec3::new(0.8, 0.6, 0.4), 0.2, 0.5, 1.7).unwrap().with_clear_coat(ClearCoat::new(0.7, 0.2, 1.4).unwrap());
|
||||
let original = ShadingSurface { surface: source, shading_normal: Vec3::Z };
|
||||
for role in [MapRole::BaseColor, MapRole::Roughness, MapRole::Metallic, MapRole::NormalOpenGl] {
|
||||
let map = TextureMap::new(png(1, 1, &[128, 255, 255, 0]), role).unwrap();
|
||||
let (prepared, _, _) = prepare_cpu_map(map.clone(), 3);
|
||||
let mut binding = TextureBinding {
|
||||
body_index: 0,
|
||||
body_name: "opaque".into(),
|
||||
frame: frame(),
|
||||
normal_strength: 1.0,
|
||||
base_color: None,
|
||||
roughness: None,
|
||||
metallic: None,
|
||||
normal: None,
|
||||
base_color_mode: BaseColorMode::Replace,
|
||||
flip_v: false,
|
||||
};
|
||||
match role {
|
||||
MapRole::BaseColor => binding.base_color = Some(map),
|
||||
MapRole::Roughness => binding.roughness = Some(map),
|
||||
MapRole::Metallic => binding.metallic = Some(map),
|
||||
MapRole::NormalOpenGl => binding.normal = Some(map),
|
||||
_ => panic!("unexpected map role in the four-role CPU closure fixture"),
|
||||
}
|
||||
let changed = prepared
|
||||
.apply(
|
||||
&binding,
|
||||
cpu_preparation::MapPoint { position: Vec3::ZERO, geometric_normal: Vec3::Z, view_direction: Vec3::Z },
|
||||
original,
|
||||
0.0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(changed.surface.clear_coat(), source.clear_coat());
|
||||
assert_eq!(changed.surface.dielectric_ior(), source.dielectric_ior());
|
||||
match role {
|
||||
MapRole::BaseColor => {
|
||||
let expected = ((128.0 / 255.0 + 0.055) / 1.055_f64).powf(2.4);
|
||||
assert!((changed.surface.base_color().x - expected).abs() < 1e-7);
|
||||
assert_eq!(changed.surface.base_color().y, 1.0);
|
||||
assert_eq!(changed.surface.base_color().z, 1.0);
|
||||
binding.base_color_mode = BaseColorMode::Modulate;
|
||||
let modulated = prepared
|
||||
.apply(
|
||||
&binding,
|
||||
cpu_preparation::MapPoint { position: Vec3::ZERO, geometric_normal: Vec3::Z, view_direction: Vec3::Z },
|
||||
original,
|
||||
0.0,
|
||||
)
|
||||
.unwrap();
|
||||
assert!((modulated.surface.base_color().x - 0.8 * expected).abs() < 1e-7);
|
||||
assert_eq!(modulated.surface.base_color().y, 0.6);
|
||||
}
|
||||
MapRole::Roughness => {
|
||||
assert!((changed.surface.roughness() - 128.0 / 255.0).abs() < 1e-7);
|
||||
assert_eq!(changed.surface.metallic(), 0.2);
|
||||
}
|
||||
MapRole::Metallic => {
|
||||
assert!((changed.surface.metallic() - 128.0 / 255.0).abs() < 1e-7);
|
||||
assert_eq!(changed.surface.roughness(), 0.5);
|
||||
}
|
||||
MapRole::NormalOpenGl => {
|
||||
let expected = Vec3::new(1.0 / 255.0, 1.0, 1.0).normalized().unwrap();
|
||||
assert!((changed.shading_normal - expected).len() < 1e-7);
|
||||
assert_eq!(changed.surface, source);
|
||||
binding.frame.v = [0.0, -4.0, 0.0];
|
||||
let mirrored = prepared
|
||||
.apply(
|
||||
&binding,
|
||||
cpu_preparation::MapPoint { position: Vec3::ZERO, geometric_normal: Vec3::Z, view_direction: Vec3::Z },
|
||||
original,
|
||||
0.0,
|
||||
)
|
||||
.unwrap();
|
||||
assert!((mirrored.shading_normal - Vec3::new(expected.x, -expected.y, expected.z)).len() < 1e-7);
|
||||
binding.frame.v = [0.0, 4.0, 0.0];
|
||||
let grazing_view = Vec3::new(0.0, -1.0, 0.01);
|
||||
let fallback = prepared
|
||||
.apply(
|
||||
&binding,
|
||||
cpu_preparation::MapPoint { position: Vec3::ZERO, geometric_normal: Vec3::Z, view_direction: grazing_view },
|
||||
original,
|
||||
0.0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fallback.shading_normal,
|
||||
Vec3::Z,
|
||||
"a normal map cannot turn the shading normal away from view, matching the GPU guard"
|
||||
);
|
||||
binding.normal_strength = 0.0;
|
||||
assert_eq!(
|
||||
prepared
|
||||
.apply(
|
||||
&binding,
|
||||
cpu_preparation::MapPoint { position: Vec3::ZERO, geometric_normal: Vec3::Z, view_direction: Vec3::Z },
|
||||
original,
|
||||
0.0
|
||||
)
|
||||
.unwrap()
|
||||
.shading_normal,
|
||||
Vec3::Z
|
||||
);
|
||||
}
|
||||
_ => panic!("unexpected map role in the four-role CPU closure fixture"),
|
||||
}
|
||||
}
|
||||
let map = TextureMap::new(png(1, 2, &[0, 0, 0, 255, 255, 255, 255, 255]), MapRole::Roughness).unwrap();
|
||||
let (prepared, _, _) = prepare_cpu_map(map.clone(), 1);
|
||||
let mut binding = binding();
|
||||
binding.base_color = None;
|
||||
binding.roughness = Some(map);
|
||||
let point = Vec3::new(0.0, 1.0, 0.0);
|
||||
assert_eq!(
|
||||
prepared
|
||||
.apply(&binding, cpu_preparation::MapPoint { position: point, geometric_normal: Vec3::Z, view_direction: Vec3::Z }, original, 0.0)
|
||||
.unwrap()
|
||||
.surface
|
||||
.roughness(),
|
||||
crate::surface::MIN_ROUGHNESS
|
||||
);
|
||||
binding.flip_v = true;
|
||||
assert_eq!(
|
||||
prepared
|
||||
.apply(&binding, cpu_preparation::MapPoint { position: point, geometric_normal: Vec3::Z, view_direction: Vec3::Z }, original, 0.0)
|
||||
.unwrap()
|
||||
.surface
|
||||
.roughness(),
|
||||
1.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn cpu_map_admission_cancel_and_work_exhaustion_never_publish_partial_levels() {
|
||||
let map = TextureMap::new(png(5, 3, &[128, 64, 32, 255].repeat(15)), MapRole::BaseColor).unwrap();
|
||||
let required = cpu_preparation::MapTask::required_bytes(&map).unwrap();
|
||||
assert_eq!(cpu_preparation::MapTask::new(map.clone(), required - 1, CancelToken::default()).err().unwrap().code, ErrorCode::LimitExceeded);
|
||||
assert!(cpu_preparation::MapTask::new(map.clone(), u64::MAX, CancelToken::default()).is_err());
|
||||
for phase in ["metadata_header", "metadata_bytes", "begin", "row", "linear", "finish_png", "new_level", "mip", "done"] {
|
||||
let cancel = CancelToken::default();
|
||||
let mut task = cpu_preparation::MapTask::new(map.clone(), required, cancel.clone()).unwrap();
|
||||
for _ in 0..1000 {
|
||||
if task.phase_name() == phase {
|
||||
break;
|
||||
}
|
||||
task.step(&mut |_| Ok(())).unwrap();
|
||||
}
|
||||
assert_eq!(task.phase_name(), phase);
|
||||
let before = task.completed_rows();
|
||||
let mut charged = 0;
|
||||
if phase != "done" {
|
||||
assert_eq!(
|
||||
task.step(&mut |_| {
|
||||
charged += 1;
|
||||
Err(JobError::new(ErrorCode::LimitExceeded, "test cumulative ceiling"))
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ErrorCode::LimitExceeded
|
||||
);
|
||||
assert_eq!(charged, 1);
|
||||
assert_eq!(task.phase_name(), phase);
|
||||
assert_eq!(task.completed_rows(), before);
|
||||
}
|
||||
cancel.cancel();
|
||||
assert_eq!(task.step(&mut |_| panic!("cancellation precedes the next debit")).unwrap_err().code, ErrorCode::Cancelled);
|
||||
assert_eq!(task.finish().err().unwrap().code, ErrorCode::Cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn cpu_map_core_refuses_unsupported_format_and_hostile_lod_source_or_frame() {
|
||||
let image = image::RgbImage::from_pixel(2, 2, image::Rgb([64, 128, 192]));
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image.write_to(&mut out, image::ImageFormat::Jpeg).unwrap();
|
||||
let jpeg = TextureMap::new(out.into_inner(), MapRole::BaseColor).unwrap();
|
||||
assert!(cpu_preparation::MapTask::required_bytes(&jpeg).is_err());
|
||||
let map = TextureMap::new(png(1, 1, &[128, 128, 255, 255]), MapRole::NormalOpenGl).unwrap();
|
||||
let (prepared, _, _) = prepare_cpu_map(map.clone(), 1);
|
||||
for lod in [-1.0, 0.1, f64::NAN, f64::INFINITY] {
|
||||
assert!(prepared.sample([0.0, 0.0], lod).is_err());
|
||||
}
|
||||
for uv in [[f64::NAN, 0.0], [0.0, f64::INFINITY], [1_048_577.0, 0.0]] {
|
||||
assert!(prepared.sample(uv, 0.0).is_err());
|
||||
}
|
||||
let mut binding = binding();
|
||||
binding.normal = Some(map);
|
||||
let original = crate::surface_patterns::ShadingSurface {
|
||||
surface: crate::surface::OpaqueSurface::new(Vec3::new(0.5, 0.5, 0.5), 0.0, 0.5, 1.5).unwrap(),
|
||||
shading_normal: Vec3::Z,
|
||||
};
|
||||
binding.normal_strength = f64::NAN;
|
||||
assert!(
|
||||
prepared
|
||||
.apply(
|
||||
&binding,
|
||||
cpu_preparation::MapPoint { position: Vec3::ZERO, geometric_normal: Vec3::Z, view_direction: Vec3::Z },
|
||||
original,
|
||||
0.0
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
binding.normal_strength = 1.0;
|
||||
binding.frame.u = [0.0; 3];
|
||||
assert!(
|
||||
prepared
|
||||
.apply(
|
||||
&binding,
|
||||
cpu_preparation::MapPoint { position: Vec3::ZERO, geometric_normal: Vec3::Z, view_direction: Vec3::Z },
|
||||
original,
|
||||
0.0
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
binding.frame = frame();
|
||||
binding.normal = Some(TextureMap::new(png(1, 1, &[0, 128, 255, 255]), MapRole::NormalOpenGl).unwrap());
|
||||
assert!(
|
||||
prepared
|
||||
.apply(
|
||||
&binding,
|
||||
cpu_preparation::MapPoint { position: Vec3::ZERO, geometric_normal: Vec3::Z, view_direction: Vec3::Z },
|
||||
original,
|
||||
0.0
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn cpu_map_corrupt_admitted_pixels_fail_before_a_prepared_map_can_escape() {
|
||||
let mut bytes = png(3, 2, &[64, 128, 192, 255].repeat(6));
|
||||
let idat = bytes.windows(4).position(|chunk| chunk == b"IDAT").unwrap();
|
||||
bytes[idat + 4] ^= 0xff;
|
||||
// Source/header admission is intentionally distinct from actual decoding.
|
||||
let map = TextureMap::new(bytes, MapRole::BaseColor).unwrap();
|
||||
let required = cpu_preparation::MapTask::required_bytes(&map).unwrap();
|
||||
let mut task = cpu_preparation::MapTask::new(map, required, CancelToken::default()).unwrap();
|
||||
let mut failed = false;
|
||||
for _ in 0..1000 {
|
||||
match task.step(&mut |_| Ok(())) {
|
||||
Err(error) => {
|
||||
assert_eq!(error.code, ErrorCode::InvalidRequest);
|
||||
failed = true;
|
||||
break;
|
||||
}
|
||||
Ok(done) => assert!(!done, "a corrupt PNG cannot complete map preparation"),
|
||||
}
|
||||
}
|
||||
assert!(failed);
|
||||
assert!(task.finish().is_err());
|
||||
}
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn cpu_metadata_chunk(kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut out = (data.len() as u32).to_be_bytes().to_vec();
|
||||
out.extend(kind);
|
||||
out.extend(data);
|
||||
let mut crc = u32::MAX;
|
||||
for byte in kind.iter().chain(data) {
|
||||
crc ^= u32::from(*byte);
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 == 0 { crc >> 1 } else { (crc >> 1) ^ 0xedb88320 };
|
||||
}
|
||||
}
|
||||
out.extend((!crc).to_be_bytes());
|
||||
out
|
||||
}
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
#[test]
|
||||
fn cpu_color_declarations_are_checked_cooperatively_after_legacy_capture() {
|
||||
let chromaticities =
|
||||
[31_270_u32, 32_900, 64_000, 33_000, 30_000, 50_000, 15_000, 6_000].into_iter().flat_map(u32::to_be_bytes).collect::<Vec<_>>();
|
||||
let plain = png(2, 2, &[128, 64, 32, 255].repeat(4));
|
||||
for (kind, data) in [(b"gAMA", 100_000_u32.to_be_bytes().to_vec()), (b"cHRM", chromaticities)] {
|
||||
let mut bytes = plain.clone();
|
||||
bytes.splice(33..33, cpu_metadata_chunk(kind, &data));
|
||||
let map = TextureMap::new(bytes.clone(), MapRole::BaseColor).unwrap();
|
||||
let required = cpu_preparation::MapTask::required_bytes(&map).unwrap();
|
||||
let mut task = cpu_preparation::MapTask::new(map, required, CancelToken::default()).unwrap();
|
||||
assert_eq!(task.phase_name(), "metadata_header");
|
||||
let mut charged = 0;
|
||||
let error = (0..100)
|
||||
.find_map(|_| {
|
||||
task.step(&mut |units| {
|
||||
assert_eq!(units, 1);
|
||||
charged += units;
|
||||
Ok(())
|
||||
})
|
||||
.err()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(error.message.contains("normalized sRGB"));
|
||||
assert!(charged > 1, "strict metadata runs in charged continuations, not the constructor");
|
||||
assert_eq!(task.completed_rows(), 0, "conflicting color cannot reach pixel decoding");
|
||||
assert!(task.finish().is_err());
|
||||
for role in [MapRole::NormalOpenGl, MapRole::Roughness, MapRole::Metallic] {
|
||||
let (prepared, _, _) = prepare_cpu_map(TextureMap::new(bytes.clone(), role).unwrap(), 1);
|
||||
let (baseline, _, _) = prepare_cpu_map(TextureMap::new(plain.clone(), role).unwrap(), 1);
|
||||
for lod in [0.0, 0.5, 1.0] {
|
||||
assert_eq!(prepared.sample([-0.25, 0.75], lod).unwrap(), baseline.sample([-0.25, 0.75], lod).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn cpu_map_with_metadata() -> TextureMap {
|
||||
let mut exif = b"II".to_vec();
|
||||
exif.extend(42_u16.to_le_bytes());
|
||||
exif.extend(8_u32.to_le_bytes());
|
||||
exif.extend(32_u16.to_le_bytes());
|
||||
for i in 0..32_u16 {
|
||||
exif.extend((if i == 0 { 0x112_u16 } else { 0xc000 + i }).to_le_bytes());
|
||||
exif.extend(3_u16.to_le_bytes());
|
||||
exif.extend(1_u32.to_le_bytes());
|
||||
exif.extend(1_u16.to_le_bytes());
|
||||
exif.extend(0_u16.to_le_bytes());
|
||||
}
|
||||
exif.extend(0_u32.to_le_bytes());
|
||||
let mut bytes = png(2, 2, &[128, 64, 32, 255].repeat(4));
|
||||
bytes.splice(33..33, cpu_metadata_chunk(b"eXIf", &exif));
|
||||
let end = bytes.len() - 12;
|
||||
bytes.splice(end..end, cpu_metadata_chunk(b"vpAg", &[17; 12_288]));
|
||||
TextureMap::new(bytes, MapRole::BaseColor).unwrap()
|
||||
}
|
||||
#[test]
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn cpu_map_metadata_prepass_is_charged_cancellable_and_does_not_rewrite_source_or_pixels() {
|
||||
for phase in ["metadata_header", "metadata_bytes", "metadata_exif_header", "metadata_ifd"] {
|
||||
let map = cpu_map_with_metadata();
|
||||
let weak = map.source().encoded_weak();
|
||||
let required = cpu_preparation::MapTask::required_bytes(&map).unwrap();
|
||||
let cancel = CancelToken::default();
|
||||
let mut task = cpu_preparation::MapTask::new(map, required, cancel.clone()).unwrap();
|
||||
for _ in 0..1000 {
|
||||
if task.phase_name() == phase {
|
||||
break;
|
||||
}
|
||||
task.step(&mut |units| {
|
||||
assert_eq!(units, 1);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(task.phase_name(), phase);
|
||||
assert_eq!(task.completed_rows(), 0, "all original metadata is checked before opening a codec");
|
||||
let mut charged = 0;
|
||||
assert_eq!(
|
||||
task.step(&mut |units| {
|
||||
charged += units;
|
||||
Err(JobError::new(ErrorCode::LimitExceeded, "metadata cumulative ceiling"))
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ErrorCode::LimitExceeded
|
||||
);
|
||||
assert_eq!(charged, 1);
|
||||
assert_eq!(task.phase_name(), phase);
|
||||
assert_eq!(task.completed_rows(), 0);
|
||||
cancel.cancel();
|
||||
assert_eq!(task.step(&mut |_| panic!("cancel precedes debit")).unwrap_err().code, ErrorCode::Cancelled);
|
||||
assert_eq!(task.finish().err().unwrap().code, ErrorCode::Cancelled);
|
||||
assert!(weak.upgrade().is_none(), "cancelled metadata work drops its only source ownership");
|
||||
}
|
||||
let map = cpu_map_with_metadata();
|
||||
let captured = map.source().png_encoded().unwrap();
|
||||
let digest = map.identity();
|
||||
let metadata_work = cpu_metadata_work(&map);
|
||||
let (prepared, work, _) = prepare_cpu_map(map.clone(), 1);
|
||||
assert_eq!(work, metadata_work + 5 + 2 + 2 + 3);
|
||||
assert_eq!(prepared.identity().0, digest);
|
||||
assert!(std::sync::Arc::ptr_eq(&captured, &map.source().png_encoded().unwrap()));
|
||||
let baseline = TextureMap::new(png(2, 2, &[128, 64, 32, 255].repeat(4)), MapRole::BaseColor).unwrap();
|
||||
let (baseline, _, _) = prepare_cpu_map(baseline, 7);
|
||||
for lod in [0.0, 0.25, 1.0] {
|
||||
assert_eq!(prepared.sample([-0.25, 0.75], lod).unwrap(), baseline.sample([-0.25, 0.75], lod).unwrap());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn cpu_map_prepass_validates_the_original_tail_crc_before_any_pixel_can_escape() {
|
||||
let mut bytes = png(1, 1, &[128, 64, 32, 255]);
|
||||
let mut tail = cpu_metadata_chunk(b"vpAg", &[17; 12_288]);
|
||||
let last = tail.len() - 1;
|
||||
tail[last] ^= 1;
|
||||
let end = bytes.len() - 12;
|
||||
bytes.splice(end..end, tail);
|
||||
let map = TextureMap::new(bytes, MapRole::Roughness).unwrap(); // Header admission remains separate from full CRC validation.
|
||||
let required = cpu_preparation::MapTask::required_bytes(&map).unwrap();
|
||||
let mut task = cpu_preparation::MapTask::new(map, required, CancelToken::default()).unwrap();
|
||||
let error = (0..1000).find_map(|_| task.step(&mut |_| Ok(())).err()).unwrap();
|
||||
assert!(error.message.contains("CRC"));
|
||||
assert_eq!(task.completed_rows(), 0);
|
||||
assert!(task.finish().is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! One work-owned cache for opaque CPU image maps. Every hit reuses one complete
|
||||
//! mapped closure for directional, point, area and environment direct lighting.
|
||||
use super::cpu_preparation::{MapPoint, MapTask, PreparedMap};
|
||||
use super::{RuntimeTextures, TextureBinding, TextureMap, validate_cpu_options};
|
||||
use crate::{
|
||||
ProductScene,
|
||||
job::{CancelToken, ErrorCode, JobError, RaytraceOptions, Result},
|
||||
surface_patterns::ShadingSurface,
|
||||
};
|
||||
use solidcraft_geom::prelude::*;
|
||||
use std::sync::Arc;
|
||||
fn invalid_owned(message: String) -> JobError {
|
||||
JobError::new(ErrorCode::InvalidRequest, message)
|
||||
}
|
||||
|
||||
fn invalid(message: &'static str) -> JobError {
|
||||
JobError::new(ErrorCode::InvalidRequest, message)
|
||||
}
|
||||
fn limit() -> JobError {
|
||||
JobError::new(ErrorCode::LimitExceeded, "CPU map cache exceeds admitted capacity")
|
||||
}
|
||||
fn reserve<T>(length: usize) -> Result<Vec<T>> {
|
||||
let mut values = Vec::new();
|
||||
values.try_reserve_exact(length).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "CPU map cache allocation failed"))?;
|
||||
if values.capacity() != length {
|
||||
return Err(limit());
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
fn map(binding: &TextureBinding, role: usize) -> Option<&TextureMap> {
|
||||
match role {
|
||||
0 => binding.base_color.as_ref(),
|
||||
1 => binding.roughness.as_ref(),
|
||||
2 => binding.metallic.as_ref(),
|
||||
3 => binding.normal.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Binding {
|
||||
body: usize,
|
||||
source: usize,
|
||||
maps: [Option<usize>; 4],
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Hit {
|
||||
binding: Option<usize>,
|
||||
point: MapPoint,
|
||||
current: ShadingSurface,
|
||||
role: usize,
|
||||
footprint: Option<[solidcore_record::Vec3; 2]>,
|
||||
}
|
||||
pub(crate) struct CpuTextureCache {
|
||||
resources: Arc<RuntimeTextures>,
|
||||
maps: Vec<PreparedMap>,
|
||||
bindings: Vec<Binding>,
|
||||
current: Vec<MapTask>,
|
||||
binding: usize,
|
||||
role: usize,
|
||||
scan: usize,
|
||||
ready: bool,
|
||||
hit: Option<Hit>,
|
||||
prepared_rows: u64,
|
||||
// Until a role is scanned, its source height is an upper-bound estimate.
|
||||
// Existing deduplication continuations remove reused rows without decoding
|
||||
// them or adding a constructor-wide identity scan.
|
||||
total_rows: u64,
|
||||
}
|
||||
impl CpuTextureCache {
|
||||
fn slots(resources: &RuntimeTextures) -> usize {
|
||||
resources.bindings().iter().map(|binding| (0..4).filter(|&role| map(binding, role).is_some()).count()).sum()
|
||||
}
|
||||
/// Actual retained source capacities plus all unique complete mip pixels; binding/map
|
||||
/// metadata is conservatively prepaid for every nonempty role, including shared
|
||||
/// roles. One sequential decoder workspace overlaps the complete cache ceiling.
|
||||
pub(crate) fn required_bytes(resources: &RuntimeTextures, scene: &ProductScene, options: &RaytraceOptions) -> Result<u64> {
|
||||
validate_cpu_options(options)?;
|
||||
resources.validate_scene(scene)?;
|
||||
if resources.bindings().iter().any(|binding| options.dielectrics.iter().any(|glass| glass.body_index == binding.body_index)) {
|
||||
return Err(invalid("CPU maps and Glass cannot share the same captured boundary"));
|
||||
}
|
||||
let mut width = 0;
|
||||
for binding in resources.bindings() {
|
||||
for role in 0..4 {
|
||||
if let Some(map) = map(binding, role) {
|
||||
MapTask::required_bytes(map)?;
|
||||
width = width.max(map.dimensions()[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let slots = Self::slots(resources) as u64;
|
||||
resources
|
||||
.cpu_source_bytes()?
|
||||
.checked_add(size_of::<Self>() as u64)
|
||||
.and_then(|n| n.checked_add(resources.bindings().len() as u64 * size_of::<Binding>() as u64))
|
||||
.and_then(|n| n.checked_add(slots * (size_of::<PreparedMap>() as u64 + 11 * PreparedMap::level_metadata_bytes())))
|
||||
.and_then(|n| n.checked_add(resources.summary().linear_pixel_bytes.checked_mul(2)?))
|
||||
.and_then(|n| n.checked_add(MapTask::row_workspace_bytes(width)))
|
||||
.ok_or_else(limit)
|
||||
}
|
||||
/// The containing ImageTask admission and host lease prepay required_bytes.
|
||||
pub(crate) fn new(resources: Arc<RuntimeTextures>, cancel: &CancelToken) -> Result<Self> {
|
||||
cancel.check()?;
|
||||
let total_rows = resources.bindings().iter().try_fold(0_u64, |sum, binding| {
|
||||
(0..4).try_fold(sum, |sum, role| {
|
||||
sum.checked_add(map(binding, role).map_or(0, |source| u64::from(source.dimensions()[1]))).ok_or_else(limit)
|
||||
})
|
||||
})?;
|
||||
Ok(Self {
|
||||
maps: reserve(Self::slots(&resources))?,
|
||||
bindings: reserve(resources.bindings().len())?,
|
||||
current: reserve(1)?,
|
||||
resources,
|
||||
binding: 0,
|
||||
role: 0,
|
||||
scan: 0,
|
||||
ready: false,
|
||||
hit: None,
|
||||
prepared_rows: 0,
|
||||
total_rows,
|
||||
})
|
||||
}
|
||||
pub(crate) fn ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
pub(crate) fn preparation_rows(&self) -> (u64, u64) {
|
||||
(self.prepared_rows + self.current.first().map_or(0, |current| u64::from(current.completed_rows())), self.total_rows)
|
||||
}
|
||||
/// The image worker supplies its same cumulative-work debit. Never debit a
|
||||
/// second operation after a PNG continuation or perform an image-wide scan.
|
||||
pub(crate) fn prepare_one(&mut self, cancel: &CancelToken, charge: &mut impl FnMut(u64) -> Result<()>) -> Result<bool> {
|
||||
cancel.check()?;
|
||||
if self.ready {
|
||||
return Err(invalid("CPU map cache preparation is already complete"));
|
||||
}
|
||||
if let Some(current) = self.current.first_mut() {
|
||||
if current.step(charge)? {
|
||||
self.prepared_rows =
|
||||
self.prepared_rows.checked_add(u64::from(current.completed_rows())).filter(|rows| *rows <= self.total_rows).ok_or_else(limit)?;
|
||||
let prepared = self.current.pop().ok_or_else(|| invalid("CPU current map disappeared"))?.finish()?;
|
||||
if self.maps.len() == self.maps.capacity() {
|
||||
return Err(limit());
|
||||
}
|
||||
let index = self.maps.len();
|
||||
self.maps.push(prepared);
|
||||
*self
|
||||
.bindings
|
||||
.get_mut(self.binding)
|
||||
.and_then(|b| b.maps.get_mut(self.role))
|
||||
.ok_or_else(|| invalid("CPU map binding cursor disappeared"))? = Some(index);
|
||||
self.role += 1;
|
||||
self.scan = 0;
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
charge(1)?;
|
||||
cancel.check()?;
|
||||
if self.binding == self.resources.bindings().len() {
|
||||
// At most128 fixed-size records; sort_unstable allocates no heap.
|
||||
self.bindings.sort_unstable_by_key(|b| b.body);
|
||||
self.ready = true;
|
||||
return Ok(true);
|
||||
}
|
||||
if self.bindings.len() == self.binding {
|
||||
if self.bindings.len() == self.bindings.capacity() {
|
||||
return Err(limit());
|
||||
}
|
||||
let source = self.resources.bindings().get(self.binding).ok_or_else(|| invalid("CPU source binding disappeared"))?;
|
||||
self.bindings.push(Binding { body: source.body_index, source: self.binding, maps: [None; 4] });
|
||||
return Ok(false);
|
||||
}
|
||||
if self.role == 4 {
|
||||
self.binding += 1;
|
||||
self.role = 0;
|
||||
self.scan = 0;
|
||||
return Ok(false);
|
||||
}
|
||||
let source = self.resources.bindings().get(self.binding).ok_or_else(|| invalid("CPU map source disappeared"))?;
|
||||
let Some(source) = map(source, self.role) else {
|
||||
self.role += 1;
|
||||
self.scan = 0;
|
||||
return Ok(false);
|
||||
};
|
||||
if let Some(prepared) = self.maps.get(self.scan) {
|
||||
if prepared.identity().0 == source.identity() {
|
||||
let total_rows =
|
||||
self.total_rows.checked_sub(u64::from(source.dimensions()[1])).filter(|rows| *rows >= self.prepared_rows).ok_or_else(limit)?;
|
||||
*self
|
||||
.bindings
|
||||
.get_mut(self.binding)
|
||||
.and_then(|b| b.maps.get_mut(self.role))
|
||||
.ok_or_else(|| invalid("CPU map binding cursor disappeared"))? = Some(self.scan);
|
||||
self.total_rows = total_rows;
|
||||
self.role += 1;
|
||||
self.scan = 0;
|
||||
} else {
|
||||
self.scan += 1;
|
||||
}
|
||||
} else {
|
||||
let bytes = MapTask::required_bytes(source)?;
|
||||
self.current.push(MapTask::new(source.clone(), bytes, cancel.clone())?);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
pub(crate) fn reset_hit(&mut self) {
|
||||
self.hit = None;
|
||||
}
|
||||
pub(crate) fn hit_started(&self) -> bool {
|
||||
self.hit.is_some()
|
||||
}
|
||||
pub(crate) fn hit_ready(&self) -> bool {
|
||||
self.hit.is_some_and(|h| h.role == 4)
|
||||
}
|
||||
pub(crate) fn begin_hit(
|
||||
&mut self,
|
||||
input: ShadingSurface,
|
||||
body: Option<usize>,
|
||||
point: MapPoint,
|
||||
footprint: Option<[solidcore_record::Vec3; 2]>,
|
||||
) -> Result<()> {
|
||||
if !self.ready {
|
||||
return Err(invalid("CPU surface maps are not completely prepared"));
|
||||
}
|
||||
let binding = body.and_then(|body| self.bindings.binary_search_by_key(&body, |b| b.body).ok());
|
||||
self.hit = Some(Hit { binding, point, footprint, current: input, role: if binding.is_none() { 4 } else { 0 } });
|
||||
Ok(())
|
||||
}
|
||||
/// Caller charges one cumulative work unit before this one-role operation.
|
||||
pub(crate) fn advance_hit(&mut self) -> Result<bool> {
|
||||
let hit = self.hit.as_mut().ok_or_else(|| invalid("CPU mapped hit is not initialized"))?;
|
||||
if hit.role == 4 {
|
||||
return Err(invalid("CPU mapped hit is already complete"));
|
||||
}
|
||||
let prepared = hit.binding.and_then(|i| self.bindings.get(i)).ok_or_else(|| invalid("CPU mapped body disappeared"))?;
|
||||
if let Some(index) = prepared.maps.get(hit.role).copied().flatten() {
|
||||
let binding = self.resources.bindings().get(prepared.source).ok_or_else(|| invalid("CPU mapped source disappeared"))?;
|
||||
let map = self.maps.get(index).ok_or_else(|| invalid("CPU prepared map disappeared"))?;
|
||||
let lod = match hit.footprint {
|
||||
None => map.level_count().saturating_sub(1) as f64,
|
||||
Some([dx, dy]) => {
|
||||
let u = solidcore_record::Vec3::new(binding.frame.u[0], binding.frame.u[1], binding.frame.u[2]);
|
||||
let v = solidcore_record::Vec3::new(binding.frame.v[0], binding.frame.v[1], binding.frame.v[2]);
|
||||
let gradient =
|
||||
|delta: solidcore_record::Vec3| [delta.dot(u) / u.len2(), delta.dot(v) / v.len2() * if binding.flip_v { -1.0 } else { 1.0 }];
|
||||
let (dx, dy) = (gradient(dx), gradient(dy));
|
||||
if dx.iter().chain(&dy).any(|value| !value.is_finite() || value.abs() > crate::texture_lod::MAX_UV_GRADIENT) {
|
||||
map.level_count().saturating_sub(1) as f64
|
||||
} else {
|
||||
crate::texture_lod::select_isotropic_lod(map.dimensions(0).ok_or_else(|| invalid("CPU map base level missing"))?, dx, dy)
|
||||
.map_err(|e| invalid_owned(e.to_string()))?
|
||||
.lod
|
||||
}
|
||||
}
|
||||
};
|
||||
hit.current = map.apply(binding, hit.point, hit.current, lod)?;
|
||||
}
|
||||
hit.role += 1;
|
||||
Ok(hit.role == 4)
|
||||
}
|
||||
pub(crate) fn surface(&self) -> Result<ShadingSurface> {
|
||||
let hit = self.hit.filter(|h| h.role == 4).ok_or_else(|| invalid("CPU mapped closure is incomplete"))?;
|
||||
Ok(hit.current)
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn preparation_phase(&self) -> Option<&'static str> {
|
||||
self.current.first().map(MapTask::phase_name)
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn prepared_maps(&self) -> usize {
|
||||
self.maps.len()
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn hit_role(&self) -> Option<usize> {
|
||||
self.hit.map(|h| h.role)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
//! Bounded CPU row preparation with complete linear area-filtered mip chains;
|
||||
//! callers prepay required_bytes and schedule these continuations on their worker.
|
||||
use super::{BaseColorMode, TextureBinding, TextureMap};
|
||||
use crate::{
|
||||
job::{CancelToken, ErrorCode, JobError, Result},
|
||||
surface::{MIN_ROUGHNESS, OpaqueSurface, srgb8_to_linear},
|
||||
surface_patterns::ShadingSurface,
|
||||
texture_input::{MapRole, png_metadata::PngMetadata},
|
||||
texture_sampling::{TextureView, mips},
|
||||
};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use std::{
|
||||
io::{self, BufRead, Read, Seek, SeekFrom},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// The existing whole-image texture decoder's bounded workspace, not a raised host cap.
|
||||
const CODEC_BYTES: usize = crate::texture_input::MAX_CODEC_BYTES as usize;
|
||||
const READ_CHUNK: usize = 4096;
|
||||
const MAX_EDGE: u32 = 1024;
|
||||
|
||||
fn invalid(message: impl Into<String>) -> JobError {
|
||||
JobError::new(ErrorCode::InvalidRequest, message)
|
||||
}
|
||||
fn allocation() -> JobError {
|
||||
JobError::new(ErrorCode::AllocationFailed, "CPU map preparation allocation failed")
|
||||
}
|
||||
fn codec_error(cancel: &CancelToken, message: impl Into<String>) -> JobError {
|
||||
if cancel.is_cancelled() { JobError::new(ErrorCode::Cancelled, "CPU map preparation cancelled") } else { invalid(message) }
|
||||
}
|
||||
fn guarded<T>(cancel: &CancelToken, work: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
cancel.check()?;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).map_err(|_| invalid("CPU PNG decoder failed"))?;
|
||||
cancel.check()?;
|
||||
result
|
||||
}
|
||||
|
||||
/// Owns the existing source Arc; no borrowed/self-referential decoder or encoded copy.
|
||||
struct EncodedReader {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
position: usize,
|
||||
cancel: CancelToken,
|
||||
}
|
||||
impl Read for EncodedReader {
|
||||
fn read(&mut self, destination: &mut [u8]) -> io::Result<usize> {
|
||||
let source = self.fill_buf()?;
|
||||
let count = source.len().min(destination.len());
|
||||
destination
|
||||
.get_mut(..count)
|
||||
.ok_or_else(|| io::Error::other("CPU map read range"))?
|
||||
.copy_from_slice(source.get(..count).ok_or_else(|| io::Error::other("CPU map source range"))?);
|
||||
self.consume(count);
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
impl BufRead for EncodedReader {
|
||||
fn fill_buf(&mut self) -> io::Result<&[u8]> {
|
||||
if self.cancel.is_cancelled() {
|
||||
return Err(io::Error::other("CPU map cancelled"));
|
||||
}
|
||||
let end = self.position.saturating_add(READ_CHUNK).min(self.bytes.len());
|
||||
self.bytes.get(self.position..end).ok_or_else(|| io::Error::other("CPU map cursor outside source"))
|
||||
}
|
||||
fn consume(&mut self, count: usize) {
|
||||
self.position = self.position.saturating_add(count).min(self.bytes.len());
|
||||
}
|
||||
}
|
||||
impl Seek for EncodedReader {
|
||||
fn seek(&mut self, from: SeekFrom) -> io::Result<u64> {
|
||||
if self.cancel.is_cancelled() {
|
||||
return Err(io::Error::other("CPU map cancelled"));
|
||||
}
|
||||
let position = match from {
|
||||
SeekFrom::Start(value) => i128::from(value),
|
||||
SeekFrom::Current(value) => self.position as i128 + i128::from(value),
|
||||
SeekFrom::End(value) => self.bytes.len() as i128 + i128::from(value),
|
||||
};
|
||||
if position < 0 || position > self.bytes.len() as i128 {
|
||||
return Err(io::Error::other("CPU map seek outside source"));
|
||||
}
|
||||
self.position = usize::try_from(position).map_err(|_| io::Error::other("CPU map seek overflow"))?;
|
||||
Ok(self.position as u64)
|
||||
}
|
||||
}
|
||||
struct Level {
|
||||
pixels: Vec<[f32; 4]>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
impl Level {
|
||||
fn view(&self) -> Result<TextureView<'_>> {
|
||||
// The builder validated each encoded/averaged pixel before insertion, and
|
||||
// this private complete level is immutable. Never rescan a map per ray.
|
||||
TextureView::from_admitted(self.pixels.as_flattened(), [self.width, self.height]).map_err(|error| invalid(error.to_string()))
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Stage {
|
||||
Metadata,
|
||||
Begin,
|
||||
Row,
|
||||
Pixel(usize),
|
||||
FinishPng,
|
||||
NewLevel,
|
||||
MipPixel(usize),
|
||||
Done,
|
||||
}
|
||||
|
||||
pub struct MapTask {
|
||||
map: TextureMap,
|
||||
cancel: CancelToken,
|
||||
readers: Vec<png::Reader<EncodedReader>>,
|
||||
metadata: PngMetadata,
|
||||
row: Vec<u8>,
|
||||
channels: usize,
|
||||
rows: u32,
|
||||
levels: Vec<Level>,
|
||||
stage: Stage,
|
||||
working_bytes: u64,
|
||||
full_mips: bool,
|
||||
}
|
||||
impl MapTask {
|
||||
/// Source capacity + existing codec allowance + exact base/tail capacities,
|
||||
/// one encoded row, exact level/reader metadata and this continuation.
|
||||
pub fn required_bytes(map: &TextureMap) -> Result<u64> {
|
||||
Self::required(map, true)
|
||||
}
|
||||
pub(crate) fn row_workspace_bytes(width: u32) -> u64 {
|
||||
CODEC_BYTES as u64 + u64::from(width) * 4 + size_of::<png::Reader<EncodedReader>>() as u64 + size_of::<Self>() as u64
|
||||
}
|
||||
fn required(map: &TextureMap, full_mips: bool) -> Result<u64> {
|
||||
let [width, height] = map.dimensions();
|
||||
if width == 0 || height == 0 || width > MAX_EDGE || height > MAX_EDGE {
|
||||
return Err(invalid("CPU map dimensions exceed 1024x1024"));
|
||||
}
|
||||
map.source().png_encoded().map_err(|error| invalid(error.to_string()))?;
|
||||
let mut pixels = 0_u64;
|
||||
let mut levels = 0_u64;
|
||||
let (mut w, mut h) = (width, height);
|
||||
loop {
|
||||
pixels = pixels.checked_add(u64::from(w) * u64::from(h) * 16).ok_or_else(|| invalid("CPU map pixel accounting overflow"))?;
|
||||
levels += 1;
|
||||
if !full_mips || (w == 1 && h == 1) {
|
||||
break;
|
||||
}
|
||||
w = (w / 2).max(1);
|
||||
h = (h / 2).max(1);
|
||||
}
|
||||
(map.retained_bytes() as u64)
|
||||
// One original Arc<Vec<u8>> allocation, not one charge per clone.
|
||||
.checked_add((size_of::<Vec<u8>>() + 2 * size_of::<usize>()) as u64)
|
||||
.and_then(|bytes| bytes.checked_add(CODEC_BYTES as u64))
|
||||
.and_then(|bytes| bytes.checked_add(pixels))
|
||||
.and_then(|bytes| bytes.checked_add(u64::from(width) * 4))
|
||||
.and_then(|bytes| bytes.checked_add(levels * size_of::<Level>() as u64))
|
||||
.and_then(|bytes| bytes.checked_add(size_of::<png::Reader<EncodedReader>>() as u64))
|
||||
.and_then(|bytes| bytes.checked_add(size_of::<Self>() as u64))
|
||||
.ok_or_else(|| invalid("CPU map preparation accounting overflow"))
|
||||
}
|
||||
pub fn new(map: TextureMap, max_bytes: u64, cancel: CancelToken) -> Result<Self> {
|
||||
Self::begin(map, max_bytes, cancel, true)
|
||||
}
|
||||
fn begin(map: TextureMap, max_bytes: u64, cancel: CancelToken, full_mips: bool) -> Result<Self> {
|
||||
cancel.check()?;
|
||||
let working_bytes = Self::required(&map, full_mips)?;
|
||||
if max_bytes > crate::job::CpuBudget::Compact.limits().max_job_bytes || working_bytes > max_bytes {
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "CPU map preparation exceeds admitted bytes"));
|
||||
}
|
||||
let [width, height] = map.dimensions();
|
||||
let (mut w, mut h) = (width, height);
|
||||
let mut count = 1;
|
||||
while full_mips && (w > 1 || h > 1) {
|
||||
count += 1;
|
||||
w = (w / 2).max(1);
|
||||
h = (h / 2).max(1);
|
||||
}
|
||||
let mut levels = Vec::new();
|
||||
levels.try_reserve_exact(count).map_err(|_| allocation())?;
|
||||
let mut pixels = Vec::new();
|
||||
let length = width as usize * height as usize;
|
||||
pixels.try_reserve_exact(length).map_err(|_| allocation())?;
|
||||
let mut row = Vec::new();
|
||||
row.try_reserve_exact(width as usize * 4).map_err(|_| allocation())?;
|
||||
let mut readers = Vec::new();
|
||||
readers.try_reserve_exact(1).map_err(|_| allocation())?;
|
||||
if levels.capacity() != count || pixels.capacity() != length || row.capacity() != width as usize * 4 || readers.capacity() != 1 {
|
||||
return Err(invalid("CPU map capacity exceeds admission"));
|
||||
}
|
||||
levels.push(Level { pixels, width, height });
|
||||
let role = map.role();
|
||||
Ok(Self {
|
||||
map,
|
||||
cancel,
|
||||
readers,
|
||||
metadata: PngMetadata::new(true, true).with_role(role),
|
||||
row,
|
||||
channels: 0,
|
||||
rows: 0,
|
||||
levels,
|
||||
stage: Stage::Metadata,
|
||||
working_bytes,
|
||||
full_mips,
|
||||
})
|
||||
}
|
||||
pub fn working_bytes(&self) -> u64 {
|
||||
self.working_bytes
|
||||
}
|
||||
pub fn completed_rows(&self) -> u32 {
|
||||
self.rows
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(super) fn phase_name(&self) -> &'static str {
|
||||
match self.stage {
|
||||
Stage::Metadata => self.metadata.phase_name(),
|
||||
Stage::Begin => "begin",
|
||||
Stage::Row => "row",
|
||||
Stage::Pixel(_) => "linear",
|
||||
Stage::FinishPng => "finish_png",
|
||||
Stage::NewLevel => "new_level",
|
||||
Stage::MipPixel(_) => "mip",
|
||||
Stage::Done => "done",
|
||||
}
|
||||
}
|
||||
/// Each call prepays one metadata header/4096-byte CRC block/EXIF entry,
|
||||
/// PNG setup/one at-most-1024-wide
|
||||
/// decoded row, one linear pixel, one mip pixel (at most3x3), or a transition.
|
||||
/// Codec reads observe cancellation in at-most4096-byte source slices too.
|
||||
/// PNG codec/header work is bounded by its existing input/workspace limits;
|
||||
/// this is not a wall-time guarantee and it is not an installed image worker.
|
||||
pub fn step(&mut self, charge: &mut impl FnMut(u64) -> Result<()>) -> Result<bool> {
|
||||
self.cancel.check()?;
|
||||
if self.stage == Stage::Done {
|
||||
return Ok(true);
|
||||
}
|
||||
charge(1)?;
|
||||
self.cancel.check()?;
|
||||
match self.stage {
|
||||
Stage::Metadata => {
|
||||
let bytes = self.map.source().png_encoded().map_err(|error| invalid(error.to_string()))?;
|
||||
if self.metadata.step(&bytes).map_err(|error| invalid(error.to_string()))? {
|
||||
self.stage = Stage::Begin;
|
||||
}
|
||||
}
|
||||
Stage::Begin => {
|
||||
let bytes = self.map.source().png_encoded().map_err(|error| invalid(error.to_string()))?;
|
||||
let cancel = self.cancel.clone();
|
||||
let mut decoder =
|
||||
png::Decoder::new_with_limits(EncodedReader { bytes, position: 0, cancel: cancel.clone() }, png::Limits { bytes: CODEC_BYTES });
|
||||
decoder.set_transformations(png::Transformations::EXPAND);
|
||||
decoder.set_ignore_text_chunk(true);
|
||||
// Presence was already refused by the shared/cooperative metadata policy.
|
||||
decoder.set_ignore_iccp_chunk(true);
|
||||
let reader = guarded(&cancel, || decoder.read_info().map_err(|error| codec_error(&cancel, error.to_string())))?;
|
||||
let info = reader.info();
|
||||
if info.interlaced
|
||||
|| info.animation_control.is_some()
|
||||
|| info.frame_control.is_some()
|
||||
|| [info.width, info.height] != self.map.dimensions()
|
||||
{
|
||||
return Err(invalid("CPU row preparation requires a static noninterlaced PNG with unchanged dimensions"));
|
||||
}
|
||||
let (color, depth) = reader.output_color_type();
|
||||
if depth != png::BitDepth::Eight {
|
||||
return Err(invalid("CPU row preparation requires 8-bit PNG channels"));
|
||||
}
|
||||
self.channels = match color {
|
||||
png::ColorType::Grayscale => 1,
|
||||
png::ColorType::GrayscaleAlpha => 2,
|
||||
png::ColorType::Rgb => 3,
|
||||
png::ColorType::Rgba => 4,
|
||||
_ => return Err(invalid("CPU row preparation requires gray, gray-alpha, RGB or RGBA PNG")),
|
||||
};
|
||||
self.readers.push(reader);
|
||||
self.stage = Stage::Row;
|
||||
}
|
||||
Stage::Row => {
|
||||
let reader = self.readers.first_mut().ok_or_else(|| invalid("CPU PNG reader missing"))?;
|
||||
let row = &mut self.row;
|
||||
row.clear();
|
||||
let width = self.map.dimensions()[0] as usize;
|
||||
let cancel = self.cancel.clone();
|
||||
let expected = width * self.channels;
|
||||
let found = guarded(&cancel, || {
|
||||
let next = reader.next_row().map_err(|error| codec_error(&cancel, error.to_string()))?;
|
||||
if let Some(next) = next {
|
||||
if next.data().len() != expected || expected > row.capacity() {
|
||||
return Err(invalid("CPU PNG row exceeds admission"));
|
||||
}
|
||||
row.extend_from_slice(next.data());
|
||||
Ok(true)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
})?;
|
||||
if found {
|
||||
if self.rows >= self.map.dimensions()[1] {
|
||||
return Err(invalid("CPU PNG has excessive rows"));
|
||||
}
|
||||
self.stage = Stage::Pixel(0);
|
||||
} else {
|
||||
if self.rows != self.map.dimensions()[1] {
|
||||
return Err(invalid("CPU PNG ended before a complete map"));
|
||||
}
|
||||
self.stage = Stage::FinishPng;
|
||||
}
|
||||
}
|
||||
Stage::Pixel(x) => {
|
||||
let start = x * self.channels;
|
||||
let bytes = self.row.get(start..start + self.channels).ok_or_else(|| invalid("CPU PNG row pixel missing"))?;
|
||||
let channel = |index| bytes.get(index).copied().ok_or_else(|| invalid("CPU PNG channel missing"));
|
||||
let rgba = match self.channels {
|
||||
1 => {
|
||||
let g = channel(0)?;
|
||||
[g, g, g, 255]
|
||||
}
|
||||
2 => {
|
||||
let g = channel(0)?;
|
||||
[g, g, g, channel(1)?]
|
||||
}
|
||||
3 => [channel(0)?, channel(1)?, channel(2)?, 255],
|
||||
4 => [channel(0)?, channel(1)?, channel(2)?, channel(3)?],
|
||||
_ => return Err(invalid("CPU PNG channels invalid")),
|
||||
};
|
||||
let mut pixel = [0_f32; 4];
|
||||
for (index, value) in pixel.iter_mut().enumerate() {
|
||||
*value = if index < 3 && self.map.role() == MapRole::BaseColor {
|
||||
srgb8_to_linear(rgba[index]) as f32
|
||||
} else {
|
||||
f32::from(rgba[index]) / 255.0
|
||||
};
|
||||
}
|
||||
let base = self.levels.first_mut().ok_or_else(|| invalid("CPU map base missing"))?;
|
||||
push_pixel(base, pixel)?;
|
||||
if x + 1 == base.width as usize {
|
||||
self.rows += 1;
|
||||
self.stage = Stage::Row;
|
||||
} else {
|
||||
self.stage = Stage::Pixel(x + 1);
|
||||
}
|
||||
}
|
||||
Stage::FinishPng => {
|
||||
// next_row(None) has consumed/flushed the full IDAT sequence. The
|
||||
// cooperative prepass already verified every original tail chunk,
|
||||
// including CRC/order/IEND and metadata policy, before this codec.
|
||||
// Do not parse unbounded tail metadata again in Reader::finish().
|
||||
self.readers = Vec::new();
|
||||
self.row = Vec::new();
|
||||
self.stage = Stage::NewLevel;
|
||||
}
|
||||
Stage::NewLevel => {
|
||||
let prior = self.levels.last().ok_or_else(|| invalid("CPU mip source missing"))?;
|
||||
if !self.full_mips || (prior.width == 1 && prior.height == 1) {
|
||||
self.stage = Stage::Done;
|
||||
return Ok(true);
|
||||
}
|
||||
let width = (prior.width / 2).max(1);
|
||||
let height = (prior.height / 2).max(1);
|
||||
let length = width as usize * height as usize;
|
||||
if self.levels.len() == self.levels.capacity() {
|
||||
return Err(invalid("CPU mip metadata exceeds admission"));
|
||||
}
|
||||
let mut pixels = Vec::new();
|
||||
pixels.try_reserve_exact(length).map_err(|_| allocation())?;
|
||||
if pixels.capacity() != length {
|
||||
return Err(invalid("CPU mip pixels exceed admission"));
|
||||
}
|
||||
self.levels.push(Level { pixels, width, height });
|
||||
self.stage = Stage::MipPixel(0);
|
||||
}
|
||||
Stage::MipPixel(pixel) => {
|
||||
let prior_index = self.levels.len().checked_sub(2).ok_or_else(|| invalid("CPU mip predecessor missing"))?;
|
||||
let source = self.levels.get(prior_index).ok_or_else(|| invalid("CPU mip source missing"))?;
|
||||
let target = self.levels.last().ok_or_else(|| invalid("CPU mip destination missing"))?;
|
||||
let (width, height) = (target.width, target.height);
|
||||
let rgb = mips::area_average(source.view()?, pixel % width as usize, pixel / width as usize, width as usize, height as usize)
|
||||
.map_err(|error| invalid(error.to_string()))?;
|
||||
let target = self.levels.last_mut().ok_or_else(|| invalid("CPU mip destination missing"))?;
|
||||
push_pixel(target, [rgb[0] as f32, rgb[1] as f32, rgb[2] as f32, 1.0])?;
|
||||
self.stage = if pixel + 1 == width as usize * height as usize { Stage::NewLevel } else { Stage::MipPixel(pixel + 1) };
|
||||
}
|
||||
Stage::Done => return Ok(true),
|
||||
}
|
||||
self.cancel.check()?;
|
||||
Ok(false)
|
||||
}
|
||||
pub fn finish(self) -> Result<PreparedMap> {
|
||||
self.cancel.check()?;
|
||||
if self.stage != Stage::Done || self.levels.iter().any(|level| level.pixels.len() != level.width as usize * level.height as usize) {
|
||||
return Err(invalid("CPU map preparation is incomplete"));
|
||||
}
|
||||
Ok(PreparedMap { identity: self.map.identity(), role: self.map.role(), levels: self.levels })
|
||||
}
|
||||
}
|
||||
fn push_pixel(level: &mut Level, pixel: [f32; 4]) -> Result<()> {
|
||||
if pixel.into_iter().any(|value| !value.is_finite() || !(0.0..=1.0).contains(&value)) || level.pixels.len() >= level.pixels.capacity() {
|
||||
return Err(invalid("CPU map pixel exceeds admitted values or capacity"));
|
||||
}
|
||||
level.pixels.push(pixel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Optical sample coordinates only. The original winding/offset normal stays
|
||||
/// owned by the image consumer; geometric_normal here is oriented toward view.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MapPoint {
|
||||
pub position: Vec3,
|
||||
pub geometric_normal: Vec3,
|
||||
pub view_direction: Vec3,
|
||||
}
|
||||
|
||||
pub struct PreparedMap {
|
||||
identity: ([u8; 32], u8),
|
||||
role: MapRole,
|
||||
levels: Vec<Level>,
|
||||
}
|
||||
impl PreparedMap {
|
||||
pub(crate) fn level_metadata_bytes() -> u64 {
|
||||
size_of::<Level>() as u64
|
||||
}
|
||||
pub fn identity(&self) -> (([u8; 32], u8), &'static str) {
|
||||
(self.identity, mips::MIP_RECIPE_ID)
|
||||
}
|
||||
pub fn level_count(&self) -> usize {
|
||||
self.levels.len()
|
||||
}
|
||||
pub fn dimensions(&self, level: usize) -> Option<[u32; 2]> {
|
||||
self.levels.get(level).map(|value| [value.width, value.height])
|
||||
}
|
||||
/// Exact retained capacities; caller owns the single existing lifetime lease.
|
||||
pub fn retained_bytes(&self) -> u64 {
|
||||
size_of::<Self>() as u64
|
||||
+ self.levels.capacity() as u64 * size_of::<Level>() as u64
|
||||
+ self.levels.iter().map(|level| level.pixels.capacity() as u64 * 16).sum::<u64>()
|
||||
}
|
||||
/// Trilinear repeating RGB in linear light, with explicit footprint-selected LOD.
|
||||
pub fn sample(&self, uv: [f64; 2], lod: f64) -> Result<[f64; 3]> {
|
||||
let last = self.levels.len().checked_sub(1).ok_or_else(|| invalid("CPU prepared map has no levels"))?;
|
||||
if !lod.is_finite() || lod < 0.0 || lod > last as f64 {
|
||||
return Err(invalid("CPU explicit mip LOD outside prepared levels"));
|
||||
}
|
||||
let low = lod.floor() as usize;
|
||||
let high = (low + 1).min(last);
|
||||
let fraction = lod - low as f64;
|
||||
let sample = |index: usize| {
|
||||
self.levels
|
||||
.get(index)
|
||||
.ok_or_else(|| invalid("CPU mip level missing"))?
|
||||
.view()?
|
||||
.sample_repeat_opaque(uv)
|
||||
.map_err(|error| invalid(error.to_string()))
|
||||
};
|
||||
let a = sample(low)?;
|
||||
if fraction == 0.0 || high == low {
|
||||
return Ok(a);
|
||||
}
|
||||
let b = sample(high)?;
|
||||
Ok(std::array::from_fn(|index| a[index] * (1.0 - fraction) + b[index] * fraction))
|
||||
}
|
||||
/// Pure material seam for future image integration: maps precede aging and
|
||||
/// the same result must feed BRDF, PDF and denoise guides. No geometry change.
|
||||
pub fn apply(&self, binding: &TextureBinding, point: MapPoint, original: ShadingSurface, lod: f64) -> Result<ShadingSurface> {
|
||||
let shading = crate::surface::unit_direction(original.shading_normal).map_err(|error| invalid(error.to_string()))?;
|
||||
let geometric = crate::surface::unit_direction(point.geometric_normal).map_err(|error| invalid(error.to_string()))?;
|
||||
let view = crate::surface::unit_direction(point.view_direction).map_err(|error| invalid(error.to_string()))?;
|
||||
if geometric.dot(view) <= 0.0 {
|
||||
return Err(invalid("CPU mapped surface view crosses its oriented geometric surface"));
|
||||
}
|
||||
// Match the GPU's pre-map shading-normal and post-map view guards.
|
||||
let normal = if shading.dot(view) > 0.0 { shading } else { geometric };
|
||||
let source = match self.role {
|
||||
MapRole::BaseColor => &binding.base_color,
|
||||
MapRole::NormalOpenGl => &binding.normal,
|
||||
MapRole::Roughness => &binding.roughness,
|
||||
MapRole::Metallic => &binding.metallic,
|
||||
_ => return Err(invalid("CPU map role unsupported")),
|
||||
};
|
||||
if source.as_ref().is_none_or(|source| source.identity() != self.identity) {
|
||||
return Err(invalid("CPU prepared map does not match its binding source/role"));
|
||||
}
|
||||
let rgb = self.sample(binding.uv(point.position)?, lod)?;
|
||||
if self.role == MapRole::NormalOpenGl {
|
||||
let mapped = binding.frame.mapped_normal(normal, geometric, rgb, binding.normal_strength)?;
|
||||
return Ok(ShadingSurface { surface: original.surface, shading_normal: if mapped.dot(view) > 0.0 { mapped } else { normal } });
|
||||
}
|
||||
let surface = original.surface;
|
||||
let base = if self.role == MapRole::BaseColor {
|
||||
match binding.base_color_mode {
|
||||
BaseColorMode::Replace => Vec3::new(rgb[0], rgb[1], rgb[2]),
|
||||
BaseColorMode::Modulate => {
|
||||
Vec3::new(surface.base_color().x * rgb[0], surface.base_color().y * rgb[1], surface.base_color().z * rgb[2])
|
||||
}
|
||||
}
|
||||
} else {
|
||||
surface.base_color()
|
||||
};
|
||||
let roughness = if self.role == MapRole::Roughness { rgb[0].clamp(MIN_ROUGHNESS, 1.0) } else { surface.roughness() };
|
||||
let metallic = if self.role == MapRole::Metallic { rgb[0] } else { surface.metallic() };
|
||||
let changed = OpaqueSurface::new(base, metallic, roughness, surface.dielectric_ior()).map_err(|error| invalid(error.to_string()))?;
|
||||
Ok(ShadingSurface { surface: surface.clear_coat().map_or(changed, |coat| changed.with_clear_coat(coat)), shading_normal: normal })
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,8 @@ pub fn admit(scene: &ProductScene, request: &ImageRequest, options: &RaytraceOpt
|
||||
if let Some(progressive) = options.progressive {
|
||||
progressive.validate_image_with_budget(request.width, request.height, options.budget)?;
|
||||
}
|
||||
let map_bytes =
|
||||
request.textures.as_ref().map_or(Ok(0), |maps| job::textures::cpu_cache::CpuTextureCache::required_bytes(maps, scene, options))?;
|
||||
let mut a = job::image::admit_raytrace_source(scene, request, options.budget)?;
|
||||
let r = options.reflections.as_ref().filter(|r| !r.bodies.is_empty()).map_or(1, |r| r.samples);
|
||||
u64::from(request.width)
|
||||
@@ -239,6 +241,7 @@ pub fn admit(scene: &ProductScene, request: &ImageRequest, options: &RaytraceOpt
|
||||
.checked_add(a.triangles.checked_mul(std::mem::size_of::<Look>() as u64).ok_or_else(|| error(TraceError::MemoryLimit))?)
|
||||
.and_then(|v| v.checked_add(emission_bytes))
|
||||
.and_then(|v| v.checked_add(effect_bytes))
|
||||
.and_then(|v| v.checked_add(map_bytes))
|
||||
.and_then(|v| v.checked_add(optical_bytes))
|
||||
.and_then(|v| v.checked_add(film_bytes))
|
||||
// The resumable pixel owns two traversal cursors. Its fallible, fixed
|
||||
@@ -454,6 +457,7 @@ fn resume_optical_path(pending: &mut PendingPixel, path: OpticalPath) {
|
||||
}
|
||||
|
||||
pub struct ImageTask {
|
||||
maps: Vec<job::textures::cpu_cache::CpuTextureCache>,
|
||||
punctual_diagnostics: job::PunctualDiagnostics,
|
||||
progressive: Option<Progressive>,
|
||||
optical: Vec<OpticalTask>,
|
||||
@@ -598,6 +602,14 @@ impl ImageTask {
|
||||
{
|
||||
return Err(error(TraceError::MemoryLimit));
|
||||
}
|
||||
let mut maps = Vec::new();
|
||||
if let Some(resources) = &request.textures {
|
||||
maps.try_reserve_exact(1).map_err(|_| error(TraceError::AllocationFailed))?;
|
||||
if maps.capacity() != 1 {
|
||||
return Err(error(TraceError::MemoryLimit));
|
||||
}
|
||||
maps.push(job::textures::cpu_cache::CpuTextureCache::new(resources.clone(), &cancel)?);
|
||||
}
|
||||
let pending = PendingPixelSlot::new(&cancel)?;
|
||||
let budget = options.budget;
|
||||
let progressive = options
|
||||
@@ -609,6 +621,7 @@ impl ImageTask {
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(Self {
|
||||
maps,
|
||||
punctual_diagnostics: Default::default(),
|
||||
progressive,
|
||||
optical,
|
||||
@@ -652,6 +665,9 @@ impl ImageTask {
|
||||
(Phase::Completed, 1, 1)
|
||||
} else if let Some(e) = &self.encoder {
|
||||
(Phase::Encoding, e.row() as u64, self.request.height as u64)
|
||||
} else if let Some(maps) = self.maps.first().filter(|maps| !maps.ready()) {
|
||||
let (done, total) = maps.preparation_rows();
|
||||
(Phase::Preparing, done, total)
|
||||
} else if let Some(progressive) = &self.progressive {
|
||||
let count = u64::from(self.request.width) * u64::from(self.request.height);
|
||||
if !progressive.film.ready() {
|
||||
@@ -723,7 +739,7 @@ impl ImageTask {
|
||||
.map_err(|_| invalid("invalid scalar surface"))?,
|
||||
};
|
||||
self.triangles.push(Triangle::new(vertices).map_err(error)?);
|
||||
self.looks.push(Look { body_index: self.body, face, surface, normals, vertices });
|
||||
self.looks.push(Look { body_index: self.body, face, has_face_override: look.is_some(), surface, normals, vertices });
|
||||
self.triangle += 1;
|
||||
Ok(())
|
||||
}
|
||||
@@ -863,7 +879,12 @@ impl ImageTask {
|
||||
// fabricated traversal debit. Check cancel/limit before mutations.
|
||||
self.work.tick(&self.cancel).map_err(error)?;
|
||||
let bvh = self.bvh.as_ref().ok_or_else(|| invalid("missing reusable primary BVH"))?;
|
||||
let shared = resolved_surface_hit(&PreparedScene { bvh, primitives: &self.looks, source: &self.scene }, pending, &self.effects)?;
|
||||
let shared = resolved_surface_hit(
|
||||
&PreparedScene { bvh, primitives: &self.looks, source: &self.scene },
|
||||
pending,
|
||||
&self.effects,
|
||||
self.maps.first(),
|
||||
)?;
|
||||
if self.optical.first().is_some_and(|o| o.pending.is_some())
|
||||
|| self.options.dielectrics.iter().any(|input| input.body_index == shared.body_index)
|
||||
{
|
||||
@@ -998,6 +1019,9 @@ impl ImageTask {
|
||||
self.primary_coverage.checked_add(1).filter(|n| *n <= 1024).ok_or_else(|| invalid("spatial coverage overflow"))?;
|
||||
}
|
||||
pending.hit = Some(hit);
|
||||
if let Some(maps) = self.maps.first_mut() {
|
||||
maps.reset_hit();
|
||||
}
|
||||
pending.light = 0;
|
||||
let shared = scene.resolve_hit(pending.ray, hit).map_err(error)?;
|
||||
let mode =
|
||||
@@ -1011,7 +1035,7 @@ impl ImageTask {
|
||||
// Traversal consumed this continuation; selector work begins next call.
|
||||
return Ok(());
|
||||
}
|
||||
pending.surface_ready = self.effects.is_empty();
|
||||
pending.surface_ready = self.effects.is_empty() && self.maps.is_empty();
|
||||
if !pending.surface_ready {
|
||||
// Traversal already consumed this continuation's unit. Scan one
|
||||
// effect per following call, before beginning any lighting stage.
|
||||
@@ -1042,8 +1066,37 @@ impl ImageTask {
|
||||
if pending.surface_cursor != self.effects.len() {
|
||||
return Err(invalid("invalid surface effect scan state"));
|
||||
}
|
||||
if let Some(maps) = self.maps.first_mut() {
|
||||
if !maps.hit_started() {
|
||||
let shared = unmapped_surface_hit(&scene, pending, &self.effects)?;
|
||||
let has_face_override = self.looks.get(shared.primitive_index).is_none_or(|look| look.has_face_override);
|
||||
let footprint = primary_footprint(
|
||||
&self.request,
|
||||
&self.options,
|
||||
self.progressive.as_ref(),
|
||||
self.pixel,
|
||||
self.spatial,
|
||||
self.clip,
|
||||
shared.position,
|
||||
shared.oriented_geometric_normal(),
|
||||
)?;
|
||||
maps.begin_hit(
|
||||
crate::surface_patterns::ShadingSurface { surface: shared.surface, shading_normal: shared.shading_normal },
|
||||
(!has_face_override).then_some(shared.body_index),
|
||||
job::textures::cpu_preparation::MapPoint {
|
||||
position: shared.position,
|
||||
geometric_normal: shared.oriented_geometric_normal(),
|
||||
view_direction: -pending.ray.direction(),
|
||||
},
|
||||
footprint,
|
||||
)?;
|
||||
}
|
||||
if !maps.hit_ready() && !maps.advance_hit()? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
pending.surface_ready = true;
|
||||
let shared = resolved_surface_hit(&scene, pending, &self.effects)?;
|
||||
let shared = resolved_surface_hit(&scene, pending, &self.effects, self.maps.first())?;
|
||||
let mode =
|
||||
self.options.reflections.as_ref().and_then(|r| r.bodies.iter().find(|entry| entry.body == shared.body_name).map(|entry| entry.mode));
|
||||
if self.options.shadows && !self.lights.lights().is_empty() && mode != Some(job::ReflectionMode::Mirror) {
|
||||
@@ -1060,7 +1113,7 @@ impl ImageTask {
|
||||
*pending.visibility.get_mut(pending.light).ok_or_else(|| invalid("light visibility index"))? = shadow.is_none();
|
||||
pending.light += 1;
|
||||
if let Some(light) = self.lights.lights().get(pending.light) {
|
||||
let shared = resolved_surface_hit(&scene, pending, &self.effects)?;
|
||||
let shared = resolved_surface_hit(&scene, pending, &self.effects, self.maps.first())?;
|
||||
pending.cursor = TraceCursor::new(shared.spawn(light.direction()).map_err(error)?, true);
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1082,7 +1135,7 @@ impl ImageTask {
|
||||
return Ok(());
|
||||
}
|
||||
if pending.punctual_index < self.options.punctual_lights.as_ref().map_or(0, Vec::len) {
|
||||
let shared = resolved_surface_hit(&scene, pending, &self.effects)?;
|
||||
let shared = resolved_surface_hit(&scene, pending, &self.effects, self.maps.first())?;
|
||||
let mode =
|
||||
self.options.reflections.as_ref().and_then(|r| r.bodies.iter().find(|entry| entry.body == shared.body_name).map(|entry| entry.mode));
|
||||
let face = self.looks.get(shared.primitive_index).ok_or_else(|| invalid("punctual face missing"))?.face;
|
||||
@@ -1093,7 +1146,7 @@ impl ImageTask {
|
||||
return Ok(());
|
||||
}
|
||||
if pending.area_light < self.options.area_lights.as_ref().map_or(0, Vec::len) {
|
||||
let shared = resolved_surface_hit(&scene, pending, &self.effects)?;
|
||||
let shared = resolved_surface_hit(&scene, pending, &self.effects, self.maps.first())?;
|
||||
let mode =
|
||||
self.options.reflections.as_ref().and_then(|r| r.bodies.iter().find(|entry| entry.body == shared.body_name).map(|entry| entry.mode));
|
||||
let face = self.looks.get(shared.primitive_index).ok_or_else(|| invalid("area face missing"))?.face;
|
||||
@@ -1129,7 +1182,7 @@ impl ImageTask {
|
||||
}
|
||||
if pending.light == self.lights.lights().len() {
|
||||
self.work.tick(&self.cancel).map_err(error)?;
|
||||
let shared = resolved_surface_hit(&scene, pending, &self.effects)?;
|
||||
let shared = resolved_surface_hit(&scene, pending, &self.effects, self.maps.first())?;
|
||||
let mode =
|
||||
self.options.reflections.as_ref().and_then(|r| r.bodies.iter().find(|entry| entry.body == shared.body_name).map(|entry| entry.mode));
|
||||
let view = -pending.ray.direction();
|
||||
@@ -1320,7 +1373,7 @@ impl ImageTask {
|
||||
}
|
||||
state.ready = true;
|
||||
let Some(index) = state.selected else {
|
||||
pending.surface_ready = self.effects.is_empty();
|
||||
pending.surface_ready = self.effects.is_empty() && self.maps.is_empty();
|
||||
if !pending.surface_ready {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1444,6 +1497,8 @@ impl ImageTask {
|
||||
self.options.emissions = None;
|
||||
self.emissions = Vec::new();
|
||||
self.options.surface_effects = None;
|
||||
self.maps = Vec::new();
|
||||
self.request.textures = None;
|
||||
self.options.dielectrics = Vec::new();
|
||||
self.options.environment = None;
|
||||
self.optical = Vec::new();
|
||||
@@ -1493,7 +1548,12 @@ impl ImageTask {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid raytrace step or completed task"));
|
||||
}
|
||||
for _ in 0..units.min(4) {
|
||||
if self.progressive.as_ref().is_some_and(|p| !p.film.ready()) {
|
||||
if self.maps.first().is_some_and(|maps| !maps.ready()) {
|
||||
self.maps
|
||||
.first_mut()
|
||||
.ok_or_else(|| invalid("CPU map cache disappeared"))?
|
||||
.prepare_one(&self.cancel, &mut |units| self.work.charge(units, &self.cancel).map_err(error))?;
|
||||
} else if self.progressive.as_ref().is_some_and(|p| !p.film.ready()) {
|
||||
self.progressive
|
||||
.as_mut()
|
||||
.ok_or_else(|| invalid("missing progressive initialization"))?
|
||||
@@ -1576,8 +1636,25 @@ fn resolved_surface_hit<'a>(
|
||||
scene: &PreparedScene<'a>,
|
||||
pending: &PendingPixel,
|
||||
effects: &[(usize, crate::surface_patterns::PreparedEffect)],
|
||||
maps: Option<&job::textures::cpu_cache::CpuTextureCache>,
|
||||
) -> job::Result<SharedHit<'a>> {
|
||||
if !pending.surface_ready || pending.surface_cursor != effects.len() {
|
||||
if !pending.surface_ready {
|
||||
return Err(invalid("mapped surface is incomplete"));
|
||||
}
|
||||
let mut shared = unmapped_surface_hit(scene, pending, effects)?;
|
||||
if let Some(maps) = maps {
|
||||
let mapped = maps.surface()?;
|
||||
shared.surface = mapped.surface;
|
||||
shared.shading_normal = mapped.shading_normal;
|
||||
}
|
||||
Ok(shared)
|
||||
}
|
||||
fn unmapped_surface_hit<'a>(
|
||||
scene: &PreparedScene<'a>,
|
||||
pending: &PendingPixel,
|
||||
effects: &[(usize, crate::surface_patterns::PreparedEffect)],
|
||||
) -> job::Result<SharedHit<'a>> {
|
||||
if pending.surface_cursor != effects.len() {
|
||||
return Err(invalid("surface effect is not completely resolved"));
|
||||
}
|
||||
let mut shared = scene.resolve_hit(pending.ray, pending.hit.ok_or_else(|| invalid("missing prepared surface hit"))?).map_err(error)?;
|
||||
@@ -1593,6 +1670,47 @@ fn resolved_surface_hit<'a>(
|
||||
Ok(shared)
|
||||
}
|
||||
|
||||
/// Adjacent shading-grid rays meet the same captured geometric plane. This is a
|
||||
/// texture footprint only: no topology or visibility decision uses the plane.
|
||||
/// A grazing footprint that crosses the horizon selects the coarsest mip.
|
||||
fn primary_footprint(
|
||||
request: &ImageRequest,
|
||||
options: &RaytraceOptions,
|
||||
progressive: Option<&Progressive>,
|
||||
pixel: usize,
|
||||
spatial: u8,
|
||||
clip: (f64, f64),
|
||||
position: Vec3,
|
||||
normal: Vec3,
|
||||
) -> job::Result<Option<[Vec3; 2]>> {
|
||||
let width = request.width as usize;
|
||||
let offset = if let Some(progressive) = progressive {
|
||||
progressive::variates(progressive.options.seed, pixel as u64, progressive.film.samples(), 0)
|
||||
} else {
|
||||
primary_offset(options.primary_samples, spatial)?
|
||||
};
|
||||
let x = (pixel % width) as f64 + offset[0];
|
||||
let y = (pixel / width) as f64 + offset[1];
|
||||
let spacing = if progressive.is_some() { 1.0 } else { 1.0 / f64::from(options.primary_samples).sqrt() };
|
||||
let mut result = [Vec3::ZERO; 2];
|
||||
for (output, [dx, dy]) in result.iter_mut().zip([[spacing, 0.0], [0.0, spacing]]) {
|
||||
let ray = primary_ray(request, x + dx, y + dy, clip).map_err(error)?;
|
||||
let denominator = ray.direction().dot(normal);
|
||||
if !denominator.is_finite() || denominator.abs() < 1e-12 {
|
||||
return Ok(None);
|
||||
}
|
||||
let distance = (position - ray.origin()).dot(normal) / denominator;
|
||||
if !distance.is_finite() || distance < 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
*output = ray.origin() + ray.direction() * distance - position;
|
||||
if !output.is_finite() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Ok(Some(result))
|
||||
}
|
||||
|
||||
fn bounded_radiance(v: Vec3) -> bool {
|
||||
v.is_finite() && [v.x, v.y, v.z].into_iter().all(|c| (0.0..=1e12).contains(&c))
|
||||
}
|
||||
|
||||
@@ -1484,3 +1484,154 @@ fn cancelled_pending_pixel_allocation_is_refused_before_any_continuation() {
|
||||
cancel.cancel();
|
||||
assert_eq!(PendingPixelSlot::new(&cancel).err().unwrap().code, ErrorCode::Cancelled);
|
||||
}
|
||||
|
||||
fn mapped_wood_fixture() -> (ProductScene, ImageRequest, RaytraceOptions) {
|
||||
use crate::job::textures::{BaseColorMode, PlanarFrame, RuntimeTextures, TextureBinding, TextureMap};
|
||||
use image::ImageEncoder;
|
||||
use std::sync::Arc;
|
||||
let mesh = solidcraft_geom::Mesh {
|
||||
positions: vec![Vec3::new(-40., -40., 0.), Vec3::new(40., -40., 0.), Vec3::new(40., 40., 0.), Vec3::new(-40., 40., 0.)],
|
||||
normals: vec![Vec3::Z; 4],
|
||||
triangles: vec![[0, 1, 2], [0, 2, 3]],
|
||||
tri_face: vec![0, 0],
|
||||
..Default::default()
|
||||
};
|
||||
let bounds = mesh.bounds();
|
||||
let surface = OpaqueSurface::new(Vec3::new(1., 1., 1.), 0., 0.7, 1.).unwrap();
|
||||
let scene = ProductScene {
|
||||
bounds,
|
||||
meshes: vec![crate::ProductMesh {
|
||||
name: "Wood board".into(),
|
||||
mesh: Arc::new(mesh),
|
||||
color: [255; 3],
|
||||
opacity: 1.,
|
||||
surface: Some(surface),
|
||||
transmission: None,
|
||||
faces: vec![],
|
||||
}],
|
||||
};
|
||||
// Original diagnostic grain: alternating opaque dark/light fibres with known mean.
|
||||
let mut encoded = Vec::new();
|
||||
let pixels: Vec<u8> = (0..64)
|
||||
.flat_map(|i| {
|
||||
let value = if i % 8 % 2 == 0 { 0 } else { 255 };
|
||||
[value, value, value, 255]
|
||||
})
|
||||
.collect();
|
||||
image::codecs::png::PngEncoder::new(&mut encoded).write_image(&pixels, 8, 8, image::ExtendedColorType::Rgba8).unwrap();
|
||||
let textures = RuntimeTextures::new(vec![TextureBinding {
|
||||
body_index: 0,
|
||||
body_name: "Wood board".into(),
|
||||
frame: PlanarFrame { origin: [0.; 3], u: [1., 0., 0.], v: [0., 1., 0.] },
|
||||
normal_strength: 0.4,
|
||||
base_color: Some(TextureMap::new(encoded, crate::texture_input::MapRole::BaseColor).unwrap()),
|
||||
normal: None,
|
||||
roughness: None,
|
||||
metallic: None,
|
||||
base_color_mode: BaseColorMode::Replace,
|
||||
flip_v: false,
|
||||
}])
|
||||
.unwrap();
|
||||
let mut request = request();
|
||||
request.camera.set_view(crate::StandardView::Top);
|
||||
request.camera.distance = 1.25;
|
||||
request.textures = Some(Arc::new(textures));
|
||||
let options = RaytraceOptions {
|
||||
lights: Some(vec![job::DirectionalLightOptions { direction: [0., 0., 1.], color: [1.; 3], intensity: std::f64::consts::PI }]),
|
||||
shadows: false,
|
||||
..Default::default()
|
||||
};
|
||||
request.raytrace = Some(options.clone());
|
||||
(scene, request, options)
|
||||
}
|
||||
fn mapped_png(scene: ProductScene, request: ImageRequest, options: RaytraceOptions, quantum: usize) -> Vec<u8> {
|
||||
let mut task = ImageTask::new(scene, request, CancelToken::default(), options).unwrap();
|
||||
for _ in 0..300_000 {
|
||||
let before = task.work.used();
|
||||
let result = task.step(quantum).unwrap();
|
||||
// Four bounded continuations; a PNG row keeps its legacy explicit row cost.
|
||||
if let TaskPoll::Complete(png) = result {
|
||||
return png;
|
||||
}
|
||||
assert!(task.work.used() >= before);
|
||||
}
|
||||
panic!("mapped image did not finish");
|
||||
}
|
||||
#[test]
|
||||
fn cpu_wood_keeps_close_fibres_and_minifies_distant_fibres_to_the_linear_mean() {
|
||||
let (scene, mut request, options) = mapped_wood_fixture();
|
||||
let mesh = scene.meshes[0].mesh.clone();
|
||||
let close = mapped_png(scene.clone(), request.clone(), options.clone(), 1);
|
||||
assert_eq!(close, mapped_png(scene.clone(), request.clone(), options.clone(), 7));
|
||||
let image = image::load_from_memory(&close).unwrap().to_rgba8();
|
||||
let values: Vec<_> = image.pixels().map(|p| p[0]).collect();
|
||||
assert!(values.iter().max().unwrap() - values.iter().min().unwrap() > 150, "magnified fibres must remain distinct: {values:?}");
|
||||
request.camera.distance = 25.;
|
||||
let far = mapped_png(scene.clone(), request.clone(), options.clone(), 7);
|
||||
let image = image::load_from_memory(&far).unwrap().to_rgba8();
|
||||
// Linear mean reflectance is 0.5. Unit irradiance/pi yields 0.5 radiance,
|
||||
// then the existing Reinhard presentation maps 0.5 to 1/3 before sRGB.
|
||||
let expected = encode8(1. / 3.);
|
||||
assert!(image.pixels().all(|p| p.0 == [expected, expected, expected, 255]), "distant grain must converge to linear-light mean");
|
||||
assert!(std::sync::Arc::ptr_eq(&mesh, &scene.meshes[0].mesh));
|
||||
assert_eq!(scene.meshes[0].surface.unwrap().dielectric_ior(), 1.);
|
||||
}
|
||||
#[test]
|
||||
fn cpu_wood_scalar_face_override_suppresses_inherited_body_maps() {
|
||||
let (mut scene, mut request, options) = mapped_wood_fixture();
|
||||
scene.meshes[0].faces.push(crate::FaceAppearance {
|
||||
face: 0,
|
||||
color: [64, 96, 128],
|
||||
opacity: 1.,
|
||||
surface: Some(OpaqueSurface::new(Vec3::new(0.2, 0.3, 0.4), 0., 0.7, 1.).unwrap()),
|
||||
transmission: None,
|
||||
});
|
||||
let mapped = mapped_png(scene.clone(), request.clone(), options.clone(), 1);
|
||||
request.textures = None;
|
||||
assert_eq!(mapped, mapped_png(scene, request, options, 7));
|
||||
}
|
||||
#[test]
|
||||
fn cpu_wood_footprint_uses_unwrapped_physical_spacing_and_applies_supersampling_once() {
|
||||
let (_, request, mut options) = mapped_wood_fixture();
|
||||
let clip = request.camera.clip(100.);
|
||||
let ray = primary_ray(&request, 0.5, 0.5, clip).unwrap();
|
||||
let point = ray.origin() + ray.direction() * (-ray.origin().z / ray.direction().z);
|
||||
let first = primary_footprint(&request, &options, None, 0, 0, clip, point, Vec3::Z).unwrap().unwrap();
|
||||
assert!((first[0].len() - 0.125).abs() < 1e-12);
|
||||
assert!((first[1].len() - 0.125).abs() < 1e-12);
|
||||
for samples in [4, 16] {
|
||||
options.primary_samples = samples;
|
||||
let offset = primary_offset(samples, 0).unwrap();
|
||||
let ray = primary_ray(&request, offset[0], offset[1], clip).unwrap();
|
||||
let p = ray.origin() + ray.direction() * (-ray.origin().z / ray.direction().z);
|
||||
let footprint = primary_footprint(&request, &options, None, 0, 0, clip, p, Vec3::Z).unwrap().unwrap();
|
||||
for delta in footprint {
|
||||
assert!((delta.len() - 0.125 / f64::from(samples).sqrt()).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn cpu_wood_preparation_cancel_and_role_work_limit_never_publish_partial_pixels() {
|
||||
let (scene, request, options) = mapped_wood_fixture();
|
||||
let cancel = CancelToken::default();
|
||||
let mut task = ImageTask::new(scene, request, cancel.clone(), options).unwrap();
|
||||
let cache = task.maps.first().unwrap();
|
||||
assert!(!cache.ready());
|
||||
assert_eq!(cache.prepared_maps(), 0);
|
||||
assert_eq!(cache.preparation_phase(), None);
|
||||
for _ in 0..100_000 {
|
||||
if task.maps.first().is_some_and(|cache| cache.hit_role() == Some(1)) {
|
||||
break;
|
||||
}
|
||||
assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
|
||||
}
|
||||
assert_eq!(task.maps.first().unwrap().prepared_maps(), 1);
|
||||
assert_eq!(task.maps.first().unwrap().hit_role(), Some(1));
|
||||
assert!(task.pixels.is_empty());
|
||||
task.work.limit = task.work.used();
|
||||
assert_eq!(task.step(1).err().unwrap().code, ErrorCode::LimitExceeded);
|
||||
assert_eq!(task.maps.first().unwrap().hit_role(), Some(1));
|
||||
cancel.cancel();
|
||||
assert_eq!(task.step(1).err().unwrap().code, ErrorCode::Cancelled);
|
||||
assert!(!task.done);
|
||||
}
|
||||
@@ -9,6 +9,8 @@ pub struct PreparedPrimitive {
|
||||
pub body_index: usize,
|
||||
/// Authoritative captured mesh face tag; never inferred from a plane.
|
||||
pub face: Option<u32>,
|
||||
/// Explicit scalar face appearance suppresses inherited body maps.
|
||||
pub has_face_override: bool,
|
||||
pub vertices: [Vec3; 3],
|
||||
pub normals: [Vec3; 3],
|
||||
pub surface: OpaqueSurface,
|
||||
|
||||
@@ -274,8 +274,14 @@ fn prepared_hit_preserves_original_winding_identity_and_never_certifies_glass()
|
||||
let mut builder = BvhBuilder::new(vec![triangle], limits(job::CpuBudget::Compact), &cancel).unwrap();
|
||||
while !builder.step(1, &cancel).unwrap().complete {}
|
||||
let bvh = builder.finish().unwrap();
|
||||
let primitives =
|
||||
[PreparedPrimitive { body_index: 0, face: Some(0), vertices, normals: [geometry.geometric_normal; 3], surface: primitive.surface.unwrap() }];
|
||||
let primitives = [PreparedPrimitive {
|
||||
has_face_override: false,
|
||||
body_index: 0,
|
||||
face: Some(0),
|
||||
vertices,
|
||||
normals: [geometry.geometric_normal; 3],
|
||||
surface: primitive.surface.unwrap(),
|
||||
}];
|
||||
let prepared = PreparedScene { bvh: &bvh, primitives: &primitives, source: &source };
|
||||
let shared = prepared.resolve_hit(ray, super::super::Hit { primitive_index: 0, geometry }).unwrap();
|
||||
assert_eq!(shared.body_name, "Mirror");
|
||||
@@ -291,6 +297,7 @@ fn shared_spawn_excludes_signed_zero_self_hit_without_skipping_thin_blockers() {
|
||||
let ray = Ray::new(Vec3::new(x + 5.0, 5.0, 10.0), -Vec3::Z, 0.0, 20.0).unwrap();
|
||||
let geometry = triangle.intersect(&ray).unwrap();
|
||||
let primitive = PreparedPrimitive {
|
||||
has_face_override: false,
|
||||
body_index: 0,
|
||||
face: Some(0),
|
||||
vertices,
|
||||
|
||||
@@ -5,10 +5,11 @@ use serde::{Deserialize, Serialize};
|
||||
use std::{io::Cursor, sync::Arc};
|
||||
|
||||
pub mod normalization;
|
||||
pub(crate) mod png_metadata;
|
||||
|
||||
pub const MAX_TEXTURE_BYTES: usize = 16 * 1024 * 1024;
|
||||
pub const MAX_TEXTURE_EDGE: u32 = 2048;
|
||||
const MAX_CODEC_BYTES: u64 = 64 * 1024 * 1024;
|
||||
pub(crate) const MAX_CODEC_BYTES: u64 = 64 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -54,6 +55,15 @@ pub struct TextureInfo {
|
||||
pub interpretation: Interpretation,
|
||||
}
|
||||
|
||||
/// An explicit base-color role means normalized sRGB samples. Reject conflicting
|
||||
/// encoded declarations; data roles preserve their numerical channels unchanged.
|
||||
pub fn validate_color_declarations(bytes: &[u8], role: MapRole) -> Result<()> {
|
||||
if role == MapRole::BaseColor && bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
png_metadata::validate_srgb_declarations(bytes)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Header-admitted source. Full decoding must succeed before publishing pixels.
|
||||
#[derive(Clone)]
|
||||
pub struct TextureSource {
|
||||
@@ -120,11 +130,42 @@ impl TextureSource {
|
||||
let info = TextureInfo { width, height, encoded_bytes: bytes.len(), rgba_bytes, role, interpretation: role.interpretation() };
|
||||
Ok(Self { bytes: Arc::new(bytes), format, info })
|
||||
}
|
||||
/// Admit a portable source with strict PNG metadata and color declarations
|
||||
/// before opening its codec. Legacy runtime inputs keep `new`'s admission.
|
||||
pub fn new_portable(bytes: Vec<u8>, role: MapRole) -> Result<Self> {
|
||||
if admitted_format(&bytes, bytes.capacity())? == ImageFormat::Png {
|
||||
png_metadata::validate(&bytes)?;
|
||||
validate_color_declarations(&bytes, role)?;
|
||||
}
|
||||
Self::new(bytes, role)
|
||||
}
|
||||
pub fn info(&self) -> &TextureInfo {
|
||||
&self.info
|
||||
}
|
||||
/// Complete static-source metadata admission before portable publication.
|
||||
/// This bounded scan belongs on the caller's existing import worker. It does
|
||||
/// not replace pixel decoding or the cooperative image worker's own checks.
|
||||
pub fn validate_static_source(&self) -> Result<()> {
|
||||
if self.format == ImageFormat::Png {
|
||||
let mut metadata = png_metadata::PngMetadata::new(true, true).with_role(self.info.role);
|
||||
while !metadata.step(&self.bytes)? {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Share admitted encoded ownership with the cooperative native row worker.
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
pub(crate) fn png_encoded(&self) -> Result<Arc<Vec<u8>>> {
|
||||
if self.format != ImageFormat::Png || self.bytes.get(24).copied() != Some(8) || self.bytes.get(28).copied() != Some(0) {
|
||||
return Err(invalid("CPU row preparation supports noninterlaced 8-bit PNG only"));
|
||||
}
|
||||
Ok(self.bytes.clone())
|
||||
}
|
||||
/// Bounded whole-image codec work, intended for a caller-owned worker. Cancellation is
|
||||
/// checked at the codec boundary, not during PNG/JPEG decoding. No partial image escapes.
|
||||
#[cfg(all(test, feature = "raytrace-cpu"))]
|
||||
pub(crate) fn encoded_weak(&self) -> std::sync::Weak<Vec<u8>> {
|
||||
Arc::downgrade(&self.bytes)
|
||||
}
|
||||
pub fn decode(&self, cancelled: impl Fn() -> bool) -> Result<DecodedTexture> {
|
||||
if cancelled() {
|
||||
return Err(invalid("texture decoding cancelled"));
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! Allocation-free PNG metadata policy and cooperative source validation.
|
||||
//! The CPU worker checks every original chunk CRC before opening its row codec.
|
||||
//! Header admission uses the same ICC/EXIF/order policy without decoding pixels.
|
||||
use super::{MapRole, Result, invalid};
|
||||
|
||||
pub(crate) const BLOCK_BYTES: usize = 4096;
|
||||
// png0.18.1 uses a128KiB row-ring shift threshold and18KiB boxed
|
||||
// Huffman tables. This covers ring growth/relocation, bounded rows,
|
||||
// palette and fixed parser metadata for the admitted texture dimensions;
|
||||
// variable chunk/text/EXIF allocations are counted separately below.
|
||||
const FIXED_CODEC_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
|
||||
const BEFORE_DATA: [[u8; 4]; 15] = [
|
||||
*b"cHRM", *b"cICP", *b"gAMA", *b"mDCV", *b"cLLI", *b"sBIT", *b"sRGB", *b"bKGD", *b"hIST", *b"tRNS", *b"pHYs", *b"sPLT", *b"PLTE", *b"eXIf",
|
||||
*b"iCCP",
|
||||
];
|
||||
const fn crc_table() -> [u32; 256] {
|
||||
let mut table = [0; 256];
|
||||
let mut i = 0;
|
||||
while i < 256 {
|
||||
let mut value = i as u32;
|
||||
let mut bit = 0;
|
||||
while bit < 8 {
|
||||
value = (value >> 1) ^ (0xedb8_8320_u32.wrapping_mul(value & 1));
|
||||
bit += 1;
|
||||
}
|
||||
table[i] = value;
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
}
|
||||
const CRC_TABLE: [u32; 256] = crc_table();
|
||||
fn be32(bytes: &[u8], at: usize) -> Result<u32> {
|
||||
let end = at.checked_add(4).ok_or_else(|| invalid("PNG metadata offset overflow"))?;
|
||||
let value: [u8; 4] =
|
||||
bytes.get(at..end).ok_or_else(|| invalid("PNG metadata is truncated"))?.try_into().map_err(|_| invalid("PNG metadata word"))?;
|
||||
Ok(u32::from_be_bytes(value))
|
||||
}
|
||||
fn word(bytes: &[u8], at: usize, length: usize, little: bool) -> Result<u32> {
|
||||
let end = at.checked_add(length).ok_or_else(|| invalid("PNG EXIF offset overflow"))?;
|
||||
let value = bytes.get(at..end).ok_or_else(|| invalid("PNG EXIF is truncated"))?;
|
||||
let mut output = 0_u32;
|
||||
for (index, byte) in value.iter().enumerate() {
|
||||
let shift = if little { index } else { length - index - 1 };
|
||||
output |= u32::from(*byte) << (shift * 8);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum Phase {
|
||||
Header,
|
||||
Bytes { next: usize, crc: u32 },
|
||||
ExifHeader,
|
||||
ExifEntry { next: usize, remaining: u16, little: bool, orientation_seen: bool },
|
||||
Done,
|
||||
}
|
||||
/// Fixed metadata only. No byte copy, map decode, recursion or caller-owned lease.
|
||||
pub(crate) struct PngMetadata {
|
||||
phase: Phase,
|
||||
position: usize,
|
||||
kind: [u8; 4],
|
||||
data_start: usize,
|
||||
data_end: usize,
|
||||
chunk_end: usize,
|
||||
data_seen: bool,
|
||||
data_ended: bool,
|
||||
palette_seen: bool,
|
||||
exif_seen: bool,
|
||||
time_seen: bool,
|
||||
cpu: bool,
|
||||
verify_crc: bool,
|
||||
srgb: bool,
|
||||
color_seen: u8,
|
||||
raw_capacity: u64,
|
||||
exif_bytes: u64,
|
||||
text_payload: u64,
|
||||
text_counts: [u64; 3],
|
||||
}
|
||||
impl PngMetadata {
|
||||
pub(crate) fn new(cpu: bool, verify_crc: bool) -> Self {
|
||||
Self {
|
||||
phase: Phase::Header,
|
||||
position: 8,
|
||||
kind: [0; 4],
|
||||
data_start: 0,
|
||||
data_end: 0,
|
||||
chunk_end: 0,
|
||||
data_seen: false,
|
||||
data_ended: false,
|
||||
palette_seen: false,
|
||||
exif_seen: false,
|
||||
time_seen: false,
|
||||
cpu,
|
||||
verify_crc,
|
||||
srgb: false,
|
||||
color_seen: 0,
|
||||
raw_capacity: 128,
|
||||
exif_bytes: 0,
|
||||
text_payload: 0,
|
||||
text_counts: [0; 3],
|
||||
}
|
||||
}
|
||||
/// Apply role-specific declarations during the existing bounded header steps.
|
||||
pub(crate) fn with_role(mut self, role: MapRole) -> Self {
|
||||
self.srgb = role == MapRole::BaseColor;
|
||||
self
|
||||
}
|
||||
fn text_capacities(&self) -> Result<[u64; 3]> {
|
||||
let mut capacities = [0; 3];
|
||||
for (out, count) in capacities.iter_mut().zip(self.text_counts) {
|
||||
*out = if count == 0 { 0 } else { count.checked_next_power_of_two().ok_or_else(|| invalid("PNG text capacity overflow"))?.max(4) };
|
||||
}
|
||||
Ok(capacities)
|
||||
}
|
||||
fn text_backing(&self) -> Result<u64> {
|
||||
// ISO8859-1 expands to at most2UTF8 bytes per source byte. Up to
|
||||
// four owned strings/vectors per text record also get their small
|
||||
// growth backing; compressed text stays compressed in these callers.
|
||||
self.text_payload
|
||||
.checked_mul(2)
|
||||
.and_then(|n| n.checked_add(self.text_counts.into_iter().zip([16_u64, 16, 32]).map(|(count, backing)| count * backing).sum::<u64>()))
|
||||
.ok_or_else(|| invalid("PNG text storage overflow"))
|
||||
}
|
||||
fn codec_storage(&mut self, length: usize) -> Result<()> {
|
||||
use png::text_metadata::{ITXtChunk, TEXtChunk, ZTXtChunk};
|
||||
let sizes = [std::mem::size_of::<TEXtChunk>() as u64, std::mem::size_of::<ZTXtChunk>() as u64, std::mem::size_of::<ITXtChunk>() as u64];
|
||||
let prior = self.text_capacities()?;
|
||||
let vector_bytes = |counts: [u64; 3]| counts.into_iter().zip(sizes).map(|(n, size)| n * size).sum::<u64>();
|
||||
let raw = (length as u64).checked_next_power_of_two().ok_or_else(|| invalid("PNG raw capacity overflow"))?.max(128).max(self.raw_capacity);
|
||||
// The chunk buffer grows by doubling from128. Include the old
|
||||
// allocation during its final growth before any new parsed value exists.
|
||||
let growth = if raw > self.raw_capacity { raw + raw / 2 } else { raw };
|
||||
let growing = FIXED_CODEC_BYTES + growth + self.exif_bytes + self.text_backing()? + vector_bytes(prior);
|
||||
if growing > super::MAX_CODEC_BYTES {
|
||||
return Err(invalid("PNG metadata exceeds the existing 64 MiB codec allowance"));
|
||||
}
|
||||
self.raw_capacity = raw;
|
||||
if self.kind == *b"eXIf" {
|
||||
self.exif_bytes = length as u64;
|
||||
}
|
||||
let text = [*b"tEXt", *b"zTXt", *b"iTXt"].iter().position(|kind| *kind == self.kind);
|
||||
if let Some(index) = text {
|
||||
self.text_counts[index] += 1;
|
||||
self.text_payload += length as u64;
|
||||
}
|
||||
let current = self.text_capacities()?;
|
||||
let old_vectors = prior.into_iter().zip(current).zip(sizes).filter_map(|((old, new), size)| (new > old).then_some(old * size)).sum::<u64>();
|
||||
// Latin1 tEXt collect reserves its input character count, then may
|
||||
// grow while emitting two UTF8 bytes per character. Keep the old
|
||||
// current-record backing too; final String capacity alone is not peak.
|
||||
// zTXt/iTXt only use Latin1 for their bounded keyword; their payload
|
||||
// is copied with exact capacity or retained compressed, never inflated.
|
||||
let string_growth = match text {
|
||||
Some(0) => length as u64 + 256,
|
||||
Some(_) => 256,
|
||||
None => 0,
|
||||
};
|
||||
let parsed = FIXED_CODEC_BYTES + raw + self.exif_bytes + self.text_backing()? + vector_bytes(current) + old_vectors + string_growth;
|
||||
// image's orientation getter owns another EXIF copy after read_info.
|
||||
let getters = FIXED_CODEC_BYTES + raw + 2 * self.exif_bytes + self.text_backing()? + vector_bytes(current);
|
||||
if parsed.max(getters) > super::MAX_CODEC_BYTES {
|
||||
return Err(invalid("PNG metadata exceeds the existing 64 MiB codec allowance"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// One header, at most4096 CRC bytes, EXIF header, or one IFD entry.
|
||||
/// The owner debits and checks cancellation before each call.
|
||||
pub(crate) fn step(&mut self, bytes: &[u8]) -> Result<bool> {
|
||||
match self.phase {
|
||||
Phase::Done => return Ok(true),
|
||||
Phase::Header => {
|
||||
if bytes.len() > super::MAX_TEXTURE_BYTES {
|
||||
return Err(invalid("texture input must contain at most 16 MiB"));
|
||||
}
|
||||
if bytes.get(..8) != Some(SIGNATURE.as_slice()) {
|
||||
return Err(invalid("PNG signature missing"));
|
||||
}
|
||||
let length = be32(bytes, self.position)? as usize;
|
||||
let type_start = self.position.checked_add(4).ok_or_else(|| invalid("PNG chunk offset overflow"))?;
|
||||
self.data_start = type_start.checked_add(4).ok_or_else(|| invalid("PNG chunk offset overflow"))?;
|
||||
self.kind = bytes
|
||||
.get(type_start..self.data_start)
|
||||
.ok_or_else(|| invalid("PNG chunk type missing"))?
|
||||
.try_into()
|
||||
.map_err(|_| invalid("PNG chunk type"))?;
|
||||
self.data_end = self.data_start.checked_add(length).ok_or_else(|| invalid("PNG chunk size overflow"))?;
|
||||
self.chunk_end = self.data_end.checked_add(4).filter(|end| *end <= bytes.len()).ok_or_else(|| invalid("PNG chunk exceeds source"))?;
|
||||
if !self.kind.iter().all(u8::is_ascii_alphabetic) || !self.kind[2].is_ascii_uppercase() {
|
||||
return Err(invalid("PNG chunk type is invalid"));
|
||||
}
|
||||
if (self.position == 8 && (self.kind != *b"IHDR" || length != 13)) || (self.position != 8 && self.kind == *b"IHDR") {
|
||||
return Err(invalid("PNG requires one first IHDR"));
|
||||
}
|
||||
if self.srgb {
|
||||
let data = bytes.get(self.data_start..self.data_end).ok_or_else(|| invalid("PNG color metadata missing"))?;
|
||||
validate_srgb_chunk(&self.kind, data, &mut self.color_seen)?;
|
||||
}
|
||||
// Refuse presence, not just a successfully decoded ICC profile. png can
|
||||
// ignore an inflate error after a large temporary allocation.
|
||||
if self.kind == *b"iCCP" {
|
||||
return Err(invalid("embedded ICC profiles need explicit color conversion before admission"));
|
||||
}
|
||||
if self.cpu && [*b"acTL", *b"fcTL", *b"fdAT"].contains(&self.kind) {
|
||||
return Err(invalid("CPU row preparation requires a static PNG"));
|
||||
}
|
||||
if self.kind[0].is_ascii_uppercase() && ![*b"IHDR", *b"PLTE", *b"IDAT", *b"IEND"].contains(&self.kind) {
|
||||
return Err(invalid("PNG has an unknown critical chunk"));
|
||||
}
|
||||
if !self.data_seen && self.kind != *b"IDAT" {
|
||||
self.codec_storage(length)?;
|
||||
}
|
||||
if self.kind == *b"IDAT" {
|
||||
if self.data_ended {
|
||||
return Err(invalid("PNG IDAT chunks must be consecutive"));
|
||||
}
|
||||
self.data_seen = true;
|
||||
} else if self.data_seen {
|
||||
self.data_ended = true;
|
||||
if BEFORE_DATA.contains(&self.kind) {
|
||||
return Err(invalid("PNG metadata must precede IDAT"));
|
||||
}
|
||||
}
|
||||
if self.kind == *b"PLTE" {
|
||||
if self.palette_seen || length == 0 || length > 768 || !length.is_multiple_of(3) {
|
||||
return Err(invalid("PNG palette is invalid"));
|
||||
}
|
||||
self.palette_seen = true;
|
||||
}
|
||||
if self.kind == *b"eXIf" {
|
||||
if self.exif_seen {
|
||||
return Err(invalid("PNG has duplicate EXIF metadata"));
|
||||
}
|
||||
self.exif_seen = true;
|
||||
}
|
||||
if self.kind == *b"tIME" {
|
||||
if self.time_seen || length != 7 {
|
||||
return Err(invalid("PNG time metadata is invalid"));
|
||||
}
|
||||
self.time_seen = true;
|
||||
}
|
||||
if self.kind == *b"IEND" && (length != 0 || self.chunk_end != bytes.len() || !self.data_seen) {
|
||||
return Err(invalid("PNG requires one final IEND after image data"));
|
||||
}
|
||||
self.phase = Phase::Bytes { next: type_start, crc: u32::MAX };
|
||||
}
|
||||
Phase::Bytes { next, mut crc } => {
|
||||
let end = if self.verify_crc { next.saturating_add(BLOCK_BYTES).min(self.data_end) } else { self.data_end };
|
||||
if self.verify_crc {
|
||||
for byte in bytes.get(next..end).ok_or_else(|| invalid("PNG CRC range"))? {
|
||||
crc = (crc >> 8) ^ CRC_TABLE[((crc ^ u32::from(*byte)) & 255) as usize];
|
||||
}
|
||||
}
|
||||
if end != self.data_end {
|
||||
self.phase = Phase::Bytes { next: end, crc };
|
||||
} else {
|
||||
if self.verify_crc && !crc != be32(bytes, self.data_end)? {
|
||||
return Err(invalid("PNG chunk CRC is invalid"));
|
||||
}
|
||||
self.position = self.chunk_end;
|
||||
self.phase = if self.kind == *b"IEND" {
|
||||
Phase::Done
|
||||
} else if self.kind == *b"eXIf" {
|
||||
Phase::ExifHeader
|
||||
} else {
|
||||
Phase::Header
|
||||
};
|
||||
}
|
||||
}
|
||||
Phase::ExifHeader => {
|
||||
let exif = bytes.get(self.data_start..self.data_end).ok_or_else(|| invalid("PNG EXIF range"))?;
|
||||
let little = match exif.get(..2) {
|
||||
Some(b"II") => true,
|
||||
Some(b"MM") => false,
|
||||
_ => return Err(invalid("PNG EXIF byte order is invalid")),
|
||||
};
|
||||
if word(exif, 2, 2, little)? != 42 {
|
||||
return Err(invalid("PNG EXIF TIFF header is invalid"));
|
||||
}
|
||||
let start = word(exif, 4, 4, little)? as usize;
|
||||
if start < 8 {
|
||||
return Err(invalid("PNG EXIF IFD offset is invalid"));
|
||||
}
|
||||
let count = word(exif, start, 2, little)? as u16;
|
||||
let next = start.checked_add(2).ok_or_else(|| invalid("PNG EXIF IFD overflow"))?;
|
||||
let end = usize::from(count)
|
||||
.checked_mul(12)
|
||||
.and_then(|n| next.checked_add(n))
|
||||
.and_then(|n| n.checked_add(4))
|
||||
.ok_or_else(|| invalid("PNG EXIF IFD overflow"))?;
|
||||
if end > exif.len() {
|
||||
return Err(invalid("PNG EXIF IFD is truncated"));
|
||||
}
|
||||
self.phase = Phase::ExifEntry { next, remaining: count, little, orientation_seen: false };
|
||||
}
|
||||
Phase::ExifEntry { next, remaining, little, mut orientation_seen } => {
|
||||
if remaining == 0 {
|
||||
self.phase = Phase::Header;
|
||||
return Ok(false);
|
||||
}
|
||||
let exif = bytes.get(self.data_start..self.data_end).ok_or_else(|| invalid("PNG EXIF range"))?;
|
||||
if word(exif, next, 2, little)? == 0x112 {
|
||||
if orientation_seen || word(exif, next + 2, 2, little)? != 3 || word(exif, next + 4, 4, little)? != 1 {
|
||||
return Err(invalid("PNG EXIF orientation tag is invalid"));
|
||||
}
|
||||
if word(exif, next + 8, 2, little)? != 1 {
|
||||
return Err(invalid("texture orientation must be normalized before admission"));
|
||||
}
|
||||
orientation_seen = true;
|
||||
}
|
||||
self.phase = Phase::ExifEntry { next: next + 12, remaining: remaining - 1, little, orientation_seen };
|
||||
}
|
||||
}
|
||||
Ok(matches!(self.phase, Phase::Done))
|
||||
}
|
||||
#[cfg(all(test, feature = "raytrace-cpu"))]
|
||||
pub(crate) fn phase_name(&self) -> &'static str {
|
||||
match self.phase {
|
||||
Phase::Header => "metadata_header",
|
||||
Phase::Bytes { .. } => "metadata_bytes",
|
||||
Phase::ExifHeader => "metadata_exif_header",
|
||||
Phase::ExifEntry { .. } => "metadata_ifd",
|
||||
Phase::Done => "metadata_done",
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Shared capture policy: check unsupported metadata before a codec can allocate.
|
||||
/// Pixel/CRC decode remains a distinct later operation, as before.
|
||||
pub(crate) fn validate(bytes: &[u8]) -> Result<()> {
|
||||
let mut scan = PngMetadata::new(false, false);
|
||||
while !scan.step(bytes)? {}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Role-aware encoded declaration check, separate from the common data/CRC policy.
|
||||
/// Untagged bytes are explicitly interpreted as sRGB by the caller's base-color role.
|
||||
pub(crate) fn validate_srgb_declarations(bytes: &[u8]) -> Result<()> {
|
||||
let mut offset = 8usize;
|
||||
let mut seen = 0u8;
|
||||
while offset < bytes.len() {
|
||||
let length = be32(bytes, offset)? as usize;
|
||||
let kind_start = offset.checked_add(4).ok_or_else(|| invalid("PNG color metadata offset overflow"))?;
|
||||
let start = kind_start.checked_add(4).ok_or_else(|| invalid("PNG color metadata offset overflow"))?;
|
||||
let end = start.checked_add(length).filter(|end| *end <= bytes.len()).ok_or_else(|| invalid("PNG color metadata exceeds source"))?;
|
||||
let kind = bytes.get(kind_start..start).ok_or_else(|| invalid("PNG color metadata kind missing"))?;
|
||||
let data = bytes.get(start..end).ok_or_else(|| invalid("PNG color metadata missing"))?;
|
||||
validate_srgb_chunk(kind, data, &mut seen)?;
|
||||
offset = end.checked_add(4).filter(|end| *end <= bytes.len()).ok_or_else(|| invalid("PNG color metadata trailer missing"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_srgb_chunk(kind: &[u8], data: &[u8], seen: &mut u8) -> Result<()> {
|
||||
let (bit, valid) = match kind {
|
||||
b"gAMA" => (1, data.len() == 4 && be32(data, 0)? == 45_455),
|
||||
b"cHRM" => {
|
||||
let standard = [31_270, 32_900, 64_000, 33_000, 30_000, 60_000, 15_000, 6_000];
|
||||
let valid = data.len() == 32 && standard.into_iter().enumerate().all(|(i, value)| be32(data, i * 4).is_ok_and(|actual| actual == value));
|
||||
(2, valid)
|
||||
}
|
||||
b"sRGB" => (4, data.len() == 1 && data.first().is_some_and(|intent| *intent <= 3)),
|
||||
// H.273: BT.709 primaries, IEC61966-2-1 transfer, RGB identity matrix,
|
||||
// full range. Other transfer/primaries require an explicit conversion.
|
||||
b"cICP" => (8, data == [1, 13, 0, 1]),
|
||||
b"mDCV" | b"cLLI" => (16, false),
|
||||
_ => (0, true),
|
||||
};
|
||||
if !valid || (bit != 0 && *seen & bit != 0) {
|
||||
return Err(invalid(
|
||||
"base-color PNG requires normalized sRGB gAMA/cHRM/cICP declarations without conflicting, duplicate or HDR metadata; normalize the image before import",
|
||||
));
|
||||
}
|
||||
*seen |= bit;
|
||||
Ok(())
|
||||
}
|
||||
@@ -63,3 +63,68 @@ fn texture_input_caps_encoded_storage_and_refuses_precision_loss() {
|
||||
image.write_to(&mut out, ImageFormat::Png).unwrap();
|
||||
assert!(TextureSource::new(out.into_inner(), MapRole::NormalOpenGl).is_err());
|
||||
}
|
||||
|
||||
fn declaration_chunk(kind: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut result = (data.len() as u32).to_be_bytes().to_vec();
|
||||
result.extend(kind);
|
||||
result.extend(data);
|
||||
let mut crc = u32::MAX;
|
||||
for byte in kind.iter().chain(data) {
|
||||
crc ^= u32::from(*byte);
|
||||
for _ in 0..8 {
|
||||
crc = (crc >> 1) ^ (0xedb8_8320_u32.wrapping_mul(crc & 1));
|
||||
}
|
||||
}
|
||||
result.extend((!crc).to_be_bytes());
|
||||
result
|
||||
}
|
||||
fn declared_image(declarations: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||
let mut bytes = png(2, 2);
|
||||
let chunks: Vec<_> = declarations.iter().flat_map(|(kind, data)| declaration_chunk(kind, data)).collect();
|
||||
bytes.splice(33..33, chunks);
|
||||
bytes
|
||||
}
|
||||
#[test]
|
||||
fn legacy_gamma_admission_preserves_encoded_pixels_and_gpu_map_interpretation() {
|
||||
let bytes = declared_image(&[(b"gAMA", 100_000_u32.to_be_bytes().to_vec())]);
|
||||
let legacy = TextureSource::new(bytes.clone(), MapRole::BaseColor).unwrap();
|
||||
assert_eq!(legacy.decode(|| false).unwrap().rgba(), [64_u8, 128, 192, 255].repeat(4));
|
||||
let map = crate::job::textures::TextureMap::new(bytes.clone(), MapRole::BaseColor).unwrap();
|
||||
let plain = crate::job::textures::TextureMap::new(png(2, 2), MapRole::BaseColor).unwrap();
|
||||
let cancel = crate::job::CancelToken::default();
|
||||
assert_eq!(map.decode(&cancel).unwrap(), plain.decode(&cancel).unwrap());
|
||||
assert!(TextureSource::new_portable(bytes, MapRole::BaseColor).err().unwrap().to_string().contains("normalized sRGB"));
|
||||
}
|
||||
#[test]
|
||||
fn normalized_srgb_declarations_are_accepted_without_reencoding_samples() {
|
||||
let chromaticities = [31_270_u32, 32_900, 64_000, 33_000, 30_000, 60_000, 15_000, 6_000].into_iter().flat_map(u32::to_be_bytes).collect();
|
||||
let declarations = [(b"gAMA", 45_455_u32.to_be_bytes().to_vec()), (b"cHRM", chromaticities), (b"sRGB", vec![0]), (b"cICP", vec![1, 13, 0, 1])];
|
||||
for declaration in &declarations {
|
||||
let bytes = declared_image(std::slice::from_ref(declaration));
|
||||
let source = TextureSource::new_portable(bytes, MapRole::BaseColor).unwrap();
|
||||
source.validate_static_source().unwrap();
|
||||
assert!(source.decode(|| false).unwrap().rgba().as_chunks::<4>().0.iter().all(|pixel| *pixel == [64, 128, 192, 255]));
|
||||
}
|
||||
let source = TextureSource::new_portable(declared_image(&declarations), MapRole::BaseColor).unwrap();
|
||||
assert_eq!(source.decode(|| false).unwrap().rgba().len(), 16);
|
||||
}
|
||||
#[test]
|
||||
fn conflicting_png_color_declarations_refuse_color_but_preserve_explicit_linear_data() {
|
||||
let chromaticities = [31_270_u32, 32_900, 64_000, 33_000, 30_000, 50_000, 15_000, 6_000].into_iter().flat_map(u32::to_be_bytes).collect();
|
||||
for declaration in [(b"gAMA", 100_000_u32.to_be_bytes().to_vec()), (b"cHRM", chromaticities), (b"cICP", vec![1, 16, 0, 1])] {
|
||||
let bytes = declared_image(std::slice::from_ref(&declaration));
|
||||
assert!(TextureSource::new_portable(bytes.clone(), MapRole::BaseColor).err().unwrap().to_string().contains("normalized sRGB"));
|
||||
for role in [MapRole::NormalOpenGl, MapRole::Roughness, MapRole::Metallic, MapRole::AmbientOcclusion] {
|
||||
let source = TextureSource::new_portable(bytes.clone(), role).unwrap();
|
||||
assert_eq!(source.decode(|| false).unwrap().rgba().get(..4), Some([64, 128, 192, 255].as_slice()));
|
||||
}
|
||||
}
|
||||
let duplicate = declared_image(&[(b"gAMA", 45_455_u32.to_be_bytes().to_vec()), (b"gAMA", 45_455_u32.to_be_bytes().to_vec())]);
|
||||
assert!(TextureSource::new_portable(duplicate.clone(), MapRole::BaseColor).is_err());
|
||||
let mut metadata = png_metadata::PngMetadata::new(true, true).with_role(MapRole::BaseColor);
|
||||
let error = (0..100).find_map(|_| metadata.step(&duplicate).err()).unwrap();
|
||||
assert!(error.to_string().contains("normalized sRGB"), "cooperative color checks retain duplicate-declaration state between steps");
|
||||
for kind in [b"mDCV", b"cLLI"] {
|
||||
assert!(validate_color_declarations(&declared_image(&[(kind, vec![0; 24])]), MapRole::BaseColor).is_err());
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,11 @@ pub struct TextureView<'a> {
|
||||
height: usize,
|
||||
}
|
||||
impl<'a> TextureView<'a> {
|
||||
/// Borrow the admitted linear RGBA storage without a copy or another scan.
|
||||
pub fn rgba(&self) -> &'a [f32] {
|
||||
self.rgba
|
||||
}
|
||||
|
||||
pub fn new(rgba: &'a [f32], dimensions: [u32; 2]) -> Result<Self, SamplingError> {
|
||||
let [width, height] = dimensions;
|
||||
if width == 0 || height == 0 || width > MAX_TEXTURE_EDGE || height > MAX_TEXTURE_EDGE {
|
||||
@@ -54,6 +59,18 @@ impl<'a> TextureView<'a> {
|
||||
Ok(Self { rgba, width, height })
|
||||
}
|
||||
|
||||
/// Shape-only borrow of immutable storage validated incrementally by its builder.
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
pub(crate) fn from_admitted(rgba: &'a [f32], dimensions: [u32; 2]) -> Result<Self, SamplingError> {
|
||||
let [width, height] = dimensions;
|
||||
if width == 0 || height == 0 || width > MAX_TEXTURE_EDGE || height > MAX_TEXTURE_EDGE {
|
||||
return Err(SamplingError::Dimensions);
|
||||
}
|
||||
if rgba.len() != width as usize * height as usize * 4 {
|
||||
return Err(SamplingError::Length);
|
||||
}
|
||||
Ok(Self { rgba, width: width as usize, height: height as usize })
|
||||
}
|
||||
/// Bilinear level-zero RGB for an explicitly opaque consumer. Alpha is validated
|
||||
/// but deliberately ignored (even zero-alpha RGB participates). This is NOT
|
||||
/// transparent compositing or premultiplied-alpha filtering.
|
||||
|
||||
@@ -166,7 +166,7 @@ fn required_bytes(mut width: usize, mut height: usize) -> Result<usize, MipError
|
||||
if width == 1 && height == 1 { Ok(bytes) } else { Err(MipError::Budget) }
|
||||
}
|
||||
|
||||
fn area_average(source: TextureView<'_>, x: usize, y: usize, width: usize, height: usize) -> Result<[f64; 3], MipError> {
|
||||
pub(crate) fn area_average(source: TextureView<'_>, x: usize, y: usize, width: usize, height: usize) -> Result<[f64; 3], MipError> {
|
||||
// Integer overlap numerators avoid dropping the final row/column at odd sizes.
|
||||
// All factors are bounded by the validated 2048 edge; at most 3x3 source cells.
|
||||
let x_start = x * source.width;
|
||||
|
||||
@@ -749,6 +749,7 @@ pub fn sync_backend(app: &mut SolveApp) {
|
||||
/// Cooperative work belongs to the frame loop, never to a status/control request. Keep
|
||||
/// scheduling frames even after leaving Render so a browser job can finish in Design.
|
||||
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
library::poll_import(app, ctx);
|
||||
if let Some(text) = app.render_workspace.clipboard_text.take() {
|
||||
ctx.copy_text(text);
|
||||
}
|
||||
|
||||
@@ -466,7 +466,7 @@ fn complete_native_studio_can_be_reviewed_and_applied_from_real_ui_controls() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_stage_contains_preview_target_plane_repeat_and_method_reason_in_order() {
|
||||
fn surface_stage_contains_preview_target_plane_repeat_angle_and_method_reason_in_order() {
|
||||
let mut h = fixture();
|
||||
h.call("ui.resize", json!({"width":1600,"height":1800}));
|
||||
h.app.run("select.set", json!({"items":[solidcraft_engine::Sel::Body { name:"Subject".into() }]})).unwrap();
|
||||
@@ -479,11 +479,13 @@ fn surface_stage_contains_preview_target_plane_repeat_and_method_reason_in_order
|
||||
let target = crate::scenario::rect_of("render:surface:target").unwrap();
|
||||
let plane = crate::scenario::rect_of("render:surface:xy").unwrap();
|
||||
let repeat = crate::scenario::rect_of("render:surface:repeat").unwrap();
|
||||
let angle = crate::scenario::rect_of("render:surface:grain-angle").unwrap();
|
||||
let reason = crate::scenario::rect_of("dialog:note:surface:gpu:reason").unwrap();
|
||||
assert!(note.bottom() < target.top());
|
||||
assert!(target.bottom() <= plane.top());
|
||||
assert!(plane.bottom() <= repeat.top());
|
||||
assert!(repeat.bottom() < reason.top());
|
||||
assert!(repeat.bottom() <= angle.top());
|
||||
assert!(angle.bottom() < reason.top());
|
||||
assert_eq!(h.app.render_workspace.view.method, ImageMethod::Raster);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
}
|
||||
@@ -685,13 +687,13 @@ fn saved_optics_removal_requires_confirmation_and_keeps_geometry() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporary_maps_review_keeps_plane_scale_and_requires_acknowledgement() {
|
||||
fn temporary_maps_review_keeps_plane_scale_angle_and_requires_acknowledgement() {
|
||||
let mut h = fixture();
|
||||
super::super::select_image_method(&mut h.app, ImageMethod::GpuRaytrace);
|
||||
let target: materials::Target = serde_json::from_value(h.app.run("render.scene", json!({})).unwrap()["bodies"][0].clone()).unwrap();
|
||||
let plan = h
|
||||
.app
|
||||
.run("render.library.surface.prepare", json!({"id":"SURF-001","body_index":target.ordinal,"body_name":target.name,"plane":"yz","scale":2.5}))
|
||||
.run("render.library.surface.prepare", json!({"id":"SURF-001","body_index":target.ordinal,"body_name":target.name,"plane":"yz","scale":2.5,"rotation_rad":std::f64::consts::FRAC_PI_2}))
|
||||
.unwrap();
|
||||
let before = h.app.session.doc.to_json();
|
||||
library::review::open(
|
||||
@@ -699,7 +701,7 @@ fn temporary_maps_review_keeps_plane_scale_and_requires_acknowledgement() {
|
||||
"maps",
|
||||
"Brushed stainless".into(),
|
||||
plan,
|
||||
library::review::Apply::Maps { id: "SURF-001".into(), target, plane: "yz".into(), scale: 2.5 },
|
||||
library::review::Apply::Maps { id: "SURF-001".into(), target, plane: "yz".into(), scale: 2.5, rotation_rad: std::f64::consts::FRAC_PI_2 },
|
||||
)
|
||||
.unwrap();
|
||||
h.frames(3);
|
||||
@@ -707,8 +709,9 @@ fn temporary_maps_review_keeps_plane_scale_and_requires_acknowledgement() {
|
||||
h.step(&json!({"click":{"handle":"render:review:acknowledge"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:ok"}})).unwrap();
|
||||
let binding = &h.app.render_workspace.view.materials.bindings[0];
|
||||
assert_eq!(binding.maps.frame.u, [0.0, 250.0, 0.0]);
|
||||
assert_eq!(binding.maps.frame.v, [0.0, 0.0, 250.0]);
|
||||
assert!((binding.maps.frame.u[2] - 250.0).abs() < 1e-10);
|
||||
assert!((binding.maps.frame.v[1] + 250.0).abs() < 1e-10);
|
||||
assert!(binding.maps.frame.u[1].abs() < 1e-10 && binding.maps.frame.v[2].abs() < 1e-10);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
}
|
||||
|
||||
@@ -885,21 +888,21 @@ fn temporary_library_maps_preserve_the_reviewed_plane_scale_and_optical_exclusiv
|
||||
let targets = h.app.run("render.scene", json!({})).unwrap();
|
||||
let target: materials::Target = serde_json::from_value(targets["bodies"][0].clone()).unwrap();
|
||||
let before = h.app.session.doc.to_json();
|
||||
materials::install_library_surface(&mut h.app, "SURF-001", target.clone(), "yz", 2.5).unwrap();
|
||||
materials::install_library_surface(&mut h.app, "SURF-001", target.clone(), "yz", 2.5, 0.0).unwrap();
|
||||
let binding = &h.app.render_workspace.view.materials.bindings[0];
|
||||
assert_eq!(binding.maps.frame.u, [0.0, 250.0, 0.0]);
|
||||
assert_eq!(binding.maps.frame.v, [0.0, 0.0, 250.0]);
|
||||
assert_eq!(h.app.session.doc.to_json(), before, "temporary maps must not change the native model");
|
||||
let original = binding.maps.frame;
|
||||
assert!(materials::install_library_surface(&mut h.app, "SURF-001", target.clone(), "unknown", 1.0).is_err());
|
||||
assert!(materials::install_library_surface(&mut h.app, "SURF-001", target.clone(), "xy", 0.0).is_err());
|
||||
assert!(materials::install_library_surface(&mut h.app, "SURF-001", target.clone(), "unknown", 1.0, 0.0).is_err());
|
||||
assert!(materials::install_library_surface(&mut h.app, "SURF-001", target.clone(), "xy", 0.0, 0.0).is_err());
|
||||
for scale in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, f64::MAX] {
|
||||
assert!(materials::install_library_surface(&mut h.app, "SURF-001", target.clone(), "xy", scale).is_err());
|
||||
assert!(materials::install_library_surface(&mut h.app, "SURF-001", target.clone(), "xy", scale, 0.0).is_err());
|
||||
assert_eq!(h.app.render_workspace.view.materials.bindings[0].maps.frame, original);
|
||||
}
|
||||
assert_eq!(h.app.render_workspace.view.materials.bindings[0].maps.frame, original);
|
||||
h.app.render_workspace.view.materials.bindings[0].glass_enabled = true;
|
||||
assert!(materials::install_library_surface(&mut h.app, "SURF-001", target, "xy", 1.0).is_err());
|
||||
assert!(materials::install_library_surface(&mut h.app, "SURF-001", target, "xy", 1.0, 0.0).is_err());
|
||||
assert!(h.app.render_workspace.view.materials.bindings[0].glass_enabled);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
}
|
||||
@@ -981,6 +984,7 @@ fn ribbon_navigation_reuses_inspector_and_preserves_exact_unsaved_intent() {
|
||||
("render.settings.get", Editor::Camera),
|
||||
("render.library.appearance.apply", Editor::Appearance),
|
||||
("render.library.surface.bind", Editor::Appearance),
|
||||
("render.surface.import", Editor::Appearance),
|
||||
("render.library.studio.prepare", Editor::Lighting),
|
||||
("render.library.studio.apply", Editor::Lighting),
|
||||
("render.library.environment.apply", Editor::Environment),
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
//! assignments and saved resources; this view owns only search and request drafts.
|
||||
#[path = "render_workspace_library_review.rs"]
|
||||
pub(super) mod review;
|
||||
#[path = "render_workspace_surface_import.rs"]
|
||||
mod surface_import;
|
||||
|
||||
use super::{ImageMethod, materials, setups};
|
||||
use crate::ui_kit::BackingTooltipExt as _;
|
||||
@@ -53,6 +55,8 @@ pub(super) struct View {
|
||||
preview_error: Option<String>,
|
||||
mapping_plane: usize,
|
||||
mapping_scale: f64,
|
||||
mapping_angle_deg: f64,
|
||||
imported: surface_import::View,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
struct Navigation {
|
||||
@@ -118,6 +122,8 @@ impl Default for View {
|
||||
preview_error: None,
|
||||
mapping_plane: 0,
|
||||
mapping_scale: 1.0,
|
||||
mapping_angle_deg: 0.0,
|
||||
imported: surface_import::View::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -721,6 +727,9 @@ fn surface_panel(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
) {
|
||||
switch_kind(&mut view, if scope == 0 { "surfaces" } else { "physical_profiles" });
|
||||
}
|
||||
if scope == 0 {
|
||||
surface_import::controls(app, ui, &mut view);
|
||||
}
|
||||
catalog_search(app, ui, &mut view, "render.stage_panels.search_surfaces");
|
||||
let layout = ui_kit::CatalogLayout::new(ui, "render:surface:catalog", 3);
|
||||
layout.body(ui, |ui| {
|
||||
@@ -824,6 +833,10 @@ fn surface_controls(app: &mut SolveApp, ui: &mut egui::Ui, view: &mut View, id:
|
||||
let repeat = ui_kit::number_field(ui, &mut view.mapping_scale, 0.001..=1000.0, 0.01, " ×");
|
||||
crate::scenario::publish_target("render:surface:repeat", repeat.rect);
|
||||
});
|
||||
ui_kit::row(ui, app.tr("render.stage_panels.grain_angle"), |ui| {
|
||||
let angle = ui_kit::number_field(ui, &mut view.mapping_angle_deg, -180.0..=180.0, 1.0, "°");
|
||||
crate::scenario::publish_target("render:surface:grain-angle", angle.rect);
|
||||
});
|
||||
let supported = view
|
||||
.details
|
||||
.as_ref()
|
||||
@@ -839,11 +852,14 @@ fn surface_controls(app: &mut SolveApp, ui: &mut egui::Ui, view: &mut View, id:
|
||||
requested = Some(1);
|
||||
}
|
||||
});
|
||||
if ui_kit::button(ui, app.tr("render.stage_panels.save_maps"), ui_kit::Button::Enabled(enabled && gpu && patch(app).is_ok())).clicked() {
|
||||
let raytrace = app.render_workspace.view.method != ImageMethod::Raster;
|
||||
if ui_kit::button(ui, app.tr("render.stage_panels.save_maps"), ui_kit::Button::Enabled(enabled && raytrace && patch(app).is_ok())).clicked() {
|
||||
requested = Some(2);
|
||||
}
|
||||
if !gpu {
|
||||
ui_kit::text(ui, app.tr("render.stage_panels.gpu_required"), ui_kit::Text::Note("surface:gpu:reason"));
|
||||
if !raytrace {
|
||||
ui_kit::text(ui, app.tr("render.surface_import.raytrace_required"), ui_kit::Text::Note("surface:gpu:reason"));
|
||||
} else if !gpu {
|
||||
ui_kit::text(ui, app.tr("render.surface_import.saved_cpu"), ui_kit::Text::Note("surface:cpu:reason"));
|
||||
}
|
||||
if !supported {
|
||||
ui_kit::text(ui, app.tr("render.library_specification_only"), ui_kit::Text::Note("surface:reference:reason"));
|
||||
@@ -861,7 +877,8 @@ fn surface_controls(app: &mut SolveApp, ui: &mut egui::Ui, view: &mut View, id:
|
||||
},
|
||||
)
|
||||
} else {
|
||||
let surface = json!({"id":id,"body_index":target.ordinal,"body_name":target.name,"scale":view.mapping_scale,"plane":plane});
|
||||
let rotation_rad = view.mapping_angle_deg.to_radians();
|
||||
let surface = json!({"id":id,"body_index":target.ordinal,"body_name":target.name,"scale":view.mapping_scale,"plane":plane,"rotation_rad":rotation_rad});
|
||||
let plan = app.run("render.library.surface.prepare", surface.clone())?;
|
||||
let action = if mode == 2 {
|
||||
review::Apply::Command {
|
||||
@@ -869,7 +886,7 @@ fn surface_controls(app: &mut SolveApp, ui: &mut egui::Ui, view: &mut View, id:
|
||||
params: json!({"name":app.render_workspace.view.name,"set":patch(app)?,"surface":surface,"replace":true}),
|
||||
}
|
||||
} else {
|
||||
review::Apply::Maps { id: id.into(), target, plane: plane.into(), scale: view.mapping_scale }
|
||||
review::Apply::Maps { id: id.into(), target, plane: plane.into(), scale: view.mapping_scale, rotation_rad }
|
||||
};
|
||||
(plan, action)
|
||||
};
|
||||
@@ -1517,10 +1534,11 @@ fn saved_surface_content(app: &mut SolveApp, ui: &mut egui::Ui, target: Option<&
|
||||
ui_kit::text(ui, app.tr("render.saved_maps_runtime_conflict"), ui_kit::Text::Note("render.saved_maps_runtime_conflict"));
|
||||
}
|
||||
for (index, binding) in bindings.iter().enumerate().filter(|(_, b)| target.is_none_or(|t| t == &b.target)) {
|
||||
ui.label(format!(
|
||||
"{} - {} ({}): {:?}",
|
||||
binding.source.id, binding.target.definition, binding.target.feature, binding.target.occurrence_path
|
||||
));
|
||||
let source = match &binding.source {
|
||||
solidcraft_engine::doc::render_surface::SurfaceSource::Builtin(source) => source.id.as_str(),
|
||||
solidcraft_engine::doc::render_surface::SurfaceSource::Embedded(_) => app.tr("render.surface_import.embedded"),
|
||||
};
|
||||
ui.label(format!("{} - {} ({}): {:?}", source, binding.target.definition, binding.target.feature, binding.target.occurrence_path));
|
||||
if ui_kit::button(ui, app.tr("render.inspector_replace"), ui_kit::Button::Normal).clicked() {
|
||||
open_category(app, "surfaces");
|
||||
}
|
||||
@@ -1669,10 +1687,146 @@ pub(super) fn invalidate(app: &mut SolveApp) {
|
||||
app.render_workspace.view.library.loaded = false;
|
||||
}
|
||||
|
||||
pub(super) fn poll_import(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
surface_import::poll(app, ctx);
|
||||
}
|
||||
|
||||
pub(super) fn reveal_import(app: &mut SolveApp) {
|
||||
app.render_workspace.view.library.imported.reveal = true;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod studio_browser_tests {
|
||||
use super::*;
|
||||
use crate::scenario::Harness;
|
||||
|
||||
fn image_import_fixture() -> (Harness, View) {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":20,"width":10,"height":5,"body_name":"Board"})).unwrap();
|
||||
super::super::enter(&mut h.app, true).unwrap();
|
||||
super::super::select_image_method(&mut h.app, ImageMethod::GpuRaytrace);
|
||||
h.app.render_workspace.view.name = "Imported wood".into();
|
||||
let path = h.dir.join("original-wood.png");
|
||||
image::RgbaImage::from_pixel(4, 4, image::Rgba([180, 110, 60, 255])).save(&path).unwrap();
|
||||
let mut view = std::mem::take(&mut h.app.render_workspace.view.library);
|
||||
refresh_targets(&mut h.app, &mut view).unwrap();
|
||||
view.target = view.targets.first().cloned();
|
||||
view.mapping_plane = 1;
|
||||
view.mapping_angle_deg = 90.0;
|
||||
view.imported.path = path.to_string_lossy().into_owned();
|
||||
view.imported.author = "Original test author".into();
|
||||
view.imported.source = "Original synthetic fixture".into();
|
||||
view.imported.license = "CC0-1.0".into();
|
||||
view.imported.repeat_mm = [20.0, 40.0];
|
||||
(h, view)
|
||||
}
|
||||
|
||||
fn prepared_image_import(h: &mut Harness, view: View) -> surface_import::Pending {
|
||||
let request = surface_import::request(&mut h.app, &view).unwrap();
|
||||
let prepared = h.app.session.scratch().compute_commands(&[("render.surface.import".into(), request)], None).unwrap();
|
||||
h.app.render_workspace.view.library = view;
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
assert!(sender.send(Ok(prepared)).is_ok());
|
||||
surface_import::Pending {
|
||||
result: Arc::new(std::sync::Mutex::new(receiver)),
|
||||
document: Arc::downgrade(&h.app.session.doc),
|
||||
revision: h.app.session.revision,
|
||||
shot: h.app.render_workspace.view.name.clone(),
|
||||
method: h.app.render_workspace.view.method,
|
||||
snapshot: patch(&h.app).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_import_controls_keep_physical_grain_and_adopt_without_reading_source_again() {
|
||||
let (mut h, view) = image_import_fixture();
|
||||
let request = surface_import::request(&mut h.app, &view).unwrap();
|
||||
let frame: solidcraft_engine::render::job::textures::PlanarFrame = serde_json::from_value(request["frame"].clone()).unwrap();
|
||||
assert!((frame.u[2] - 20.0).abs() < 1e-10 && frame.u[1].abs() < 1e-10);
|
||||
assert!((frame.v[1] + 40.0).abs() < 1e-10 && frame.v[2].abs() < 1e-10);
|
||||
assert_eq!(request["provenance"]["author"], "Original test author");
|
||||
assert_eq!(request["provenance"]["source"], "Original synthetic fixture");
|
||||
let before = h.app.session.doc.to_json();
|
||||
let physical = (h.app.session.doc.features.clone(), h.app.session.doc.materials.clone());
|
||||
let undo = h.app.session.undo.len();
|
||||
let pending = prepared_image_import(&mut h, view);
|
||||
std::fs::remove_file(h.dir.join("original-wood.png")).unwrap();
|
||||
h.app.render_workspace.view.library.imported.pending = Some(pending);
|
||||
poll_import(&mut h.app, &crate::fonts::test_context());
|
||||
assert!(h.app.render_workspace.view.library.imported.pending.is_none());
|
||||
assert_eq!(h.app.session.doc.render.texture_resources.len(), 1);
|
||||
assert_eq!(h.app.session.doc.render.setups["Imported wood"].surfaces.len(), 1);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert_eq!((h.app.session.doc.features.clone(), h.app.session.doc.materials.clone()), physical);
|
||||
assert!(!h.app.session.doc.to_json().contains("original-wood.png"));
|
||||
assert_eq!(h.app.render_workspace.view.draft.surfaces.len(), 1);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_import_completion_cannot_replace_a_changed_document_shot_method_or_draft() {
|
||||
for change in ["document", "shot", "method", "draft"] {
|
||||
let (mut h, view) = image_import_fixture();
|
||||
let pending = prepared_image_import(&mut h, view);
|
||||
h.app.render_workspace.view.library.imported.pending = Some(pending);
|
||||
match change {
|
||||
"document" => {
|
||||
h.app.run("appearance.assign", json!({"bodies":["Board"],"color":"#208040"})).unwrap();
|
||||
}
|
||||
"shot" => h.app.render_workspace.view.name = "Other shot".into(),
|
||||
"method" => h.app.render_workspace.view.method = ImageMethod::Raster,
|
||||
_ => h.app.render_workspace.view.draft.width = 96,
|
||||
}
|
||||
let before = h.app.session.doc.clone();
|
||||
poll_import(&mut h.app, &crate::fonts::test_context());
|
||||
assert!(Arc::ptr_eq(&before, &h.app.session.doc), "{change}");
|
||||
assert!(h.app.render_workspace.view.library.imported.pending.is_none());
|
||||
assert!(h.app.session.doc.render.texture_resources.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_picker_cancellation_and_late_answers_keep_the_current_selection() {
|
||||
use crate::picker_test_support::{Slot, open};
|
||||
for change in ["cancel", "document", "shot", "method", "input"] {
|
||||
let mut h = Harness::new();
|
||||
h.app.render_workspace.view.library.imported.path = "original.png".into();
|
||||
let slot: Slot = Default::default();
|
||||
open(&mut h.app, &slot);
|
||||
surface_import::pick(&mut h.app, "original.png".into());
|
||||
match change {
|
||||
"document" => {
|
||||
h.app.session.execute("file.new", &json!({})).unwrap();
|
||||
}
|
||||
"shot" => h.app.render_workspace.view.name = "Other shot".into(),
|
||||
"method" => h.app.render_workspace.view.method = ImageMethod::CpuRaytrace,
|
||||
"input" => h.app.render_workspace.view.library.imported.path = "newer.png".into(),
|
||||
_ => {}
|
||||
}
|
||||
let before = h.app.render_workspace.view.library.imported.path.clone();
|
||||
let document = h.app.session.doc.clone();
|
||||
*slot.borrow_mut() = Some(if change == "cancel" { None } else { Some("never-opened-late.png".into()) });
|
||||
crate::file_picker::tick(&mut h.app, &crate::fonts::test_context());
|
||||
assert_eq!(h.app.render_workspace.view.library.imported.path, before, "{change}");
|
||||
assert!(Arc::ptr_eq(&document, &h.app.session.doc));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starting_image_import_reveals_file_controls_without_changing_the_design() {
|
||||
let (mut h, view) = image_import_fixture();
|
||||
h.app.render_workspace.view.library = view;
|
||||
h.call("ui.resize", json!({"width":1440,"height":1800}));
|
||||
let before = h.app.session.doc.to_json();
|
||||
h.app.start("render.surface.import");
|
||||
h.frames(3);
|
||||
assert!(crate::scenario::rect_of("render:surface-import:choose").is_some());
|
||||
assert!(crate::scenario::rect_of("render:surface-import:save").is_some());
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert!(!h.app.file_picker_busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_cards_select_inspect_switch_layout_and_keep_explicit_apply() {
|
||||
let mut h = Harness::new();
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::*;
|
||||
#[derive(Clone, Debug)]
|
||||
pub(in crate::render_workspace) enum Apply {
|
||||
Command { id: String, params: Value },
|
||||
Maps { id: String, target: materials::Target, plane: String, scale: f64 },
|
||||
Maps { id: String, target: materials::Target, plane: String, scale: f64, rotation_rad: f64 },
|
||||
Quality(Value),
|
||||
Physical(Value),
|
||||
}
|
||||
@@ -139,7 +139,9 @@ fn apply(app: &mut SolveApp, draft: &mut Draft) -> Result<(), String> {
|
||||
setups::load(app);
|
||||
}
|
||||
}
|
||||
Apply::Maps { id, target, plane, scale } => materials::install_library_surface(app, id, target.clone(), plane, *scale)?,
|
||||
Apply::Maps { id, target, plane, scale, rotation_rad } => {
|
||||
materials::install_library_surface(app, id, target.clone(), plane, *scale, *rotation_rad)?
|
||||
}
|
||||
Apply::Quality(plan) => {
|
||||
super::super::inspector::apply_quality(app, plan)?;
|
||||
setups::save(app)?;
|
||||
|
||||
@@ -604,7 +604,14 @@ fn choose_map(app: &mut SolveApp, binding: &Binding, slot: MapSlot) {
|
||||
|
||||
/// Prepare through the engine, then install only a request-local binding. No
|
||||
/// authored material may silently remove a glass override or switch executor.
|
||||
pub(super) fn install_library_surface(app: &mut SolveApp, id: &str, target: Target, plane: &str, scale: f64) -> Result<(), String> {
|
||||
pub(super) fn install_library_surface(
|
||||
app: &mut SolveApp,
|
||||
id: &str,
|
||||
target: Target,
|
||||
plane: &str,
|
||||
scale: f64,
|
||||
rotation_rad: f64,
|
||||
) -> Result<(), String> {
|
||||
if app.render_workspace.view.method != super::ImageMethod::GpuRaytrace {
|
||||
return Err(app.tr("render.material_gpu_required").into());
|
||||
}
|
||||
@@ -621,8 +628,10 @@ pub(super) fn install_library_surface(app: &mut SolveApp, id: &str, target: Targ
|
||||
} else if app.render_workspace.view.materials.bindings.len() >= MAX_BINDINGS {
|
||||
return Err(app.tr("render.material_limits").into());
|
||||
}
|
||||
let reply =
|
||||
app.run("render.library.surface.prepare", json!({"id":id,"body_index":target.ordinal,"body_name":target.name,"plane":plane,"scale":scale}))?;
|
||||
let reply = app.run(
|
||||
"render.library.surface.prepare",
|
||||
json!({"id":id,"body_index":target.ordinal,"body_name":target.name,"plane":plane,"scale":scale,"rotation_rad":rotation_rad}),
|
||||
)?;
|
||||
let texture = &reply["texture"];
|
||||
let frame: PlanarFrame = serde_json::from_value(texture["frame"].clone()).map_err(|e| e.to_string())?;
|
||||
let path = |key: &str| texture[key].as_str().map(str::to_string).ok_or_else(|| crate::i18n::tr("render.error_surface_reply").to_string());
|
||||
|
||||
@@ -30,6 +30,7 @@ fn handled(id: &str) -> bool {
|
||||
| "render.optics.bind"
|
||||
| "render.optics.unbind"
|
||||
| "render.library.surface.bind"
|
||||
| "render.surface.import"
|
||||
| "render.library.surface.unbind"
|
||||
| "render.library.surface.prepare"
|
||||
| "render.library.physical.prepare"
|
||||
@@ -300,6 +301,10 @@ fn navigate(app: &mut SolveApp, id: &str) -> Result<(), String> {
|
||||
"render.library.surface.bind" | "render.library.surface.prepare" | "render.library.surface.unbind" => {
|
||||
focus(app, object, Some("surfaces"));
|
||||
}
|
||||
"render.surface.import" => {
|
||||
focus(app, object, Some("surfaces"));
|
||||
library::reveal_import(app);
|
||||
}
|
||||
"render.library.physical.prepare" => focus(app, object, Some("physical_profiles")),
|
||||
"render.library.studio.prepare" => focus(app, RenderSelection::Lighting, Some("rigs")),
|
||||
"render.library.studio.apply" => focus(app, RenderSelection::Lighting, None),
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Thin file/provenance controls. Native codecs run once on a scratch-session worker;
|
||||
//! only a complete, still-current command transaction reaches the displayed design.
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct View {
|
||||
pub(super) path: String,
|
||||
pub(super) author: String,
|
||||
pub(super) source: String,
|
||||
pub(super) license: String,
|
||||
pub(super) repeat_mm: [f64; 2],
|
||||
pub(super) reveal: bool,
|
||||
pub(super) pending: Option<Pending>,
|
||||
}
|
||||
impl Default for View {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: String::new(),
|
||||
author: String::new(),
|
||||
source: String::new(),
|
||||
license: String::new(),
|
||||
repeat_mm: [100.0; 2],
|
||||
reveal: false,
|
||||
pending: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Imported = Result<solidcraft_engine::ComputedCommands, String>;
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Pending {
|
||||
pub(super) result: Arc<std::sync::Mutex<std::sync::mpsc::Receiver<Imported>>>,
|
||||
pub(super) document: Weak<Document>,
|
||||
pub(super) revision: u64,
|
||||
pub(super) shot: String,
|
||||
pub(super) method: ImageMethod,
|
||||
pub(super) snapshot: Value,
|
||||
}
|
||||
impl std::fmt::Debug for Pending {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PendingSurfaceImport").field("revision", &self.revision).field("shot", &self.shot).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
static WORKER_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
struct WorkerPermit;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl Drop for WorkerPermit {
|
||||
fn drop(&mut self) {
|
||||
WORKER_ACTIVE.store(false, std::sync::atomic::Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pick(app: &mut SolveApp, previous: String) {
|
||||
let document = Arc::downgrade(&app.session.doc);
|
||||
let revision = app.session.revision;
|
||||
let shot = app.render_workspace.view.name.clone();
|
||||
let method = app.render_workspace.view.method;
|
||||
let extensions: &[&str] = if method == ImageMethod::CpuRaytrace { &["png"] } else { &["png", "jpg", "jpeg"] };
|
||||
app.pick_open_for(extensions, move |app, path| {
|
||||
if app.session.revision != revision
|
||||
|| !Weak::ptr_eq(&document, &Arc::downgrade(&app.session.doc))
|
||||
|| app.render_workspace.view.name != shot
|
||||
|| app.render_workspace.view.method != method
|
||||
|| app.render_workspace.view.library.imported.path != previous
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(path) = path {
|
||||
app.render_workspace.view.library.imported.path = path;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn request(app: &mut SolveApp, view: &super::View) -> Result<Value, String> {
|
||||
if app.render_workspace.view.method == ImageMethod::Raster {
|
||||
return Err(app.tr("render.surface_import.raytrace_required").into());
|
||||
}
|
||||
if !current_target(app, view) {
|
||||
return Err(app.tr("render.material_revalidate_needed").into());
|
||||
}
|
||||
let input = &view.imported;
|
||||
if input.path.trim().is_empty() || [&input.author, &input.source, &input.license].into_iter().any(|text| text.trim().is_empty()) {
|
||||
return Err(app.tr("render.surface_import.complete").into());
|
||||
}
|
||||
let target = view.target.as_ref().ok_or_else(|| app.tr("render.material_choose_body").to_string())?;
|
||||
let scene = app.run("render.scene", json!({}))?;
|
||||
let body = scene["bodies"]
|
||||
.get(target.ordinal)
|
||||
.filter(|body| body["name"].as_str() == Some(target.name.as_str()))
|
||||
.ok_or_else(|| app.tr("render.material_revalidate_needed").to_string())?;
|
||||
let min: Vec3 = serde_json::from_value(body["bounds"]["min"].clone()).map_err(|e| e.to_string())?;
|
||||
let max: Vec3 = serde_json::from_value(body["bounds"]["max"].clone()).map_err(|e| e.to_string())?;
|
||||
let center = (min + max) * 0.5;
|
||||
let [width, height] = input.repeat_mm;
|
||||
let (sin, cos) = view.mapping_angle_deg.to_radians().sin_cos();
|
||||
let (u, v) = match view.mapping_plane {
|
||||
1 => ([0.0, cos * width, sin * width], [0.0, -sin * height, cos * height]),
|
||||
2 => ([sin * width, 0.0, cos * width], [cos * height, 0.0, -sin * height]),
|
||||
_ => ([cos * width, sin * width, 0.0], [-sin * height, cos * height, 0.0]),
|
||||
};
|
||||
let frame = solidcraft_engine::render::job::textures::PlanarFrame { origin: [center.x, center.y, center.z], u, v };
|
||||
frame.validate().map_err(|e| e.to_string())?;
|
||||
Ok(json!({"name":app.render_workspace.view.name,"set":patch(app)?,"body_index":target.ordinal,"body_name":target.name,
|
||||
"frame":frame,"maps":{"base_color_srgb":{"path":input.path}},
|
||||
"provenance":{"author":input.author,"source":input.source,"license":input.license},
|
||||
"normal_strength":1.0,"flip_v":false,"replace":true}))
|
||||
}
|
||||
|
||||
fn finish(app: &mut SolveApp, imported: Imported) -> Result<(), String> {
|
||||
if app.file_load.busy() || app.file_picker_busy() {
|
||||
return Err("a file operation is in progress".into());
|
||||
}
|
||||
let replies = app.session.accept_commands(imported?).map_err(|e| e.to_string())?;
|
||||
for (id, reply) in replies {
|
||||
super::super::apply_command(app, &id, &reply)?;
|
||||
app.command_completed(&id, Ok(reply))?;
|
||||
}
|
||||
setups::load(app);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn begin(app: &mut SolveApp, view: &mut super::View, ctx: &egui::Context) -> Result<(), String> {
|
||||
let params = request(app, view)?;
|
||||
let session = app.session.scratch();
|
||||
let commands = vec![("render.surface.import".into(), params)];
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if WORKER_ACTIVE.compare_exchange(false, true, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire).is_err() {
|
||||
return Err(app.tr("render.surface_import.busy").into());
|
||||
}
|
||||
let permit = WorkerPermit;
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let pending = Pending {
|
||||
result: Arc::new(std::sync::Mutex::new(receiver)),
|
||||
document: Arc::downgrade(&app.session.doc),
|
||||
revision: app.session.revision,
|
||||
shot: app.render_workspace.view.name.clone(),
|
||||
method: app.render_workspace.view.method,
|
||||
snapshot: patch(app)?,
|
||||
};
|
||||
let repaint = ctx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("surface import".into())
|
||||
.spawn(move || {
|
||||
let _permit = permit;
|
||||
let result = session.compute_commands(&commands, None).map_err(|e| e.to_string());
|
||||
let _ = sender.send(result);
|
||||
repaint.request_repaint();
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
view.imported.pending = Some(pending);
|
||||
ctx.request_repaint();
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// The browser host has no Rust worker threads. Codec validation is bounded
|
||||
// by the same 1024-square resource admission; the async picker uses its VFS.
|
||||
let _ = ctx;
|
||||
finish(app, session.compute_commands(&commands, None).map_err(|e| e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn poll(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(pending) = app.render_workspace.view.library.imported.pending.as_ref() else { return };
|
||||
let current = pending.revision == app.session.revision
|
||||
&& Weak::ptr_eq(&pending.document, &Arc::downgrade(&app.session.doc))
|
||||
&& pending.shot == app.render_workspace.view.name
|
||||
&& pending.method == app.render_workspace.view.method
|
||||
&& patch(app).ok().as_ref() == Some(&pending.snapshot);
|
||||
if !current {
|
||||
app.render_workspace.view.library.imported.pending = None;
|
||||
return;
|
||||
}
|
||||
let result = match pending.result.lock() {
|
||||
Ok(receiver) => match receiver.try_recv() {
|
||||
Ok(result) => Some(result),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => None,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Err(app.tr("render.surface_import.failed").into())),
|
||||
},
|
||||
Err(_) => Some(Err(app.tr("render.surface_import.failed").into())),
|
||||
};
|
||||
let Some(result) = result else {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(50));
|
||||
return;
|
||||
};
|
||||
app.render_workspace.view.library.imported.pending = None;
|
||||
if let Err(error) = finish(app, result) {
|
||||
app.set_status(error, true);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn controls(app: &mut SolveApp, ui: &mut egui::Ui, view: &mut super::View) {
|
||||
let title = app.tr("render.surface_import.title").to_string();
|
||||
if std::mem::take(&mut view.imported.reveal) {
|
||||
let section = ui.make_persistent_id("surface_import");
|
||||
ui.ctx().data_mut(|data| data.insert_persisted(section, true));
|
||||
ui.scroll_to_cursor(Some(egui::Align::Min));
|
||||
}
|
||||
ui_kit::section(ui, "surface_import", &title, false, |ui| {
|
||||
if view.imported.pending.is_some() {
|
||||
ui_kit::text(ui, app.tr("render.surface_import.busy"), ui_kit::Text::Note("surface_import:busy"));
|
||||
if ui_kit::button(ui, app.tr("ui.cancel"), ui_kit::Button::Normal).clicked() {
|
||||
view.imported.pending = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !current_target(app, view) {
|
||||
let previous = view.target.clone();
|
||||
if let Err(error) = refresh_targets(app, view) {
|
||||
app.set_status(error, true);
|
||||
} else {
|
||||
view.target = previous.filter(|target| view.targets.contains(target));
|
||||
}
|
||||
}
|
||||
let picked = app.session.selection.iter().find_map(|sel| match sel {
|
||||
solidcraft_engine::Sel::Body { name } | solidcraft_engine::Sel::Face { body: name, .. } => Some(name),
|
||||
_ => None,
|
||||
});
|
||||
if view.target.is_none() {
|
||||
view.target = view.targets.iter().find(|target| picked.is_some_and(|name| target.name == *name)).cloned();
|
||||
}
|
||||
ui_kit::row(ui, app.tr("render.stage_panels.target"), |ui| {
|
||||
let name = view.target.as_ref().map_or(app.tr("render.material_choose_body"), |target| target.name.as_str()).to_string();
|
||||
ui_kit::dropdown_menu(ui, "surface_import_target", &name, |ui| {
|
||||
for target in &view.targets {
|
||||
if ui_kit::list_picker(ui, &target.name, &target.name, view.target.as_ref() == Some(target)).clicked() {
|
||||
view.target = Some(target.clone());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let label = if view.imported.path.is_empty() {
|
||||
app.tr("render.appearance_editor.choose_image").to_string()
|
||||
} else {
|
||||
std::path::Path::new(&view.imported.path).file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
};
|
||||
let choose = ui_kit::button(ui, &label, ui_kit::Button::Normal);
|
||||
crate::scenario::publish_target("render:surface-import:choose", choose.rect);
|
||||
if choose.clicked() {
|
||||
pick(app, view.imported.path.clone());
|
||||
}
|
||||
for (key, value, maximum) in [
|
||||
("render.surface_import.author", &mut view.imported.author, 512),
|
||||
("render.surface_import.source", &mut view.imported.source, 2048),
|
||||
("render.surface_import.license", &mut view.imported.license, 4096),
|
||||
] {
|
||||
ui_kit::row(ui, app.tr(key), |ui| {
|
||||
ui_kit::bounded_cell_editor(ui, ui.id().with(key), value, "", false, maximum);
|
||||
});
|
||||
}
|
||||
ui_kit::row(ui, app.tr("render.stage_panels.plane"), |ui| {
|
||||
ui_kit::segmented(ui, &mut view.mapping_plane, &["xy", "yz", "zx"], ui_kit::Segments { compact: true, ..Default::default() });
|
||||
});
|
||||
for (key, value) in ["render.surface_import.repeat_u", "render.surface_import.repeat_v"].into_iter().zip(&mut view.imported.repeat_mm) {
|
||||
ui_kit::row(ui, app.tr(key), |ui| {
|
||||
ui_kit::number_field(ui, value, 0.001..=1e6, 1.0, " mm");
|
||||
});
|
||||
}
|
||||
ui_kit::row(ui, app.tr("render.stage_panels.grain_angle"), |ui| {
|
||||
ui_kit::number_field(ui, &mut view.mapping_angle_deg, -180.0..=180.0, 1.0, "°");
|
||||
});
|
||||
let raytrace = app.render_workspace.view.method != ImageMethod::Raster;
|
||||
let ready = raytrace
|
||||
&& current_target(app, view)
|
||||
&& !view.imported.path.is_empty()
|
||||
&& [&view.imported.author, &view.imported.source, &view.imported.license].into_iter().all(|text| !text.trim().is_empty());
|
||||
let save = ui_kit::button(ui, app.tr("render.surface_import.save"), ui_kit::Button::Enabled(ready));
|
||||
crate::scenario::publish_target("render:surface-import:save", save.rect);
|
||||
if save.clicked()
|
||||
&& let Err(error) = begin(app, view, ui.ctx())
|
||||
{
|
||||
app.set_status(error, true);
|
||||
}
|
||||
if !raytrace {
|
||||
ui_kit::text(ui, app.tr("render.surface_import.raytrace_required"), ui_kit::Text::Note("surface_import:raytrace"));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,11 +4,11 @@ Integration-branch implementation, not a release acceptance claim. Source, type/
|
||||
|
||||
## Saved surfaces
|
||||
|
||||
`RenderSetup.surfaces` stores at most 128 built-in surface bindings: a `SURF-nnn` ID, resource version, four encoded SHA-256 receipts, an exact definition/producing-feature/component/occurrence-path target, a definition-local planar frame in millimetres, and normal strength. The roles are base color, OpenGL normal, roughness and metallic. Height remains authoring-only. Existing body IOR is retained. Engineering material cards, mass, topology and the CAD timeline are unchanged.
|
||||
`RenderSetup.surfaces` stores at most 128 surface bindings. Built-in sources retain: a `SURF-nnn` ID, resource version, four encoded SHA-256 receipts, an exact definition/producing-feature/component/occurrence-path target, a definition-local planar frame in millimetres, and normal strength. The roles are base color, OpenGL normal, roughness and metallic. Height remains authoring-only. Existing body IOR is retained. Engineering material cards, mass, topology and the CAD timeline are unchanged.
|
||||
|
||||
No captured body ordinal, GPU object or external path is saved. Every Check and Start resolves the semantic target, transforms its local frame into current world coordinates, then produces the ordinary bounded GPU texture request. Motion follows that transform; another instance does not inherit the assignment. Missing, hidden, ambiguous, renamed or replaced definitions/features refuse instead of selecting a similar body. An occurrence label may change while its ID path remains valid. Definition renaming and topology-producing feature changes currently require explicit rebinding.
|
||||
No captured body ordinal, GPU object or external path is saved. Every Check and Start resolves the semantic target, transforms its local frame into current world coordinates, then produces the ordinary bounded texture request for the selected executor. Motion follows that transform; another instance does not inherit the assignment. Missing, hidden, ambiguous, renamed or replaced definitions/features refuse instead of selecting a similar body. An occurrence label may change while its ID path remains valid. Definition renaming and topology-producing feature changes currently require explicit rebinding.
|
||||
|
||||
Bindings are path-independent **for the pinned built-in pack**, not a general embedded external-texture format. Reopening preserves references, but execution refuses an unavailable resource or version/digest mismatch. A future installed pack must still supply those exact bytes; an old ID must never be silently reinterpreted.
|
||||
Built-in bindings are path-independent **for the pinned built-in pack**. Reopening preserves references, but execution refuses an unavailable resource or version/digest mismatch. A future installed pack must still supply those exact bytes; an old ID must never be silently reinterpreted.
|
||||
|
||||
Send the following to `render.library.surface.bind` after inspecting `render.scene`:
|
||||
|
||||
@@ -18,7 +18,7 @@ Send the following to `render.library.surface.bind` after inspecting `render.sce
|
||||
"set": {"job": {"method": "gpu_path", "options": {"samples": 128}}},
|
||||
"surface": {
|
||||
"id": "SURF-001", "body_index": 0, "body_name": "Panel",
|
||||
"plane": "xy", "scale": 1.0
|
||||
"plane": "xy", "scale": 1.0, "rotation_rad": 0.7853981633974483
|
||||
},
|
||||
"replace": false
|
||||
}
|
||||
@@ -26,10 +26,71 @@ Send the following to `render.library.surface.bind` after inspecting `render.sce
|
||||
|
||||
The temporary ordinal/name pair is checked once, its semantic identity is captured, and the opaque target and planar precision bounds are validated before one atomic setup change/undo. `replace: true` explicitly replaces a prior binding; an identical application is a no-op. The full setup is returned.
|
||||
|
||||
`render.library.surface.unbind` takes setup `name` and the exact saved `target`. It can remove an obsolete association without current geometry or a device. Switching a bound setup to CPU/raster without removing GPU-only maps refuses. Temporary optical overrides and unsaved external HDR capture cannot silently supersede saved maps; save the HDR first or explicitly remove the temporary inputs.
|
||||
`render.library.surface.unbind` takes setup `name` and the exact saved `target`. It can remove an obsolete association without current geometry or a device. Saved opaque maps work with native CPU raytracing and GPU path tracing. Switching to raster refuses instead of dropping the maps. Temporary optical overrides and unsaved external HDR capture cannot silently supersede saved maps; save the HDR first or explicitly remove the temporary inputs.
|
||||
|
||||
The existing Render library adds Save maps to this setup and saved-binding inspection/removal. The older temporary-map action stays separate. No alternate UI shell, new artwork or runtime Python dependency is introduced.
|
||||
|
||||
## Imported wood and other opaque surface images
|
||||
|
||||
The Surfaces panel can import a local color image, choose its physical U/V repeat
|
||||
and grain angle, and save it with the selected shot. Import records the supplied
|
||||
author, source and license as inert declared provenance. It does not fetch a URL
|
||||
or approve licensing. The full frame is saved in definition-local millimetres;
|
||||
rotating or moving an occurrence carries its grain with it. Physical materials,
|
||||
mechanical axes, mass and geometry are unchanged.
|
||||
|
||||
`render.surface.import` captures exact static 8-bit PNG/JPEG bytes and binds the
|
||||
image in one undo step. Example after inspecting `render.scene`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Wood",
|
||||
"set": {"job": {"method": "gpu_path", "options": {"samples": 128}}},
|
||||
"body_index": 0, "body_name": "Panel",
|
||||
"frame": {"origin": [0,0,0], "u": [240,0,0], "v": [0,480,0]},
|
||||
"maps": {"base_color_srgb": {"path": "/textures/original-wood.png"}},
|
||||
"provenance": {"author": "Your name", "source": "Original photograph", "license": "Your declared terms"}
|
||||
}
|
||||
```
|
||||
|
||||
Map inputs also accept `normal_opengl`, `roughness_linear` and `metallic_linear`.
|
||||
Each map takes `path` and optional `expected_sha256`; a changed selection refuses.
|
||||
The role is explicit: color normalizes from sRGB before filtering, while normals
|
||||
and scalar maps remain linear data. Portable base-color PNG declarations must
|
||||
agree with normalized sRGB. Static metadata and encoding checks apply to portable
|
||||
imports; older built-in and temporary GPU maps retain their prior decoder admission,
|
||||
explicit-role interpretation, single-level bilinear sampling and face-map precedence.
|
||||
Newly imported GPU resources opt into full mip chains and yield to explicit scalar
|
||||
face appearances. Native CPU maps use full mip chains. `normal_strength` defaults to one and
|
||||
`flip_v` defaults to false. `replace: true` is required for an existing binding.
|
||||
|
||||
The design deduplicates bytes by SHA-256, independently of role. Importing an
|
||||
existing digest reuses its first recorded provenance; later imports do not replace
|
||||
that author, source or license metadata. The design stores at most
|
||||
32 unique images and 64 MiB total encoded storage; each image is at most 16 MiB
|
||||
and 1024×1024. Complete decode and metadata validation happen before publishing
|
||||
any resource. Native containers reuse verified `blobs/` entries; portable JSON
|
||||
uses bounded canonical base64. Save, delete the source file, and reopen offline:
|
||||
no filesystem path is retained or reopened. Failed import/open leaves the prior
|
||||
design intact. Older applications refuse the newer format instead of discarding
|
||||
its resources.
|
||||
|
||||
`render.texture.list` reports digests, dimensions, declared provenance and setup
|
||||
users. `render.texture.remove` refuses referenced images and lists their users;
|
||||
explicitly unbind them first. Import, unbind, removal and their undo operations
|
||||
preserve the exact bytes. Completed render handles retain metadata rather than
|
||||
keeping an uncharged encoded-image cache alive.
|
||||
|
||||
The native CPU consumer prepares rows and complete mip chains cooperatively,
|
||||
then maps the opaque closure used for direct lights and HDR illumination. It
|
||||
accepts only noninterlaced 8-bit PNG when importing into a CPU setup. Supported
|
||||
JPEG8 and interlaced PNG remain GPU inputs; explicitly normalize them before CPU use. It
|
||||
rejects incompatible selected reflections, glass, anisotropy and procedural
|
||||
surface overrides. Browser execution and unsupported source encodings refuse
|
||||
explicitly. Projection remains planar: use separately oriented bindings for
|
||||
separate boards. This is an opaque image-based wood preview, not a volumetric
|
||||
end-grain or measured wood-scattering model.
|
||||
|
||||
## Passive stages
|
||||
|
||||
`RenderSetup.stage` stores up to 32 finite passive rectangles, each with ID, world-mm center/half-edge vectors and exact linear RGB reflectance. They are two-sided diffuse surfaces, with zero emission and no transmission. They are appended only to the captured render scene, preserving product ordinals. No engineering bodies or mass are created.
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
The renderer supports built-in sources, absolute-color mode, explicit V orientation, linear roughness/metallic maps and content-addressed texture sharing. See [built-in library](render-built-in-library.md) for its bounded resource contract. Legacy examples below retain modulation and rows-down defaults.
|
||||
|
||||
Progressive GPU jobs accept immutable per-body base-color and OpenGL tangent-space
|
||||
normal maps. No document, appearance, recovery, or saved material format changes.
|
||||
Only explicit runtime commands capture file bytes; queued work does not reopen paths.
|
||||
normal maps. Runtime commands capture file bytes once; queued work does not reopen paths.
|
||||
Existing saved built-in and temporary GPU maps retain single-level bilinear
|
||||
sampling. Newly imported design-owned maps opt into footprint-filtered mip chains. See
|
||||
[portable surfaces](render-portable-surfaces-and-stages.md) for design-owned bytes,
|
||||
physical grain orientation, offline reopening and native CPU execution.
|
||||
|
||||
```json
|
||||
{"progressive":true,"raytrace":{"textures":[{"body_index":3,"body_name":"Walnut tabletop","frame":{"origin":[0,0,480],"u":[900,0,0],"v":[0,90,0]},"base_color_srgb":"C:/art/wood.png","normal_opengl":"C:/art/wood-normal.png","normal_strength":0.35}]}}
|
||||
@@ -19,32 +22,57 @@ bounding-box corners must remain within ±128 repeats; the frame origin must be
|
||||
within 10 million mm of the camera target. These caps limit f32 coordinate loss.
|
||||
|
||||
Base RGB is explicitly sRGB, converted to linear reflectance before bilinear
|
||||
filtering and multiplied by the body's linear base color. Use a white base to
|
||||
reproduce the map's color. Normal channels are untransformed linear data. Normal
|
||||
filtering. Legacy runtime maps multiply the body's linear base color; authored
|
||||
and imported saved surfaces replace it with the map's absolute reflectance. Normal channels are untransformed linear data. Normal
|
||||
strength scales tangent XY in 0..1 before normalization; degenerate vectors, an
|
||||
invalid tangent projection, or a normal behind the geometric/view hemisphere
|
||||
fall back to the original shading normal. Alpha is ignored by this opaque-only
|
||||
consumer. Glass, scalar opacity, roughness, metalness, displacement, height and
|
||||
alpha maps are rejected or unsupported; they are not silently interpreted.
|
||||
consumer. Linear roughness and metallic maps replace those scalar parameters.
|
||||
An explicit scalar face appearance suppresses newly imported body maps on that face.
|
||||
Existing GPU built-in and temporary map roles retain their previous precedence,
|
||||
including when a request mixes legacy and imported roles.
|
||||
Displacement, height and alpha maps are unsupported.
|
||||
|
||||
Sampling uses bilinear level-zero periodic filtering at texel centers, including
|
||||
negative UV repeats. There is no mipmapping, anisotropic filter or UV mesh import
|
||||
in this increment, so fine distant patterns may alias. PNG/JPEG input is 8-bit,
|
||||
maximum 1024×1024 per map. Embedded ICC profiles and nonidentity orientation must
|
||||
be normalized beforehand; no implicit color-management guess is made. The sRGB
|
||||
Newly imported GPU maps and native CPU maps prepare complete floor-halved, linear area-filtered mip chains through
|
||||
1×1. It interpolates bilinearly within each level and linearly between levels.
|
||||
LOD uses the largest singular value of the unwrapped UV-to-texel Jacobian;
|
||||
negative repeats and repeat seams do not create false derivatives. Magnification
|
||||
retains level zero. GPU primary footprints analytically differentiate the camera ray and
|
||||
its intersection with the hit tangent plane, holding the lens sample fixed and
|
||||
using one output-pixel spacing. Secondary GPU footprints use a conservative camera ray cone scaled by
|
||||
path length and grazing angle. That approximation can blur stretched footprints;
|
||||
it does not implement anisotropic filtering or specular ray differentials. Legacy GPU
|
||||
maps retain only their exact decoded level-zero pixels and bypass LOD selection.
|
||||
Content/role sharing also includes this policy, so mixed requests cannot silently
|
||||
replace legacy sampling with the imported-map policy or conversely.
|
||||
|
||||
PNG/JPEG input is 8-bit, maximum 1024×1024 per map. Embedded ICC profiles and nonidentity orientation must
|
||||
be normalized beforehand. Temporary GPU maps retain their historical decoder
|
||||
admission and explicit-role interpretation: ancillary PNG color declarations do
|
||||
not change their encoded samples or introduce a new refusal. Portable design-owned
|
||||
imports require normalized sRGB declarations for base color and validate static
|
||||
metadata before publication. The native CPU metadata worker also rejects conflicting
|
||||
base-color declarations. Normal and scalar roles retain their raw numerical channels.
|
||||
No implicit color-profile conversion is performed. The sRGB
|
||||
base/data normal distinction follows the public
|
||||
[Khronos glTF material semantics](https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#materials),
|
||||
without adopting its document format or copying implementation code.
|
||||
|
||||
At most 16 exact name-and-index body bindings retain 16 MiB encoded capacity and
|
||||
32 MiB linear pixel data collectively. Admission counts captured bytes, codec
|
||||
scratch, normalized pixels, staging and device storage against the existing job
|
||||
At most 128 exact name-and-index body bindings retain 64 MiB encoded capacity and
|
||||
96 MiB level-zero linear pixel data collectively. Admission counts captured bytes, codec
|
||||
scratch, normalized pixels, any prepared mip chains, staging and device storage against the existing job
|
||||
budget. Codecs execute on the worker with cancellation checks before/after each
|
||||
bounded decode and during conversion/upload preparation. Public status contains
|
||||
metadata only; completion or cancellation releases captured source bytes even
|
||||
when callers retain job/status/frame handles. GPU preparation also releases
|
||||
source bytes after preparing the pixel buffer.
|
||||
|
||||
The GPU executor selects a bounded opaque pipeline when the captured scene has
|
||||
no physical transmission or active volume. Glass and active volume rendering
|
||||
retain the general pipeline. Both honor each map's captured filtering and face-precedence policy.
|
||||
Disjoint media use one private stack entry; certified nested media retain their
|
||||
sixteen-entry capacity. Captured scene options select at most six cached programs.
|
||||
|
||||
The independent progressive device requests six storage buffers per shader
|
||||
stage. An adapter limited to fewer cannot install this executor; raster and
|
||||
tessellation device requirements remain unchanged.
|
||||
|
||||
@@ -151,6 +151,12 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## Render foundation
|
||||
|
||||
- Wood surface images (#1345): native CPU and GPU opaque rendering share physical
|
||||
repeat frames and filtered mip chains. Imported maps retain exact bytes and
|
||||
declared provenance in the design, reopen offline, and expose grain-angle
|
||||
controls. Scalar face appearances suppress body maps. Planar projection and
|
||||
CPU encoding/optical limits remain explicit; see [portable surfaces](render-portable-surfaces-and-stages.md).
|
||||
|
||||
- Original starter appearances (#1722): 34 opaque recipes through existing commands, with six explicitly unavailable glass recipes and preserved legacy looks; see [the contract](render-starter-appearances.md).
|
||||
|
||||
- Bounded linear film accumulation (#1696) supplies caller-admitted mean/variance storage and complete-pass views; actual progressive consumers remain separate. See [the film contract](render-film.md).
|
||||
|
||||
@@ -205,6 +205,9 @@ render.stage.clear rendering: images, cameras, stages
|
||||
render.stage.ground rendering: images, cameras, stages
|
||||
render.start rendering: images, cameras, stages
|
||||
render.status rendering: images, cameras, stages
|
||||
render.surface.import rendering image resources; crates/engine/src/cmd/render/library_tests.rs::imported_wood_maps_reopen_without_source_and_keep_geometry_history_and_resource_users
|
||||
render.texture.list rendering image resources; crates/engine/src/cmd/render/library_tests.rs::imported_wood_maps_reopen_without_source_and_keep_geometry_history_and_resource_users
|
||||
render.texture.remove rendering image resources; crates/engine/src/cmd/render/library_tests.rs::imported_wood_maps_reopen_without_source_and_keep_geometry_history_and_resource_users
|
||||
select.clear the selection
|
||||
select.filter the selection
|
||||
select.same_look the selection
|
||||
|
||||
Reference in new issue
Block a user