mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 21:11:56 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66bc1b7de2 | |||
| e8e4861933 | |||
| c56cb30444 |
Generated
+57
@@ -2508,6 +2508,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
@@ -2773,6 +2782,16 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-io-surface"
|
||||
version = "0.3.2"
|
||||
@@ -4288,12 +4307,14 @@ dependencies = [
|
||||
"image",
|
||||
"moxcms",
|
||||
"png",
|
||||
"rayon",
|
||||
"robust",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"solidcore-record",
|
||||
"solidcraft-geom",
|
||||
"sysinfo",
|
||||
"web-time",
|
||||
"weezl",
|
||||
]
|
||||
@@ -4485,6 +4506,20 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.40.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e8a3edbe0394ce115f3917a00349dd242be09eb5a091ed509eaf67b55cfb8a8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-kit",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5337,6 +5372,22 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-util"
|
||||
version = "0.1.11"
|
||||
@@ -5346,6 +5397,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
|
||||
@@ -62,6 +62,8 @@ semver = "1"
|
||||
# constrained Delaunay + Ruppert refinement for trimmed faces (MIT OR Apache-2.0).
|
||||
spade = { version = "2.15", default-features = false, features = ["std"] }
|
||||
rayon = "1.12"
|
||||
# Native render-memory admission; RAM-only queries through a safe MIT-licensed API.
|
||||
sysinfo = { version = "0.40.1", default-features = false, features = ["system"] }
|
||||
|
||||
# packages (3MF)
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
|
||||
@@ -3397,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "يتطلب تتبع الأشعة على CPU خلفية معتمة. ألغِ تحديد «خلفية شفافة» للمتابعة.",
|
||||
"render.cpu_options_pending": "لا تستطيع لوحة CPU هذه تحرير الانبعاث ولا تأثيرات السطح ولا الزجاج ولا تباين الخواص بعد. امسح خيارات API هذه قبل استخدامها.",
|
||||
"render.cpu_path_depth": "عمق المسار",
|
||||
"render.cpu_path_help": "تضيف الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءًا من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_path_help": "تضيف الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءًا من أول تقاطع (1–64). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتًا أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_path_lighting": "إضاءة غير مباشرة",
|
||||
"render.cpu_path_optics_reason": "تبقى إعداداتك كما هي. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_point": "ضوء نقطي",
|
||||
|
||||
@@ -3151,7 +3151,7 @@
|
||||
"render.cpu_opaque_required": "CPU-Raytracing benötigt einen undurchsichtigen Hintergrund. Entfernen Sie das Häkchen bei „Transparenter Hintergrund“, um fortzufahren.",
|
||||
"render.cpu_options_pending": "Dieses CPU-Bedienfeld kann Emission, Oberflächeneffekte, Glas oder Anisotropie noch nicht bearbeiten. Entfernen Sie diese API-Optionen, bevor Sie es verwenden.",
|
||||
"render.cpu_path_depth": "Pfadtiefe",
|
||||
"render.cpu_path_help": "Berechnet Licht, das zwischen undurchsichtigen Flächen und durch unterstützte Glasformen läuft. Erfordert progressives Rendern und Raytracing-Schatten. Die Pfadtiefe zählt beleuchtete Flächen, Glasgrenzen und Streuereignisse ab dem ersten Treffer (1–16). Tiefe 1 liefert direkte Beleuchtung auf undurchsichtigen Flächen; höhere Werte erlauben mehr Reflexionen oder Glasdurchgänge und können länger dauern. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
|
||||
"render.cpu_path_help": "Berechnet Licht, das zwischen undurchsichtigen Flächen und durch unterstützte Glasformen läuft. Erfordert progressives Rendern und Raytracing-Schatten. Die Pfadtiefe zählt beleuchtete Flächen, Glasgrenzen und Streuereignisse ab dem ersten Treffer (1–64). Tiefe 1 liefert direkte Beleuchtung auf undurchsichtigen Flächen; höhere Werte erlauben mehr Reflexionen oder Glasdurchgänge und können länger dauern. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
|
||||
"render.cpu_path_lighting": "Indirekte Beleuchtung",
|
||||
"render.cpu_path_optics_reason": "Die Einstellungen bleiben erhalten. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten.",
|
||||
"render.cpu_point": "Punktlicht",
|
||||
|
||||
@@ -3397,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "CPU ray tracing needs an opaque background. Clear Transparent background to continue.",
|
||||
"render.cpu_options_pending": "This CPU panel cannot edit emission, surface effects, glass or anisotropy yet. Clear those API options before using it.",
|
||||
"render.cpu_path_depth": "Path depth",
|
||||
"render.cpu_path_help": "Adds light bouncing between opaque surfaces and through supported Glass shapes. Requires progressive rendering and traced shadows. Path depth counts shaded surfaces, Glass boundaries and scattering events, starting with the first hit (1–16). Depth 1 gives direct lighting on opaque surfaces; higher values allow more bounces or Glass crossings and may take longer. Turn off Indirect lighting to use custom reflections or anisotropy.",
|
||||
"render.cpu_path_help": "Adds light bouncing between opaque surfaces and through supported Glass shapes. Requires progressive rendering and traced shadows. Path depth counts shaded surfaces, Glass boundaries and scattering events, starting with the first hit (1–64). Depth 1 gives direct lighting on opaque surfaces; higher values allow more bounces or Glass crossings and may take longer. Turn off Indirect lighting to use custom reflections or anisotropy.",
|
||||
"render.cpu_path_lighting": "Indirect lighting",
|
||||
"render.cpu_path_optics_reason": "Your settings are kept. Turn off Indirect lighting to use custom reflections or anisotropy.",
|
||||
"render.cpu_point": "Point light",
|
||||
|
||||
@@ -3397,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "מעקב קרניים ב־CPU דורש רקע אטום. יש לבטל את „רקע שקוף” כדי להמשיך.",
|
||||
"render.cpu_options_pending": "לוח CPU זה עדיין אינו יכול לערוך פליטה, אפקטי משטח, זכוכית או אנאיזוטרופיה. יש לנקות את אפשרויות ה־API האלה לפני השימוש בו.",
|
||||
"render.cpu_path_depth": "עומק מסלול",
|
||||
"render.cpu_path_help": "מחשבת אור שמוחזר בין משטחים אטומים ועובר דרך צורות זכוכית נתמכות. נדרשים רינדור הדרגתי וצללים במעקב קרניים. עומק המסלול סופר משטחים מוצללים, גבולות זכוכית ואירועי פיזור החל מהמפגש הראשון (1–16). עומק 1 נותן תאורה ישירה למשטחים אטומים; ערכים גבוהים מאפשרים יותר החזרות או מעברים בזכוכית ועשויים לדרוש יותר זמן. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
|
||||
"render.cpu_path_help": "מחשבת אור שמוחזר בין משטחים אטומים ועובר דרך צורות זכוכית נתמכות. נדרשים רינדור הדרגתי וצללים במעקב קרניים. עומק המסלול סופר משטחים מוצללים, גבולות זכוכית ואירועי פיזור החל מהמפגש הראשון (1–64). עומק 1 נותן תאורה ישירה למשטחים אטומים; ערכים גבוהים מאפשרים יותר החזרות או מעברים בזכוכית ועשויים לדרוש יותר זמן. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
|
||||
"render.cpu_path_lighting": "תאורה עקיפה",
|
||||
"render.cpu_path_optics_reason": "ההגדרות שלך נשמרות. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
|
||||
"render.cpu_point": "אור נקודתי",
|
||||
|
||||
@@ -3397,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "CPU レイトレーシングには不透明な背景が必要です。透明な背景を解除してください。",
|
||||
"render.cpu_options_pending": "このCPUパネルでは、発光、表面効果、ガラス、異方性はまだ編集できません。使用前にそれらのAPIオプションを解除してください。",
|
||||
"render.cpu_path_depth": "パス深度",
|
||||
"render.cpu_path_help": "不透明な面の間で反射し、対応するガラス形状を通る光を計算します。プログレッシブ描画とレイトレーシングの影が必要です。パス深度は最初の交点から、照明を計算する面、ガラス境界、散乱イベントを数えます(1~16)。深度1は不透明面の直接照明のみです。値を上げると反射やガラスの通過を増やせますが、時間がかかる場合があります。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
|
||||
"render.cpu_path_help": "不透明な面の間で反射し、対応するガラス形状を通る光を計算します。プログレッシブ描画とレイトレーシングの影が必要です。パス深度は最初の交点から、照明を計算する面、ガラス境界、散乱イベントを数えます(1~64)。深度1は不透明面の直接照明のみです。値を上げると反射やガラスの通過を増やせますが、時間がかかる場合があります。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
|
||||
"render.cpu_path_lighting": "間接照明",
|
||||
"render.cpu_path_optics_reason": "設定は保持されます。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
|
||||
"render.cpu_point": "点光源",
|
||||
|
||||
@@ -2669,7 +2669,7 @@
|
||||
"render.cpu_opaque_required": "CPU 레이 트레이싱에는 불투명 배경이 필요합니다. 계속하려면 투명 배경을 해제하세요.",
|
||||
"render.cpu_options_pending": "이 CPU 패널은 아직 발광, 표면 효과, 유리, 이방성을 편집할 수 없습니다. 사용하기 전에 해당 API 옵션을 해제하세요.",
|
||||
"render.cpu_path_depth": "경로 깊이",
|
||||
"render.cpu_path_help": "불투명 표면 사이에서 반사되고 지원되는 유리 형상을 통과하는 빛을 계산합니다. 점진적 렌더링과 레이 추적 그림자가 필요합니다. 경로 깊이는 첫 교점부터 조명이 계산되는 표면, 유리 경계 및 산란 이벤트를 셉니다(1–16). 깊이 1은 불투명 표면의 직접 조명만 계산합니다. 값을 높이면 반사나 유리 통과가 늘어나며 시간이 더 걸릴 수 있습니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
|
||||
"render.cpu_path_help": "불투명 표면 사이에서 반사되고 지원되는 유리 형상을 통과하는 빛을 계산합니다. 점진적 렌더링과 레이 추적 그림자가 필요합니다. 경로 깊이는 첫 교점부터 조명이 계산되는 표면, 유리 경계 및 산란 이벤트를 셉니다(1–64). 깊이 1은 불투명 표면의 직접 조명만 계산합니다. 값을 높이면 반사나 유리 통과가 늘어나며 시간이 더 걸릴 수 있습니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
|
||||
"render.cpu_path_lighting": "간접 조명",
|
||||
"render.cpu_path_optics_reason": "설정은 유지됩니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
|
||||
"render.cpu_point": "점 조명",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.caustics_area_lights": "تأثيرات الضوء المركّز من الأضواء المساحية",
|
||||
"render.caustics_environment": "تأثيرات الضوء المركّز من البيئة",
|
||||
"render.metal_energy_compensation": "استعادة انعكاس المعادن الخشنة",
|
||||
"render.metal_energy_compensation_help": "عوّض الضوء الذي تفقده الأسطح المعدنية الخشنة بين أوجهها المجهرية. يتطلب معالج CPU إضاءة غير مباشرة.",
|
||||
"render.texture_filtering": "ترشيح تفاصيل الخامات",
|
||||
"render.texture_filtering_help": "استخدم مستويات التفاصيل والترشيح المتباين لتقليل تعرّج الخامات عند التصغير. يزيد استهلاك الذاكرة؛ يبقى الوضع القديم معطّلاً."
|
||||
},
|
||||
"de": {
|
||||
"render.caustics_area_lights": "Kaustiken von Flächenlichtern",
|
||||
"render.caustics_environment": "Kaustiken aus der Umgebung",
|
||||
"render.metal_energy_compensation": "Reflexion rauer Metalle wiederherstellen",
|
||||
"render.metal_energy_compensation_help": "Gleicht den Lichtverlust zwischen den Mikrofacetten rauer Metalle aus. Die CPU benötigt indirekte Beleuchtung.",
|
||||
"render.texture_filtering": "Texturdetails filtern",
|
||||
"render.texture_filtering_help": "Mip-Stufen und anisotrope Filterung verringern Flimmern verkleinerter Texturen. Benötigt zusätzlichen Speicher; ausgeschaltet bleibt die bisherige Filterung."
|
||||
},
|
||||
"en": {
|
||||
"render.caustics_area_lights": "Area light caustics",
|
||||
"render.caustics_environment": "Environment caustics",
|
||||
"render.metal_energy_compensation": "Recover rough metal reflectance",
|
||||
"render.metal_energy_compensation_help": "Compensate for light lost between rough metal microfacets. CPU rendering needs Indirect lighting.",
|
||||
"render.texture_filtering": "Filter texture detail",
|
||||
"render.texture_filtering_help": "Use mip levels and anisotropic filtering to reduce texture aliasing under minification. Uses more memory; off retains the previous filtering."
|
||||
},
|
||||
"he": {
|
||||
"render.caustics_area_lights": "קאוסטיקה מתאורת שטח",
|
||||
"render.caustics_environment": "קאוסטיקה מתאורת הסביבה",
|
||||
"render.metal_energy_compensation": "שחזור ההחזר של מתכת מחוספסת",
|
||||
"render.metal_energy_compensation_help": "מפצה על האור שאובד בין הפאות המיקרוסקופיות של מתכת מחוספסת. עיבוד במעבד דורש תאורה עקיפה.",
|
||||
"render.texture_filtering": "סינון פרטי מרקם",
|
||||
"render.texture_filtering_help": "שימוש ברמות פירוט ובסינון אנאיזוטרופי מפחית שינון במרקמים מוקטנים. דורש יותר זיכרון; כשהאפשרות כבויה נשמר הסינון הקודם."
|
||||
},
|
||||
"ja": {
|
||||
"render.caustics_area_lights": "面光源のコースティクス",
|
||||
"render.caustics_environment": "環境光のコースティクス",
|
||||
"render.metal_energy_compensation": "粗い金属の反射を補正",
|
||||
"render.metal_energy_compensation_help": "粗い金属の微小面間で失われる光を補います。CPU レンダリングには間接照明が必要です。",
|
||||
"render.texture_filtering": "テクスチャの細部をフィルタリング",
|
||||
"render.texture_filtering_help": "ミップレベルと異方性フィルタリングで縮小時のちらつきを抑えます。メモリ使用量が増えます。オフでは従来のフィルタリングを維持します。"
|
||||
},
|
||||
"ko": {
|
||||
"render.caustics_area_lights": "면광원 집광 효과",
|
||||
"render.caustics_environment": "환경광 집광 효과",
|
||||
"render.metal_energy_compensation": "거친 금속의 반사 복원",
|
||||
"render.metal_energy_compensation_help": "거친 금속의 미세면 사이에서 손실되는 빛을 보정합니다. CPU 렌더링에는 간접 조명이 필요합니다.",
|
||||
"render.texture_filtering": "텍스처 세부 필터링",
|
||||
"render.texture_filtering_help": "밉 레벨과 비등방성 필터링으로 축소 시 텍스처 계단 현상을 줄입니다. 메모리를 더 사용하며 끄면 기존 필터링을 유지합니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.caustics_area_lights": "Tụ quang từ đèn diện tích",
|
||||
"render.caustics_environment": "Tụ quang từ ánh sáng môi trường",
|
||||
"render.metal_energy_compensation": "Khôi phục phản xạ của kim loại nhám",
|
||||
"render.metal_energy_compensation_help": "Bù ánh sáng thất thoát giữa các vi diện của kim loại nhám. Kết xuất CPU cần chiếu sáng gián tiếp.",
|
||||
"render.texture_filtering": "Lọc chi tiết kết cấu",
|
||||
"render.texture_filtering_help": "Dùng các mức mip và lọc bất đẳng hướng để giảm răng cưa khi thu nhỏ kết cấu. Tốn thêm bộ nhớ; tắt để giữ cách lọc trước đây."
|
||||
},
|
||||
"zh": {
|
||||
"render.caustics_area_lights": "面光源焦散",
|
||||
"render.caustics_environment": "环境光焦散",
|
||||
"render.metal_energy_compensation": "恢复粗糙金属的反射",
|
||||
"render.metal_energy_compensation_help": "补偿粗糙金属微表面之间损失的光能。CPU 渲染需要间接照明。",
|
||||
"render.texture_filtering": "过滤纹理细节",
|
||||
"render.texture_filtering_help": "使用多级纹理和各向异性过滤,减少纹理缩小时的锯齿。需要更多内存;关闭时保留原有过滤方式。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
{
|
||||
"en": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "Saved value: {samples} spp. New selections are limited to {maximum} spp.",
|
||||
"render.quality_preset_limit": "This preset exceeds the 16384 spp selection limit. Choose a sample count up to 16384.",
|
||||
"render.supersampling": "Supersampling",
|
||||
"render.supersampling_off": "Off",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "Render at twice the width and height, then reduce in linear light. The output size stays unchanged.",
|
||||
"render.supersampling_dimensions": "Renders {width} × {height} and reduces to {output_width} × {output_height}. Uses four times the pixels.",
|
||||
"render.cpu_threads": "CPU workers",
|
||||
"render.cpu_threads_auto": "Auto",
|
||||
"render.cpu_threads_single": "Single",
|
||||
"render.cpu_threads_custom": "Custom",
|
||||
"render.cpu_threads_help": "Auto uses available CPU workers. One worker runs serially. More workers can need more memory.",
|
||||
"render.cpu_threads_web": "Browser rendering uses one cooperative worker. The saved desktop worker choice is preserved.",
|
||||
"render.denoise_passes": "Filter passes",
|
||||
"render.denoise_strength": "Denoising strength",
|
||||
"render.denoise_firefly": "Suppress isolated bright pixels",
|
||||
"render.denoise_settings_help": "More passes smooth a wider area. Strength controls how much filtering each pass applies. Unsupported optical paths remain unfiltered.",
|
||||
"render.denoise_diagnostics_enable": "Collect denoising diagnostics",
|
||||
"render.denoise_diagnostics_help": "Collects eligible and skipped pixel counts even when denoising is off. Counts use the internal render size; the mask uses the output size.",
|
||||
"render.denoise_diagnostics_title": "Denoising diagnostics",
|
||||
"render.denoise_diagnostics_grid": "Pixel counts at internal size {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "Eligible: {eligible} · Skipped: {skipped}",
|
||||
"render.denoise_mask_unavailable": "Enable denoising diagnostics and render an image to inspect its mask.",
|
||||
"render.denoise_mask_copy_help": "Export the denoising mask as PNG, or return to the rendered image to copy it.",
|
||||
"render.denoise_view_image": "Rendered image",
|
||||
"render.denoise_view_mask": "Denoising mask",
|
||||
"render.denoise_export_mask": "Export mask PNG",
|
||||
"render.denoise_format_mask": "Mask PNG",
|
||||
"render.denoise_class_unavailable": "Skipped: no guide",
|
||||
"render.denoise_class_surface": "Eligible: surface",
|
||||
"render.denoise_class_optical": "Eligible: refracted surface",
|
||||
"render.denoise_class_background": "Skipped: background",
|
||||
"render.denoise_class_emitter": "Skipped: light emitter",
|
||||
"render.denoise_class_material": "Skipped: unsupported material",
|
||||
"render.denoise_class_nested": "Skipped: nested medium",
|
||||
"render.denoise_class_lens": "Skipped: depth of field",
|
||||
"render.denoise_class_volume": "Skipped: volume scattering",
|
||||
"render.denoise_class_depth": "Skipped: path depth limit",
|
||||
"render.denoise_class_degenerate": "Skipped: degenerate path",
|
||||
"render.denoise_class_exit": "Skipped: incompatible exit",
|
||||
"render.denoise_class_tir": "Skipped: total internal reflection",
|
||||
"render.denoise_class_transmission": "Skipped: further transmission",
|
||||
"render.denoise_class_reflection": "Skipped: reflective endpoint",
|
||||
"render.denoise_class_identity": "Skipped: guide identity limit",
|
||||
"render.denoise_class_coverage": "Skipped: partial coverage"
|
||||
},
|
||||
"ar": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "القيمة المحفوظة: {samples} عينة لكل بكسل. تقتصر الاختيارات الجديدة على {maximum} عينة لكل بكسل.",
|
||||
"render.quality_preset_limit": "يتجاوز هذا الإعداد المسبق حد الاختيار البالغ 16384 عينة لكل بكسل. اختر عدد عينات لا يتجاوز 16384.",
|
||||
"render.supersampling": "أخذ عينات فائقة",
|
||||
"render.supersampling_off": "إيقاف",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "يُصيَّر المشهد بضعف العرض والارتفاع، ثم يُصغَّر في فضاء الإضاءة الخطي. يبقى حجم الإخراج دون تغيير.",
|
||||
"render.supersampling_dimensions": "يُصيَّر بحجم {width} × {height} ويُصغَّر إلى {output_width} × {output_height}. يستخدم أربعة أضعاف عدد البكسلات.",
|
||||
"render.cpu_threads": "خيوط المعالجة",
|
||||
"render.cpu_threads_auto": "تلقائي",
|
||||
"render.cpu_threads_single": "واحد",
|
||||
"render.cpu_threads_custom": "مخصص",
|
||||
"render.cpu_threads_help": "يستخدم الوضع التلقائي خيوط المعالجة المتاحة. ينفّذ خيط واحد العمل تسلسليًا. قد تحتاج الخيوط الإضافية إلى ذاكرة أكبر.",
|
||||
"render.cpu_threads_web": "يستخدم التصيير في المتصفح خيطًا تعاونيًا واحدًا. يُحفظ اختيار خيوط المعالجة الخاص بسطح المكتب.",
|
||||
"render.denoise_passes": "مرات الترشيح",
|
||||
"render.denoise_strength": "قوة إزالة الضوضاء",
|
||||
"render.denoise_firefly": "تقليل البكسلات الساطعة المعزولة",
|
||||
"render.denoise_settings_help": "تُنعّم مرات الترشيح الإضافية مساحة أوسع. تحدد القوة مقدار الترشيح المطبّق في كل مرة. تبقى المسارات البصرية غير المدعومة دون ترشيح.",
|
||||
"render.denoise_diagnostics_enable": "جمع تشخيصات إزالة الضوضاء",
|
||||
"render.denoise_diagnostics_help": "يجمع أعداد البكسلات المؤهلة والمتجاوزة حتى عند إيقاف إزالة الضوضاء. تعتمد الأعداد على حجم التصيير الداخلي، ويعتمد القناع على حجم الإخراج.",
|
||||
"render.denoise_diagnostics_title": "تشخيصات إزالة الضوضاء",
|
||||
"render.denoise_diagnostics_grid": "أعداد البكسلات بالحجم الداخلي {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "المؤهلة: {eligible} · المتجاوزة: {skipped}",
|
||||
"render.denoise_mask_unavailable": "فعّل تشخيصات إزالة الضوضاء وصيّر صورة لفحص قناعها.",
|
||||
"render.denoise_mask_copy_help": "صدّر قناع إزالة الضوضاء بصيغة PNG، أو عُد إلى الصورة المصيّرة لنسخها.",
|
||||
"render.denoise_view_image": "الصورة المصيّرة",
|
||||
"render.denoise_view_mask": "قناع إزالة الضوضاء",
|
||||
"render.denoise_export_mask": "تصدير القناع بصيغة PNG",
|
||||
"render.denoise_format_mask": "قناع PNG",
|
||||
"render.denoise_class_unavailable": "متجاوز: بلا دليل",
|
||||
"render.denoise_class_surface": "مؤهل: سطح",
|
||||
"render.denoise_class_optical": "مؤهل: سطح عبر انكسار",
|
||||
"render.denoise_class_background": "متجاوز: خلفية",
|
||||
"render.denoise_class_emitter": "متجاوز: مصدر ضوء",
|
||||
"render.denoise_class_material": "متجاوز: مادة غير مدعومة",
|
||||
"render.denoise_class_nested": "متجاوز: وسط متداخل",
|
||||
"render.denoise_class_lens": "متجاوز: عمق المجال",
|
||||
"render.denoise_class_volume": "متجاوز: تشتت حجمي",
|
||||
"render.denoise_class_depth": "متجاوز: حد عمق المسار",
|
||||
"render.denoise_class_degenerate": "متجاوز: مسار متدهور",
|
||||
"render.denoise_class_exit": "متجاوز: خروج غير متوافق",
|
||||
"render.denoise_class_tir": "متجاوز: انعكاس داخلي كلي",
|
||||
"render.denoise_class_transmission": "متجاوز: نفاذ إضافي",
|
||||
"render.denoise_class_reflection": "متجاوز: نهاية عاكسة",
|
||||
"render.denoise_class_identity": "متجاوز: حد هوية الدليل",
|
||||
"render.denoise_class_coverage": "متجاوز: تغطية جزئية"
|
||||
},
|
||||
"he": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "ערך שמור: {samples} דגימות לפיקסל. בחירות חדשות מוגבלות ל־{maximum} דגימות לפיקסל.",
|
||||
"render.quality_preset_limit": "הגדרה קבועה זו חורגת ממגבלת הבחירה של 16384 דגימות לפיקסל. יש לבחור עד 16384 דגימות.",
|
||||
"render.supersampling": "דגימת יתר",
|
||||
"render.supersampling_off": "כבוי",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "רינדור ברוחב ובגובה כפולים, ואז הקטנה במרחב אור ליניארי. גודל הפלט נשאר ללא שינוי.",
|
||||
"render.supersampling_dimensions": "רינדור בגודל {width} × {height} והקטנה ל־{output_width} × {output_height}. נעשה שימוש בפי ארבעה פיקסלים.",
|
||||
"render.cpu_threads": "תהליכוני CPU",
|
||||
"render.cpu_threads_auto": "אוטומטי",
|
||||
"render.cpu_threads_single": "יחיד",
|
||||
"render.cpu_threads_custom": "מותאם",
|
||||
"render.cpu_threads_help": "המצב האוטומטי משתמש בתהליכוני ה־CPU הזמינים. תהליכון יחיד פועל באופן סדרתי. תהליכונים נוספים עשויים לדרוש יותר זיכרון.",
|
||||
"render.cpu_threads_web": "רינדור בדפדפן משתמש בתהליכון שיתופי אחד. בחירת התהליכונים השמורה למחשב נשמרת.",
|
||||
"render.denoise_passes": "מעברי סינון",
|
||||
"render.denoise_strength": "עוצמת הפחתת רעש",
|
||||
"render.denoise_firefly": "דיכוי פיקסלים בהירים מבודדים",
|
||||
"render.denoise_settings_help": "מעברים נוספים מחליקים אזור רחב יותר. העוצמה קובעת כמה סינון מופעל בכל מעבר. מסלולים אופטיים שאינם נתמכים נשארים ללא סינון.",
|
||||
"render.denoise_diagnostics_enable": "איסוף נתוני אבחון להפחתת רעש",
|
||||
"render.denoise_diagnostics_help": "נאספים מספרי הפיקסלים המתאימים ואלה שדולגו, גם כשהפחתת הרעש כבויה. הספירה לפי גודל הרינדור הפנימי; המסכה לפי גודל הפלט.",
|
||||
"render.denoise_diagnostics_title": "אבחון הפחתת רעש",
|
||||
"render.denoise_diagnostics_grid": "ספירת פיקסלים בגודל הפנימי {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "מתאימים: {eligible} · דולגו: {skipped}",
|
||||
"render.denoise_mask_unavailable": "יש להפעיל אבחון הפחתת רעש ולרנדר תמונה כדי לבדוק את המסכה שלה.",
|
||||
"render.denoise_mask_copy_help": "אפשר לייצא את מסכת הפחתת הרעש כ־PNG, או לחזור לתמונה המרונדרת כדי להעתיק אותה.",
|
||||
"render.denoise_view_image": "תמונה מרונדרת",
|
||||
"render.denoise_view_mask": "מסכת הפחתת רעש",
|
||||
"render.denoise_export_mask": "ייצוא המסכה כ־PNG",
|
||||
"render.denoise_format_mask": "מסכה PNG",
|
||||
"render.denoise_class_unavailable": "דולג: אין נתוני הנחיה",
|
||||
"render.denoise_class_surface": "מתאים: משטח",
|
||||
"render.denoise_class_optical": "מתאים: משטח דרך שבירה",
|
||||
"render.denoise_class_background": "דולג: רקע",
|
||||
"render.denoise_class_emitter": "דולג: מקור אור",
|
||||
"render.denoise_class_material": "דולג: חומר לא נתמך",
|
||||
"render.denoise_class_nested": "דולג: תווך מקונן",
|
||||
"render.denoise_class_lens": "דולג: עומק שדה",
|
||||
"render.denoise_class_volume": "דולג: פיזור נפחי",
|
||||
"render.denoise_class_depth": "דולג: מגבלת עומק מסלול",
|
||||
"render.denoise_class_degenerate": "דולג: מסלול מנוון",
|
||||
"render.denoise_class_exit": "דולג: יציאה לא תואמת",
|
||||
"render.denoise_class_tir": "דולג: החזרה פנימית מלאה",
|
||||
"render.denoise_class_transmission": "דולג: העברה נוספת",
|
||||
"render.denoise_class_reflection": "דולג: נקודת סיום מחזירה",
|
||||
"render.denoise_class_identity": "דולג: מגבלת מזהי הנחיה",
|
||||
"render.denoise_class_coverage": "דולג: כיסוי חלקי"
|
||||
},
|
||||
"ja": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "保存値: {samples} spp。新しい選択値の上限は {maximum} spp です。",
|
||||
"render.quality_preset_limit": "このプリセットは選択上限の 16384 spp を超えています。16384 以下のサンプル数を選択してください。",
|
||||
"render.supersampling": "スーパーサンプリング",
|
||||
"render.supersampling_off": "オフ",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "幅と高さをそれぞれ 2 倍にしてレンダリングし、リニアライトで縮小します。出力サイズは変わりません。",
|
||||
"render.supersampling_dimensions": "{width} × {height} でレンダリングし、{output_width} × {output_height} に縮小します。ピクセル数は 4 倍になります。",
|
||||
"render.cpu_threads": "CPU ワーカー",
|
||||
"render.cpu_threads_auto": "自動",
|
||||
"render.cpu_threads_single": "1 ワーカー",
|
||||
"render.cpu_threads_custom": "カスタム",
|
||||
"render.cpu_threads_help": "自動では利用可能な CPU ワーカーを使用します。1 ワーカーでは順次実行します。ワーカー数を増やすとメモリ使用量が増える場合があります。",
|
||||
"render.cpu_threads_web": "ブラウザーでのレンダリングは単一の協調ワーカーを使用します。保存されたデスクトップ用ワーカー設定は保持されます。",
|
||||
"render.denoise_passes": "フィルターパス数",
|
||||
"render.denoise_strength": "ノイズ除去の強度",
|
||||
"render.denoise_firefly": "孤立した高輝度ピクセルを抑制",
|
||||
"render.denoise_settings_help": "パス数を増やすと、より広い範囲が平滑化されます。強度は各パスでのフィルター適用量を調整します。未対応の光路にはフィルターを適用しません。",
|
||||
"render.denoise_diagnostics_enable": "ノイズ除去の診断情報を収集",
|
||||
"render.denoise_diagnostics_help": "ノイズ除去がオフでも、対象と対象外のピクセル数を収集します。集計は内部レンダリングサイズ、マスクは出力サイズに対応します。",
|
||||
"render.denoise_diagnostics_title": "ノイズ除去の診断",
|
||||
"render.denoise_diagnostics_grid": "内部サイズ {width} × {height} でのピクセル数。",
|
||||
"render.denoise_diagnostics_counts": "対象: {eligible} · 対象外: {skipped}",
|
||||
"render.denoise_mask_unavailable": "ノイズ除去の診断を有効にして画像をレンダリングすると、マスクを確認できます。",
|
||||
"render.denoise_mask_copy_help": "ノイズ除去マスクを PNG で書き出すか、レンダリング画像に戻ってコピーしてください。",
|
||||
"render.denoise_view_image": "レンダリング画像",
|
||||
"render.denoise_view_mask": "ノイズ除去マスク",
|
||||
"render.denoise_export_mask": "マスク PNG を書き出す",
|
||||
"render.denoise_format_mask": "マスク PNG",
|
||||
"render.denoise_class_unavailable": "対象外: ガイドなし",
|
||||
"render.denoise_class_surface": "対象: サーフェス",
|
||||
"render.denoise_class_optical": "対象: 屈折越しのサーフェス",
|
||||
"render.denoise_class_background": "対象外: 背景",
|
||||
"render.denoise_class_emitter": "対象外: 発光体",
|
||||
"render.denoise_class_material": "対象外: 未対応のマテリアル",
|
||||
"render.denoise_class_nested": "対象外: 入れ子の媒質",
|
||||
"render.denoise_class_lens": "対象外: 被写界深度",
|
||||
"render.denoise_class_volume": "対象外: ボリューム散乱",
|
||||
"render.denoise_class_depth": "対象外: 経路深度の上限",
|
||||
"render.denoise_class_degenerate": "対象外: 退化した経路",
|
||||
"render.denoise_class_exit": "対象外: 不整合な出射",
|
||||
"render.denoise_class_tir": "対象外: 全反射",
|
||||
"render.denoise_class_transmission": "対象外: 追加の透過",
|
||||
"render.denoise_class_reflection": "対象外: 反射する終点",
|
||||
"render.denoise_class_identity": "対象外: ガイド識別子の上限",
|
||||
"render.denoise_class_coverage": "対象外: 部分的なカバレッジ"
|
||||
},
|
||||
"vi": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "Giá trị đã lưu: {samples} mẫu/pixel. Lựa chọn mới được giới hạn ở {maximum} mẫu/pixel.",
|
||||
"render.quality_preset_limit": "Thiết lập sẵn này vượt quá giới hạn 16384 mẫu/pixel. Hãy chọn số mẫu tối đa là 16384.",
|
||||
"render.supersampling": "Lấy mẫu siêu phân giải",
|
||||
"render.supersampling_off": "Tắt",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "Kết xuất với chiều rộng và chiều cao gấp đôi, rồi thu nhỏ trong không gian ánh sáng tuyến tính. Kích thước đầu ra không đổi.",
|
||||
"render.supersampling_dimensions": "Kết xuất ở {width} × {height} rồi thu nhỏ về {output_width} × {output_height}. Sử dụng số pixel gấp bốn lần.",
|
||||
"render.cpu_threads": "Luồng CPU",
|
||||
"render.cpu_threads_auto": "Tự động",
|
||||
"render.cpu_threads_single": "Một luồng",
|
||||
"render.cpu_threads_custom": "Tùy chỉnh",
|
||||
"render.cpu_threads_help": "Chế độ tự động dùng các luồng CPU khả dụng. Một luồng chạy tuần tự. Nhiều luồng hơn có thể cần nhiều bộ nhớ hơn.",
|
||||
"render.cpu_threads_web": "Kết xuất trong trình duyệt dùng một luồng phối hợp. Lựa chọn luồng đã lưu cho ứng dụng máy tính được giữ nguyên.",
|
||||
"render.denoise_passes": "Số lượt lọc",
|
||||
"render.denoise_strength": "Cường độ khử nhiễu",
|
||||
"render.denoise_firefly": "Giảm các pixel sáng đơn lẻ",
|
||||
"render.denoise_settings_help": "Nhiều lượt lọc hơn làm mịn vùng rộng hơn. Cường độ điều chỉnh mức lọc áp dụng trong mỗi lượt. Các đường truyền quang học không được hỗ trợ vẫn không được lọc.",
|
||||
"render.denoise_diagnostics_enable": "Thu thập chẩn đoán khử nhiễu",
|
||||
"render.denoise_diagnostics_help": "Thu thập số pixel đủ điều kiện và bị bỏ qua ngay cả khi tắt khử nhiễu. Số lượng dựa trên kích thước kết xuất nội bộ; mặt nạ dùng kích thước đầu ra.",
|
||||
"render.denoise_diagnostics_title": "Chẩn đoán khử nhiễu",
|
||||
"render.denoise_diagnostics_grid": "Số pixel ở kích thước nội bộ {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "Đủ điều kiện: {eligible} · Bỏ qua: {skipped}",
|
||||
"render.denoise_mask_unavailable": "Bật chẩn đoán khử nhiễu và kết xuất ảnh để kiểm tra mặt nạ.",
|
||||
"render.denoise_mask_copy_help": "Xuất mặt nạ khử nhiễu dưới dạng PNG, hoặc quay lại ảnh đã kết xuất để sao chép.",
|
||||
"render.denoise_view_image": "Ảnh đã kết xuất",
|
||||
"render.denoise_view_mask": "Mặt nạ khử nhiễu",
|
||||
"render.denoise_export_mask": "Xuất mặt nạ PNG",
|
||||
"render.denoise_format_mask": "PNG mặt nạ",
|
||||
"render.denoise_class_unavailable": "Bỏ qua: không có dữ liệu hướng dẫn",
|
||||
"render.denoise_class_surface": "Đủ điều kiện: bề mặt",
|
||||
"render.denoise_class_optical": "Đủ điều kiện: bề mặt qua khúc xạ",
|
||||
"render.denoise_class_background": "Bỏ qua: nền",
|
||||
"render.denoise_class_emitter": "Bỏ qua: nguồn phát sáng",
|
||||
"render.denoise_class_material": "Bỏ qua: vật liệu không hỗ trợ",
|
||||
"render.denoise_class_nested": "Bỏ qua: môi trường lồng nhau",
|
||||
"render.denoise_class_lens": "Bỏ qua: độ sâu trường ảnh",
|
||||
"render.denoise_class_volume": "Bỏ qua: tán xạ thể tích",
|
||||
"render.denoise_class_depth": "Bỏ qua: giới hạn độ sâu đường truyền",
|
||||
"render.denoise_class_degenerate": "Bỏ qua: đường truyền suy biến",
|
||||
"render.denoise_class_exit": "Bỏ qua: điểm thoát không tương thích",
|
||||
"render.denoise_class_tir": "Bỏ qua: phản xạ toàn phần",
|
||||
"render.denoise_class_transmission": "Bỏ qua: truyền qua tiếp",
|
||||
"render.denoise_class_reflection": "Bỏ qua: điểm cuối phản xạ",
|
||||
"render.denoise_class_identity": "Bỏ qua: giới hạn định danh hướng dẫn",
|
||||
"render.denoise_class_coverage": "Bỏ qua: độ phủ một phần"
|
||||
},
|
||||
"zh": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "已保存的值:{samples} spp。新选择的上限为 {maximum} spp。",
|
||||
"render.quality_preset_limit": "此预设超过 16384 spp 的选择上限。请选择不超过 16384 的采样数。",
|
||||
"render.supersampling": "超采样",
|
||||
"render.supersampling_off": "关闭",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "以双倍宽度和高度渲染,然后在线性光空间中缩小。输出尺寸保持不变。",
|
||||
"render.supersampling_dimensions": "以 {width} × {height} 渲染,再缩小至 {output_width} × {output_height}。使用四倍像素。",
|
||||
"render.cpu_threads": "CPU 工作线程",
|
||||
"render.cpu_threads_auto": "自动",
|
||||
"render.cpu_threads_single": "单线程",
|
||||
"render.cpu_threads_custom": "自定义",
|
||||
"render.cpu_threads_help": "自动模式使用可用的 CPU 工作线程。单线程按顺序执行。更多线程可能需要更多内存。",
|
||||
"render.cpu_threads_web": "浏览器渲染使用一个协作式工作线程。已保存的桌面线程选择会保留。",
|
||||
"render.denoise_passes": "滤波轮数",
|
||||
"render.denoise_strength": "降噪强度",
|
||||
"render.denoise_firefly": "抑制孤立亮点",
|
||||
"render.denoise_settings_help": "更多轮次会平滑更大范围。强度控制每轮滤波的应用程度。不支持的光路保持未滤波状态。",
|
||||
"render.denoise_diagnostics_enable": "收集降噪诊断信息",
|
||||
"render.denoise_diagnostics_help": "即使关闭降噪,也会收集可滤波和跳过的像素数量。计数基于内部渲染尺寸;蒙版使用输出尺寸。",
|
||||
"render.denoise_diagnostics_title": "降噪诊断",
|
||||
"render.denoise_diagnostics_grid": "内部尺寸 {width} × {height} 下的像素计数。",
|
||||
"render.denoise_diagnostics_counts": "可滤波:{eligible} · 已跳过:{skipped}",
|
||||
"render.denoise_mask_unavailable": "启用降噪诊断并渲染图像后,即可查看其蒙版。",
|
||||
"render.denoise_mask_copy_help": "将降噪蒙版导出为 PNG,或返回渲染图像后复制图像。",
|
||||
"render.denoise_view_image": "渲染图像",
|
||||
"render.denoise_view_mask": "降噪蒙版",
|
||||
"render.denoise_export_mask": "导出蒙版 PNG",
|
||||
"render.denoise_format_mask": "蒙版 PNG",
|
||||
"render.denoise_class_unavailable": "已跳过:无引导数据",
|
||||
"render.denoise_class_surface": "可滤波:表面",
|
||||
"render.denoise_class_optical": "可滤波:折射后的表面",
|
||||
"render.denoise_class_background": "已跳过:背景",
|
||||
"render.denoise_class_emitter": "已跳过:发光体",
|
||||
"render.denoise_class_material": "已跳过:不支持的材质",
|
||||
"render.denoise_class_nested": "已跳过:嵌套介质",
|
||||
"render.denoise_class_lens": "已跳过:景深",
|
||||
"render.denoise_class_volume": "已跳过:体积散射",
|
||||
"render.denoise_class_depth": "已跳过:路径深度上限",
|
||||
"render.denoise_class_degenerate": "已跳过:退化路径",
|
||||
"render.denoise_class_exit": "已跳过:不兼容的出射",
|
||||
"render.denoise_class_tir": "已跳过:全内反射",
|
||||
"render.denoise_class_transmission": "已跳过:进一步透射",
|
||||
"render.denoise_class_reflection": "已跳过:反射终点",
|
||||
"render.denoise_class_identity": "已跳过:引导标识上限",
|
||||
"render.denoise_class_coverage": "已跳过:部分覆盖"
|
||||
},
|
||||
"de": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "Gespeicherter Wert: {samples} spp. Neue Werte sind auf {maximum} spp begrenzt.",
|
||||
"render.quality_preset_limit": "Diese Vorgabe überschreitet die Auswahlgrenze von 16384 spp. Wählen Sie höchstens 16384 Samples.",
|
||||
"render.supersampling": "Supersampling",
|
||||
"render.supersampling_off": "Aus",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "Mit doppelter Breite und Höhe rendern und anschließend im linearen Farbraum verkleinern. Die Ausgabegröße bleibt unverändert.",
|
||||
"render.supersampling_dimensions": "Rendert {width} × {height} und verkleinert auf {output_width} × {output_height}. Verwendet die vierfache Pixelzahl.",
|
||||
"render.cpu_threads": "CPU-Threads",
|
||||
"render.cpu_threads_auto": "Auto",
|
||||
"render.cpu_threads_single": "Einzeln",
|
||||
"render.cpu_threads_custom": "Eigene",
|
||||
"render.cpu_threads_help": "Auto verwendet die verfügbaren CPU-Threads. Ein Thread arbeitet seriell. Mehr Threads können mehr Speicher benötigen.",
|
||||
"render.cpu_threads_web": "Im Browser wird ein kooperativer Thread verwendet. Die gespeicherte Desktop-Auswahl bleibt erhalten.",
|
||||
"render.denoise_passes": "Filterdurchläufe",
|
||||
"render.denoise_strength": "Entrauschungsstärke",
|
||||
"render.denoise_firefly": "Einzelne helle Pixel unterdrücken",
|
||||
"render.denoise_settings_help": "Mehr Durchläufe glätten einen größeren Bereich. Die Stärke regelt die Filterwirkung jedes Durchlaufs. Nicht unterstützte Lichtwege bleiben ungefiltert.",
|
||||
"render.denoise_diagnostics_enable": "Entrauschungsdiagnose erfassen",
|
||||
"render.denoise_diagnostics_help": "Erfasst geeignete und übersprungene Pixel auch ohne Entrauschung. Die Zählung nutzt die interne Rendergröße, die Maske die Ausgabegröße.",
|
||||
"render.denoise_diagnostics_title": "Entrauschungsdiagnose",
|
||||
"render.denoise_diagnostics_grid": "Pixelzahlen bei interner Größe {width} × {height}.",
|
||||
"render.denoise_diagnostics_counts": "Geeignet: {eligible} · Übersprungen: {skipped}",
|
||||
"render.denoise_mask_unavailable": "Aktivieren Sie die Entrauschungsdiagnose und rendern Sie ein Bild, um seine Maske zu prüfen.",
|
||||
"render.denoise_mask_copy_help": "Exportieren Sie die Entrauschungsmaske als PNG oder kehren Sie zum gerenderten Bild zurück, um es zu kopieren.",
|
||||
"render.denoise_view_image": "Gerendertes Bild",
|
||||
"render.denoise_view_mask": "Entrauschungsmaske",
|
||||
"render.denoise_export_mask": "Maske als PNG exportieren",
|
||||
"render.denoise_format_mask": "Maske PNG",
|
||||
"render.denoise_class_unavailable": "Übersprungen: keine Führungsdaten",
|
||||
"render.denoise_class_surface": "Geeignet: Oberfläche",
|
||||
"render.denoise_class_optical": "Geeignet: Oberfläche durch Brechung",
|
||||
"render.denoise_class_background": "Übersprungen: Hintergrund",
|
||||
"render.denoise_class_emitter": "Übersprungen: Lichtquelle",
|
||||
"render.denoise_class_material": "Übersprungen: nicht unterstütztes Material",
|
||||
"render.denoise_class_nested": "Übersprungen: verschachteltes Medium",
|
||||
"render.denoise_class_lens": "Übersprungen: Schärfentiefe",
|
||||
"render.denoise_class_volume": "Übersprungen: Volumenstreuung",
|
||||
"render.denoise_class_depth": "Übersprungen: Pfadtiefengrenze",
|
||||
"render.denoise_class_degenerate": "Übersprungen: entarteter Pfad",
|
||||
"render.denoise_class_exit": "Übersprungen: unpassender Austritt",
|
||||
"render.denoise_class_tir": "Übersprungen: Totalreflexion",
|
||||
"render.denoise_class_transmission": "Übersprungen: weitere Transmission",
|
||||
"render.denoise_class_reflection": "Übersprungen: reflektierender Endpunkt",
|
||||
"render.denoise_class_identity": "Übersprungen: Grenze der Führungskennungen",
|
||||
"render.denoise_class_coverage": "Übersprungen: teilweise Abdeckung"
|
||||
},
|
||||
"ko": {
|
||||
"render.size_8k_uhd": "8K UHD",
|
||||
"render.quality_legacy_samples": "저장된 값: {samples} spp. 새 선택값은 {maximum} spp로 제한됩니다.",
|
||||
"render.quality_preset_limit": "이 프리셋은 선택 한도인 16384 spp를 초과합니다. 샘플 수를 16384 이하로 선택하세요.",
|
||||
"render.supersampling": "슈퍼샘플링",
|
||||
"render.supersampling_off": "끄기",
|
||||
"render.supersampling_x2": "2×",
|
||||
"render.supersampling_help": "가로와 세로를 각각 두 배로 렌더링한 뒤 선형 광 공간에서 축소합니다. 출력 크기는 그대로 유지됩니다.",
|
||||
"render.supersampling_dimensions": "{width} × {height}로 렌더링한 뒤 {output_width} × {output_height}로 축소합니다. 픽셀 수가 네 배가 됩니다.",
|
||||
"render.cpu_threads": "CPU 작업 스레드",
|
||||
"render.cpu_threads_auto": "자동",
|
||||
"render.cpu_threads_single": "단일",
|
||||
"render.cpu_threads_custom": "사용자 지정",
|
||||
"render.cpu_threads_help": "자동은 사용 가능한 CPU 작업 스레드를 사용합니다. 단일 스레드는 순차적으로 실행됩니다. 스레드가 많을수록 메모리가 더 필요할 수 있습니다.",
|
||||
"render.cpu_threads_web": "브라우저 렌더링은 하나의 협력형 작업 스레드를 사용합니다. 저장된 데스크톱 스레드 설정은 유지됩니다.",
|
||||
"render.denoise_passes": "필터 반복 횟수",
|
||||
"render.denoise_strength": "노이즈 제거 강도",
|
||||
"render.denoise_firefly": "고립된 밝은 픽셀 억제",
|
||||
"render.denoise_settings_help": "반복 횟수가 많을수록 더 넓은 영역을 부드럽게 합니다. 강도는 각 단계에서 적용하는 필터의 정도를 조절합니다. 지원되지 않는 광 경로에는 필터를 적용하지 않습니다.",
|
||||
"render.denoise_diagnostics_enable": "노이즈 제거 진단 수집",
|
||||
"render.denoise_diagnostics_help": "노이즈 제거가 꺼져 있어도 적용 가능 픽셀과 건너뛴 픽셀 수를 수집합니다. 집계는 내부 렌더링 크기 기준이며 마스크는 출력 크기를 사용합니다.",
|
||||
"render.denoise_diagnostics_title": "노이즈 제거 진단",
|
||||
"render.denoise_diagnostics_grid": "내부 크기 {width} × {height} 기준 픽셀 수입니다.",
|
||||
"render.denoise_diagnostics_counts": "적용 가능: {eligible} · 건너뜀: {skipped}",
|
||||
"render.denoise_mask_unavailable": "노이즈 제거 진단을 켜고 이미지를 렌더링하면 마스크를 확인할 수 있습니다.",
|
||||
"render.denoise_mask_copy_help": "노이즈 제거 마스크를 PNG로 내보내거나 렌더링 이미지로 돌아가 이미지를 복사하세요.",
|
||||
"render.denoise_view_image": "렌더링 이미지",
|
||||
"render.denoise_view_mask": "노이즈 제거 마스크",
|
||||
"render.denoise_export_mask": "마스크 PNG 내보내기",
|
||||
"render.denoise_format_mask": "마스크 PNG",
|
||||
"render.denoise_class_unavailable": "건너뜀: 가이드 없음",
|
||||
"render.denoise_class_surface": "적용 가능: 표면",
|
||||
"render.denoise_class_optical": "적용 가능: 굴절을 통해 보이는 표면",
|
||||
"render.denoise_class_background": "건너뜀: 배경",
|
||||
"render.denoise_class_emitter": "건너뜀: 발광체",
|
||||
"render.denoise_class_material": "건너뜀: 지원되지 않는 재질",
|
||||
"render.denoise_class_nested": "건너뜀: 중첩된 매질",
|
||||
"render.denoise_class_lens": "건너뜀: 피사계 심도",
|
||||
"render.denoise_class_volume": "건너뜀: 볼륨 산란",
|
||||
"render.denoise_class_depth": "건너뜀: 경로 깊이 한도",
|
||||
"render.denoise_class_degenerate": "건너뜀: 퇴화 경로",
|
||||
"render.denoise_class_exit": "건너뜀: 호환되지 않는 출사",
|
||||
"render.denoise_class_tir": "건너뜀: 전반사",
|
||||
"render.denoise_class_transmission": "건너뜀: 추가 투과",
|
||||
"render.denoise_class_reflection": "건너뜀: 반사 종점",
|
||||
"render.denoise_class_identity": "건너뜀: 가이드 식별자 한도",
|
||||
"render.denoise_class_coverage": "건너뜀: 부분 커버리지"
|
||||
}
|
||||
}
|
||||
@@ -3397,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "Dò tia trên CPU yêu cầu nền đục. Hãy bỏ chọn 'Nền trong suốt' để tiếp tục.",
|
||||
"render.cpu_options_pending": "Bảng điều khiển CPU này chưa hỗ trợ chỉnh sửa tự phát xạ, hiệu ứng bề mặt, thủy tinh hoặc bất đẳng hướng. Hãy xóa các tùy chọn API đó trước khi sử dụng.",
|
||||
"render.cpu_path_depth": "Độ sâu đường truyền",
|
||||
"render.cpu_path_help": "Tính ánh sáng phản xạ giữa các bề mặt đục và truyền qua các hình kính được hỗ trợ. Cần kết xuất lũy tiến và bóng dò tia. Độ sâu đường đi đếm các bề mặt được chiếu sáng, ranh giới kính và các sự kiện tán xạ từ giao điểm đầu tiên (1–16). Độ sâu 1 chỉ chiếu sáng trực tiếp bề mặt đục; giá trị cao hơn cho phép thêm lần phản xạ hoặc xuyên kính và có thể lâu hơn. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
|
||||
"render.cpu_path_help": "Tính ánh sáng phản xạ giữa các bề mặt đục và truyền qua các hình kính được hỗ trợ. Cần kết xuất lũy tiến và bóng dò tia. Độ sâu đường đi đếm các bề mặt được chiếu sáng, ranh giới kính và các sự kiện tán xạ từ giao điểm đầu tiên (1–64). Độ sâu 1 chỉ chiếu sáng trực tiếp bề mặt đục; giá trị cao hơn cho phép thêm lần phản xạ hoặc xuyên kính và có thể lâu hơn. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
|
||||
"render.cpu_path_lighting": "Chiếu sáng gián tiếp",
|
||||
"render.cpu_path_optics_reason": "Các thiết lập được giữ nguyên. Tắt Chiếu sáng gián tiếp để dùng phản xạ tùy chỉnh hoặc dị hướng.",
|
||||
"render.cpu_point": "Nguồn sáng điểm",
|
||||
|
||||
@@ -3397,7 +3397,7 @@
|
||||
"render.cpu_opaque_required": "CPU 光线追踪需要不透明背景。请关闭透明背景。",
|
||||
"render.cpu_options_pending": "此CPU面板暂不支持编辑自发光、表面效果、玻璃或各向异性。使用前请清除这些API选项。",
|
||||
"render.cpu_path_depth": "路径深度",
|
||||
"render.cpu_path_help": "计算在不透明表面之间反射并穿过受支持玻璃形状的光。需要渐进渲染和光线追踪阴影。路径深度从首次相交开始计算着色表面、玻璃边界和散射事件(1–16)。深度 1 只计算不透明表面的直接照明;更高的值允许更多反射或玻璃穿越,也可能耗时更长。使用自定义反射或各向异性时,请关闭间接照明。",
|
||||
"render.cpu_path_help": "计算在不透明表面之间反射并穿过受支持玻璃形状的光。需要渐进渲染和光线追踪阴影。路径深度从首次相交开始计算着色表面、玻璃边界和散射事件(1–64)。深度 1 只计算不透明表面的直接照明;更高的值允许更多反射或玻璃穿越,也可能耗时更长。使用自定义反射或各向异性时,请关闭间接照明。",
|
||||
"render.cpu_path_lighting": "间接照明",
|
||||
"render.cpu_path_optics_reason": "设置会保留。使用自定义反射或各向异性时,请关闭间接照明。",
|
||||
"render.cpu_point": "点光源",
|
||||
|
||||
@@ -2294,6 +2294,10 @@ pub struct MeshData {
|
||||
pub positions: std::sync::Arc<[f64]>,
|
||||
/// Three vertex indices per triangle.
|
||||
pub triangles: std::sync::Arc<[u32]>,
|
||||
/// Unit shading normals, x/y/z for each triangle corner. Empty means flat shading.
|
||||
/// Independent of the welded positions and the geometric boundary certificate.
|
||||
#[serde(default, skip_serializing_if = "<[f64]>::is_empty")]
|
||||
pub corner_normals: std::sync::Arc<[f64]>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<[f32; 3]>,
|
||||
}
|
||||
@@ -2309,6 +2313,36 @@ impl PartialEq for MeshData {
|
||||
&& self.color == other.color
|
||||
&& (Arc::ptr_eq(&self.triangles, &other.triangles) || self.triangles == other.triangles)
|
||||
&& (Arc::ptr_eq(&self.positions, &other.positions) || self.positions == other.positions)
|
||||
&& (Arc::ptr_eq(&self.corner_normals, &other.corner_normals) || self.corner_normals == other.corner_normals)
|
||||
}
|
||||
}
|
||||
|
||||
impl MeshData {
|
||||
/// Explicitly generate crease-aware shading for an existing imported source.
|
||||
/// Coordinates, triangle indices, topology and body identity remain unchanged.
|
||||
pub fn generate_crease_normals(&mut self, angle_degrees: f64) -> crate::Result<()> {
|
||||
if !angle_degrees.is_finite()
|
||||
|| !(0.0..=180.0).contains(&angle_degrees)
|
||||
|| !self.positions.len().is_multiple_of(3)
|
||||
|| !self.triangles.len().is_multiple_of(3)
|
||||
{
|
||||
return Err(crate::DocError::Invalid("mesh crease angle must be between 0 and 180 degrees with complete triangles".into()));
|
||||
}
|
||||
let allocation = |_| crate::DocError::Invalid("could not reserve mesh normal generation".into());
|
||||
let mut positions = Vec::new();
|
||||
positions.try_reserve_exact(self.positions.len() / 3).map_err(allocation)?;
|
||||
positions.extend(self.positions.as_chunks::<3>().0.iter().map(|p| solidcore_record::Vec3::new(p[0], p[1], p[2])));
|
||||
let mut triangles = Vec::new();
|
||||
triangles.try_reserve_exact(self.triangles.len() / 3).map_err(allocation)?;
|
||||
triangles.extend_from_slice(self.triangles.as_chunks::<3>().0);
|
||||
let mesh = solidcraft_geom::Mesh { positions, triangles, ..Default::default() };
|
||||
let normals = mesh.crease_corner_normals(angle_degrees.to_radians(), None).map_err(crate::DocError::Invalid)?;
|
||||
let count = normals.len().checked_mul(9).ok_or_else(|| crate::DocError::Invalid("mesh normal count overflow".into()))?;
|
||||
let mut values = Vec::new();
|
||||
values.try_reserve_exact(count).map_err(allocation)?;
|
||||
values.extend(normals.iter().flatten().flat_map(|n| [n.x, n.y, n.z]));
|
||||
self.corner_normals = values.into();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-3
@@ -1224,12 +1224,15 @@ struct ImportDigests {
|
||||
struct MeshDigest {
|
||||
positions: Arc<[f64]>,
|
||||
triangles: Arc<[u32]>,
|
||||
corner_normals: Arc<[f64]>,
|
||||
digest: u64,
|
||||
}
|
||||
|
||||
impl MeshDigest {
|
||||
fn matches(&self, mesh: &crate::MeshData) -> bool {
|
||||
Arc::ptr_eq(&self.positions, &mesh.positions) && Arc::ptr_eq(&self.triangles, &mesh.triangles)
|
||||
Arc::ptr_eq(&self.positions, &mesh.positions)
|
||||
&& Arc::ptr_eq(&self.triangles, &mesh.triangles)
|
||||
&& Arc::ptr_eq(&self.corner_normals, &mesh.corner_normals)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1248,7 +1251,12 @@ impl ImportDigests {
|
||||
.enumerate()
|
||||
.map(|(index, mesh)| {
|
||||
let digest = known.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh));
|
||||
MeshDigest { positions: mesh.positions.clone(), triangles: mesh.triangles.clone(), digest }
|
||||
MeshDigest {
|
||||
positions: mesh.positions.clone(),
|
||||
triangles: mesh.triangles.clone(),
|
||||
corner_normals: mesh.corner_normals.clone(),
|
||||
digest,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.meshes.insert(f.id, digests);
|
||||
@@ -1277,6 +1285,9 @@ fn mesh_content_digest(mesh: &crate::MeshData) -> u64 {
|
||||
coordinate.to_bits().hash(&mut h);
|
||||
}
|
||||
mesh.triangles.hash(&mut h);
|
||||
for n in mesh.corner_normals.iter() {
|
||||
n.to_bits().hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
@@ -1368,6 +1379,7 @@ fn feature_json(f: &Feature, steps: &ImportDigests) -> String {
|
||||
digests.push(steps.mesh(f.id, index, mesh).unwrap_or_else(|| mesh_content_digest(mesh)));
|
||||
mesh.positions = Arc::from([]);
|
||||
mesh.triangles = Arc::from([]);
|
||||
mesh.corner_normals = Arc::from([]);
|
||||
}
|
||||
}
|
||||
// Keep the complete definition (including mesh order, names and colors) without
|
||||
@@ -8398,7 +8410,17 @@ fn mesh_bodies(meshes: &[crate::MeshData]) -> Result<Vec<(String, Body)>> {
|
||||
for m in meshes {
|
||||
let positions: Vec<Vec3> = m.positions.as_chunks::<3>().0.iter().map(|c| Vec3::new(c[0], c[1], c[2])).collect();
|
||||
let triangles: Vec<[u32; 3]> = m.triangles.as_chunks::<3>().0.to_vec();
|
||||
let b = kernel::mesh_body(&positions, &triangles).map_err(|e| DocError::Invalid(format!("mesh `{}`: {e}", m.name)))?;
|
||||
if !m.corner_normals.is_empty() && m.corner_normals.len() != m.triangles.len().saturating_mul(3) {
|
||||
return Err(DocError::Invalid(format!("mesh `{}`: invalid shading normal count", m.name)));
|
||||
}
|
||||
let normals: Vec<[Vec3; 3]> = m
|
||||
.corner_normals
|
||||
.as_chunks::<9>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| [Vec3::new(c[0], c[1], c[2]), Vec3::new(c[3], c[4], c[5]), Vec3::new(c[6], c[7], c[8])])
|
||||
.collect();
|
||||
let b = kernel::mesh_body_with_normals(&positions, &triangles, &normals).map_err(|e| DocError::Invalid(format!("mesh `{}`: {e}", m.name)))?;
|
||||
out.push((m.name.clone(), b.with_color(m.color)));
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -911,6 +911,7 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
|
||||
positions: Arc::from([0., 0., 0., 1., 0., 0., 0., 1., 0.]),
|
||||
triangles: Arc::from([0, 1, 2]),
|
||||
color: None,
|
||||
corner_normals: Default::default(),
|
||||
}],
|
||||
},
|
||||
None,
|
||||
@@ -924,7 +925,7 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
|
||||
Arc::make_mut(&mut doc).occurrences[0].transform = rigid(Vec3::new(10., 0., 0.), Vec3::ZERO, Vec3::Z, 0.3);
|
||||
assert!(!model.evaluate_shared(&doc));
|
||||
assert!(Arc::ptr_eq(&original, &model.state()));
|
||||
for edit in 0..5 {
|
||||
for edit in 0..6 {
|
||||
let FeatureKind::MeshImport { meshes, .. } = &mut Arc::make_mut(&mut doc).feature_mut(feature).unwrap().kind else { panic!("mesh") };
|
||||
let mesh = &mut meshes[0];
|
||||
match edit {
|
||||
@@ -932,6 +933,7 @@ fn shared_imported_mesh_inputs_keep_payload_identity_and_invalidate_replacements
|
||||
1 => Arc::make_mut(&mut mesh.triangles).swap(1, 2),
|
||||
2 => mesh.color = Some([0.2, 0.4, 0.6]),
|
||||
3 => mesh.name = "Renamed".into(),
|
||||
4 => mesh.corner_normals = Arc::from([0., 1., 1., 0., 1., 1., 0., 1., 1.]),
|
||||
_ => mesh.positions = Arc::from(mesh.positions.to_vec()),
|
||||
}
|
||||
assert!(model.evaluate_shared(&doc), "mesh edit {edit} must invalidate");
|
||||
@@ -1044,6 +1046,7 @@ fn comparing_a_copied_mesh_does_not_read_its_shared_arrays() {
|
||||
positions: Arc::from([0., 0., 0., 1., 0., 0., 0., 1., f64::NAN]),
|
||||
triangles: Arc::from([0, 1, 2]),
|
||||
color: None,
|
||||
corner_normals: Default::default(),
|
||||
};
|
||||
let copy = mesh.clone();
|
||||
assert!(copy == mesh, "a copy shares the arrays and is the same mesh without reading them");
|
||||
@@ -1771,6 +1774,7 @@ fn imported_meshes() -> Document {
|
||||
positions: [0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 0.0, 4.0].into(),
|
||||
triangles: [0, 2, 1, 0, 1, 3, 0, 3, 2, 1, 2, 3].into(),
|
||||
color: Some([0.2, 0.4, 0.6]),
|
||||
corner_normals: Default::default(),
|
||||
})
|
||||
.collect();
|
||||
let id = doc.add_feature(FeatureKind::MeshImport { file: "tetrahedra.stl".into(), meshes }, Some("Meshes")).unwrap();
|
||||
@@ -1802,6 +1806,16 @@ fn same_as_fresh(doc: &Document, model: &Model) {
|
||||
#[test]
|
||||
fn unchanged_mesh_payloads_are_not_read_again_for_rigid_motion() {
|
||||
let mut doc = imported_meshes();
|
||||
for mesh in meshes_mut(&mut doc) {
|
||||
mesh.generate_crease_normals(10.0).unwrap();
|
||||
assert!(!mesh.corner_normals.is_empty());
|
||||
}
|
||||
let compact: serde_json::Value = serde_json::from_str(&super::feature_json(&doc.features[0], &super::ImportDigests::default())).unwrap();
|
||||
for mesh in compact[0]["meshes"].as_array().unwrap() {
|
||||
assert_eq!(mesh["positions"], serde_json::json!([]));
|
||||
assert_eq!(mesh["triangles"], serde_json::json!([]));
|
||||
assert!(mesh.get("corner_normals").is_none(), "the fingerprint must hash the retained digest without serializing shading payloads");
|
||||
}
|
||||
let mut model = Model::new();
|
||||
assert_eq!(counted(|| model.evaluate(&doc)).mesh_digested, 2);
|
||||
for k in 1..5 {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Imported mesh sources may save optional `corner_normals`: nine f64 values per
|
||||
//! triangle, independent of positions, topology and optical boundary certification.
|
||||
//! Missing/empty normals retain the previous flat shading. OBJ `vn` and `s` import
|
||||
//! preserve authored corner normals and smoothing groups. Explicit crease generation
|
||||
//! stores only shading metadata and preserves original coordinates and triangle indices.
|
||||
//! Older released readers warn about this format and retain fields they cannot read.
|
||||
//!
|
||||
//! Render jobs may save opt-in `texture_filtering`; omission/false preserves LOD0
|
||||
//! texture sampling. Supported consumers use the captured texture's mip chain and
|
||||
//! ray footprint without changing source image identity or source color semantics.
|
||||
//!
|
||||
//! Optional `metal_energy_compensation` restores a bounded share of rough metal
|
||||
//! reflectance in both path tracers. Omission or false keeps the previous BRDF.
|
||||
//!
|
||||
//! Caustics options may save `area_lights: true` and `environment: true` for
|
||||
//! both path tracers. Omission preserves the hard-source photon map.
|
||||
//!
|
||||
//! Caustics may also save `guided_emission: true` to concentrate photon emission
|
||||
//! around captured optical bounds with probability-corrected power. Omission or
|
||||
//! false preserves the original emission proposal. Older released readers warn
|
||||
//! about the newer format and retain fields they cannot interpret when saving.
|
||||
//!
|
||||
//! Caustics may save `refresh_per_pass: true` to build one photon map per camera
|
||||
//! pass. Omission or false retains one map per render job.
|
||||
//!
|
||||
//! Caustics may save `power_weighted_sources: true` to allocate photon launches
|
||||
//! using estimated source power with a uniform support component. Each source
|
||||
//! retains its matching probability correction. Omission or false preserves
|
||||
//! uniform source selection and the original random sequence.
|
||||
//!
|
||||
//! CPU jobs may save `environment_importance_sampling: true` to use the captured
|
||||
//! HDR radiance distribution for lighting and photon emission. Omission or false
|
||||
//! retains uniform sphere sampling. Older readers warn and retain unknown fields.
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Saves CPU worker selection, linear 2x supersampling and denoising controls in render setups.
|
||||
//! Older designs retain native-size output and the existing denoising settings; omitted
|
||||
//! CPU worker selection uses the bounded automatic pool, with one worker available for serial work.
|
||||
//! The last release warns about this newer design format and preserves fields it cannot read.
|
||||
@@ -240,8 +240,12 @@ fn same_feature(a: &Feature, b: &Feature) -> bool {
|
||||
a == b
|
||||
&& meshes.len() == other.len()
|
||||
&& meshes.iter().zip(other).all(|(a, b)| {
|
||||
let crate::MeshData { name, positions, triangles, color } = a;
|
||||
*name == b.name && *color == b.color && Arc::ptr_eq(positions, &b.positions) && Arc::ptr_eq(triangles, &b.triangles)
|
||||
let crate::MeshData { name, positions, triangles, corner_normals, color } = a;
|
||||
*name == b.name
|
||||
&& *color == b.color
|
||||
&& Arc::ptr_eq(positions, &b.positions)
|
||||
&& Arc::ptr_eq(triangles, &b.triangles)
|
||||
&& Arc::ptr_eq(corner_normals, &b.corner_normals)
|
||||
})
|
||||
}
|
||||
(
|
||||
|
||||
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
|
||||
use solidcraft_render::{
|
||||
Camera,
|
||||
job::{
|
||||
CpuBudget, CpuProgressiveOptions, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions, RaytraceEnvironmentOptions,
|
||||
RaytraceOptions, RectangleLightOptions, StudioLighting, Sun, ThinLens, ToneMap,
|
||||
CpuBudget, CpuProgressiveOptions, DenoiseSettings, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions,
|
||||
RaytraceEnvironmentOptions, RaytraceOptions, RectangleLightOptions, StudioLighting, Sun, ThinLens, ToneMap,
|
||||
},
|
||||
lighting::environment::SkyGradient,
|
||||
};
|
||||
@@ -82,6 +82,18 @@ impl RenderJob {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct CpuJob {
|
||||
/// Zero selects a bounded native worker count; one retains serial transport.
|
||||
#[serde(skip_serializing_if = "zero_threads")]
|
||||
pub threads: u16,
|
||||
/// Spend HDR lighting samples according to captured environment radiance.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub environment_importance_sampling: bool,
|
||||
/// Explicit ray-footprint mip and bounded anisotropic texture filtering.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub texture_filtering: bool,
|
||||
/// Approximate the missing microfacet bounces of rough isotropic conductors.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub metal_energy_compensation: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caustics: Option<solidcraft_render::job::CausticOptions>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -98,6 +110,13 @@ pub struct CpuJob {
|
||||
pub path: Option<solidcraft_render::job::CpuPathOptions>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub denoise: bool,
|
||||
#[serde(skip_serializing_if = "DenoiseSettings::is_default")]
|
||||
pub denoise_settings: DenoiseSettings,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub denoise_diagnostics: bool,
|
||||
/// Internal scale; saved width and height remain the exported image size.
|
||||
#[serde(skip_serializing_if = "solidcraft_render::job::single_supersample")]
|
||||
pub supersampling: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lens: Option<ThinLens>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -129,6 +148,10 @@ pub struct CpuJob {
|
||||
impl Default for CpuJob {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
threads: 0,
|
||||
environment_importance_sampling: false,
|
||||
texture_filtering: false,
|
||||
metal_energy_compensation: false,
|
||||
caustics: None,
|
||||
volume: None,
|
||||
sun: None,
|
||||
@@ -136,6 +159,9 @@ impl Default for CpuJob {
|
||||
aging: Default::default(),
|
||||
adaptive: None,
|
||||
denoise: false,
|
||||
denoise_settings: DenoiseSettings::default(),
|
||||
denoise_diagnostics: false,
|
||||
supersampling: 1,
|
||||
path: None,
|
||||
lens: None,
|
||||
output: None,
|
||||
@@ -200,6 +226,10 @@ impl CpuJob {
|
||||
})
|
||||
.transpose()?;
|
||||
let options = RaytraceOptions {
|
||||
threads: self.threads,
|
||||
environment_importance_sampling: self.environment_importance_sampling,
|
||||
texture_filtering: self.texture_filtering,
|
||||
metal_energy_compensation: self.metal_energy_compensation,
|
||||
caustics: self.caustics,
|
||||
volume: self.volume,
|
||||
sun: self.sun.map(Box::new),
|
||||
@@ -207,6 +237,9 @@ impl CpuJob {
|
||||
aging: (!aging.is_empty()).then(|| Box::new(aging)),
|
||||
adaptive: self.adaptive.map(Box::new),
|
||||
denoise: self.denoise,
|
||||
denoise_settings: self.denoise_settings,
|
||||
denoise_diagnostics: self.denoise_diagnostics,
|
||||
supersampling: self.supersampling,
|
||||
path: self.path,
|
||||
lens: self.lens,
|
||||
output: self.output.clone(),
|
||||
@@ -233,6 +266,12 @@ impl CpuJob {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct GpuJob {
|
||||
/// Explicit ray-footprint mip and bounded anisotropic texture filtering.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub texture_filtering: bool,
|
||||
/// Approximate the missing microfacet bounces of rough isotropic conductors.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub metal_energy_compensation: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caustics: Option<solidcraft_render::job::CausticOptions>,
|
||||
#[serde(skip_serializing_if = "crate::render_aging::SavedAgingStack::is_empty")]
|
||||
@@ -255,6 +294,13 @@ pub struct GpuJob {
|
||||
pub seed: u32,
|
||||
pub tone_map: ToneMap,
|
||||
pub denoise: bool,
|
||||
#[serde(skip_serializing_if = "DenoiseSettings::is_default")]
|
||||
pub denoise_settings: DenoiseSettings,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub denoise_diagnostics: bool,
|
||||
/// Internal scale; saved width and height remain the exported image size.
|
||||
#[serde(skip_serializing_if = "solidcraft_render::job::single_supersample")]
|
||||
pub supersampling: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lens: Option<ThinLens>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -269,6 +315,8 @@ impl Default for GpuJob {
|
||||
fn default() -> Self {
|
||||
let options = JobOptions::default();
|
||||
Self {
|
||||
texture_filtering: false,
|
||||
metal_energy_compensation: false,
|
||||
caustics: None,
|
||||
aging: Default::default(),
|
||||
nested_media: options.nested_media,
|
||||
@@ -283,6 +331,9 @@ impl Default for GpuJob {
|
||||
seed: options.seed,
|
||||
tone_map: options.tone_map,
|
||||
denoise: options.denoise,
|
||||
denoise_settings: options.denoise_settings,
|
||||
denoise_diagnostics: options.denoise_diagnostics,
|
||||
supersampling: options.supersampling,
|
||||
lens: options.lens,
|
||||
lighting: options.lighting,
|
||||
environment: None,
|
||||
@@ -317,6 +368,8 @@ impl GpuJob {
|
||||
environment.validate()?;
|
||||
}
|
||||
let options = JobOptions {
|
||||
texture_filtering: self.texture_filtering,
|
||||
metal_energy_compensation: self.metal_energy_compensation,
|
||||
caustics: self.caustics,
|
||||
aging: self.aging.parameters_only()?,
|
||||
nested_media: self.nested_media,
|
||||
@@ -331,6 +384,9 @@ impl GpuJob {
|
||||
seed: self.seed,
|
||||
tone_map: self.tone_map,
|
||||
denoise: self.denoise,
|
||||
denoise_settings: self.denoise_settings,
|
||||
denoise_diagnostics: self.denoise_diagnostics,
|
||||
supersampling: self.supersampling,
|
||||
lens: self.lens,
|
||||
lighting: self.lighting,
|
||||
exposure_ev,
|
||||
@@ -365,6 +421,10 @@ impl SkyEnvironment {
|
||||
}
|
||||
}
|
||||
|
||||
fn zero_threads(threads: &u16) -> bool {
|
||||
*threads == 0
|
||||
}
|
||||
|
||||
fn unit() -> f64 {
|
||||
1.0
|
||||
}
|
||||
@@ -478,3 +538,33 @@ mod cpu_volume_portable_tests {
|
||||
assert_eq!(serde_json::to_value(job).unwrap(), old);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn portable_quality_controls_preserve_explicit_values_and_legacy_defaults() {
|
||||
let old_cpu: CpuJob = serde_json::from_str("{}").unwrap();
|
||||
let old_gpu: GpuJob = serde_json::from_str("{}").unwrap();
|
||||
assert_eq!(old_cpu.threads, 0);
|
||||
assert_eq!((old_cpu.supersampling, old_gpu.supersampling), (1, 1));
|
||||
assert_eq!(old_cpu.denoise_settings, DenoiseSettings::default());
|
||||
assert_eq!(old_gpu.denoise_settings, DenoiseSettings::default());
|
||||
assert!(!old_cpu.denoise_diagnostics && !old_gpu.denoise_diagnostics);
|
||||
let filter = DenoiseSettings { passes: 2, strength: 0.35, firefly_suppression: false };
|
||||
let cpu = CpuJob { threads: 4, supersampling: 2, denoise: true, denoise_settings: filter, denoise_diagnostics: true, ..Default::default() };
|
||||
let gpu = GpuJob { samples: 128, supersampling: 2, denoise: true, denoise_settings: filter, denoise_diagnostics: true, ..Default::default() };
|
||||
let cpu_reopened: CpuJob = serde_json::from_value(serde_json::to_value(&cpu).unwrap()).unwrap();
|
||||
let gpu_reopened: GpuJob = serde_json::from_value(serde_json::to_value(&gpu).unwrap()).unwrap();
|
||||
assert_eq!(cpu_reopened, cpu);
|
||||
assert_eq!(gpu_reopened, gpu);
|
||||
let cpu_runtime = cpu_reopened.to_runtime(-1.0).unwrap();
|
||||
let gpu_runtime = gpu_reopened.to_runtime(-1.0).unwrap();
|
||||
assert_eq!((cpu_runtime.threads, cpu_runtime.supersampling), (4, 2));
|
||||
assert_eq!((gpu_runtime.samples, gpu_runtime.supersampling), (128, 2));
|
||||
assert_eq!(cpu_runtime.denoise_settings, filter);
|
||||
assert_eq!(gpu_runtime.denoise_settings, filter);
|
||||
assert!(cpu_runtime.denoise_diagnostics && gpu_runtime.denoise_diagnostics);
|
||||
assert!(CpuJob { threads: 65, ..cpu.clone() }.validate(0.0).is_err());
|
||||
assert!(GpuJob { supersampling: 3, ..gpu.clone() }.validate(0.0).is_err());
|
||||
assert!(CpuJob { denoise_settings: DenoiseSettings { strength: f64::NAN, ..filter }, ..cpu }.validate(0.0).is_err());
|
||||
assert!(GpuJob { denoise_settings: DenoiseSettings { passes: 4, ..filter }, ..gpu }.validate(0.0).is_err());
|
||||
}
|
||||
|
||||
@@ -147,8 +147,8 @@ impl OpticalBindings {
|
||||
self.0.iter()
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.0.len() > 32 || self.0.capacity() > 32 {
|
||||
return Err(DocError::Invalid("at most 32 portable optical targets".into()));
|
||||
if self.0.len() > solidcraft_render::job::MAX_OPTICAL_TARGETS || self.0.capacity() > solidcraft_render::job::MAX_OPTICAL_TARGETS {
|
||||
return Err(DocError::Invalid("at most 128 portable optical targets".into()));
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for binding in &self.0 {
|
||||
@@ -164,7 +164,7 @@ impl OpticalBindings {
|
||||
}
|
||||
pub fn validate_for_cpu_path(&self, path: bool) -> Result<()> {
|
||||
self.validate()?;
|
||||
if self.len() > 8 {
|
||||
if !path && self.len() > 8 {
|
||||
return Err(DocError::Invalid("CPU supports at most eight certified optical targets".into()));
|
||||
}
|
||||
for binding in self.iter() {
|
||||
@@ -183,11 +183,11 @@ impl OpticalBindings {
|
||||
}
|
||||
*old = binding;
|
||||
} else {
|
||||
if self.0.len() >= 32 {
|
||||
return Err(DocError::Invalid("at most 32 portable optical targets".into()));
|
||||
if self.0.len() >= solidcraft_render::job::MAX_OPTICAL_TARGETS {
|
||||
return Err(DocError::Invalid("at most 128 portable optical targets".into()));
|
||||
}
|
||||
self.0.try_reserve_exact(1).map_err(|_| DocError::Invalid("optical binding reservation failed".into()))?;
|
||||
if self.0.capacity() > 32 {
|
||||
if self.0.capacity() > solidcraft_render::job::MAX_OPTICAL_TARGETS {
|
||||
return Err(DocError::Invalid("optical capacity exceeds its bound".into()));
|
||||
}
|
||||
self.0.push(binding);
|
||||
@@ -202,7 +202,7 @@ impl OpticalBindings {
|
||||
}
|
||||
impl<'de> Deserialize<'de> for OpticalBindings {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
let result = Self(bounded_sequence::<D, SavedOptics, 32>(d)?);
|
||||
let result = Self(bounded_sequence::<D, SavedOptics, { solidcraft_render::job::MAX_OPTICAL_TARGETS }>(d)?);
|
||||
result.validate().map_err(serde::de::Error::custom)?;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -212,6 +212,25 @@ impl<'de> Deserialize<'de> for OpticalBindings {
|
||||
mod advanced_model_contract_tests {
|
||||
use super::*;
|
||||
use solidcraft_render::glass_model::{Dispersion, GlassModel, ThinFilm};
|
||||
#[test]
|
||||
fn portable_optics_keep_128_targets_and_legacy_cpu_keeps_its_explicit_limit() {
|
||||
let mut bindings = OpticalBindings::default();
|
||||
for feature in 1..=solidcraft_render::job::MAX_OPTICAL_TARGETS {
|
||||
let binding = SavedOptics {
|
||||
target: RenderBodyTarget { definition: "OPUS".into(), feature: feature as u64, component: 0, occurrence_path: Vec::new() },
|
||||
optics: OpticalMaterial::default(),
|
||||
};
|
||||
bindings.set(binding, false).unwrap();
|
||||
}
|
||||
bindings.validate_for_cpu_path(true).unwrap();
|
||||
assert!(bindings.validate_for_cpu().is_err());
|
||||
let raw = serde_json::to_value(&bindings).unwrap();
|
||||
assert_eq!(serde_json::from_value::<OpticalBindings>(raw.clone()).unwrap(), bindings);
|
||||
let mut too_many = raw.as_array().unwrap().clone();
|
||||
too_many.push(too_many[0].clone());
|
||||
assert!(serde_json::from_value::<OpticalBindings>(serde_json::Value::Array(too_many)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_optics_keep_every_authored_number_through_saved_and_cpu_lowering() {
|
||||
let model = GlassModel {
|
||||
@@ -238,14 +257,32 @@ mod advanced_model_contract_tests {
|
||||
#[test]
|
||||
fn caustic_quality_is_portable_between_both_saved_jobs_and_runtime_requests() {
|
||||
use crate::render_job::{CpuJob, GpuJob};
|
||||
let caustics = Some(solidcraft_render::job::CausticOptions { photons: 8192, radius_mm: 0.4, max_depth: 12, seed: 381 });
|
||||
let cpu = CpuJob { caustics, path: Some(Default::default()), progressive: Some(Default::default()), ..Default::default() };
|
||||
let caustics = Some(solidcraft_render::job::CausticOptions {
|
||||
photons: 8192,
|
||||
radius_mm: 0.4,
|
||||
max_depth: 12,
|
||||
seed: 381,
|
||||
area_lights: false,
|
||||
environment: false,
|
||||
guided_emission: true,
|
||||
refresh_per_pass: true,
|
||||
power_weighted_sources: true,
|
||||
});
|
||||
let cpu = CpuJob {
|
||||
caustics,
|
||||
environment_importance_sampling: true,
|
||||
path: Some(Default::default()),
|
||||
progressive: Some(Default::default()),
|
||||
..Default::default()
|
||||
};
|
||||
let gpu = GpuJob { caustics, ..Default::default() };
|
||||
assert_eq!(cpu.to_runtime(0.0).unwrap().caustics, caustics);
|
||||
assert!(cpu.to_runtime(0.0).unwrap().environment_importance_sampling);
|
||||
assert_eq!(gpu.to_runtime(0.0).unwrap().caustics, caustics);
|
||||
assert_eq!(serde_json::from_value::<CpuJob>(serde_json::to_value(&cpu).unwrap()).unwrap(), cpu);
|
||||
assert_eq!(serde_json::from_value::<GpuJob>(serde_json::to_value(&gpu).unwrap()).unwrap(), gpu);
|
||||
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("caustics").is_none());
|
||||
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("environment_importance_sampling").is_none());
|
||||
assert!(serde_json::to_value(GpuJob::default()).unwrap().get("caustics").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,15 +212,27 @@ impl RenderSetup {
|
||||
));
|
||||
}
|
||||
job.validate(&self.camera, self.exposure_ev)?;
|
||||
if let RenderJob::GpuPath { options, .. } = job {
|
||||
solidcraft_render::job::supersampled_dimensions(self.width, self.height, options.supersampling)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
}
|
||||
if let RenderJob::CpuRaytrace { options, .. } = job {
|
||||
if options.denoise {
|
||||
solidcraft_render::job::validate_cpu_denoise_image(self.width, self.height).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
// Existing native-size Workstation intent remains readable. X2
|
||||
// uses the bounded 8K output envelope and pays for its internal grid.
|
||||
let (trace_width, trace_height) = if options.supersampling == 1 && !options.denoise && !options.denoise_diagnostics {
|
||||
(self.width, self.height)
|
||||
} else {
|
||||
solidcraft_render::job::supersampled_dimensions(self.width, self.height, options.supersampling)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?
|
||||
};
|
||||
if options.denoise || options.denoise_diagnostics {
|
||||
solidcraft_render::job::validate_cpu_denoise_image(trace_width, trace_height).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
}
|
||||
options
|
||||
.budget
|
||||
.validate_image(
|
||||
self.width,
|
||||
self.height,
|
||||
trace_width,
|
||||
trace_height,
|
||||
solidcraft_render::job::cpu_camera_sampling(options.progressive, options.adaptive, options.budget)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?,
|
||||
)
|
||||
@@ -670,7 +682,7 @@ mod tests {
|
||||
gpu(3840, 2160).validate().unwrap();
|
||||
gpu(3840, 2160).validate_for_gpu().unwrap();
|
||||
let message = gpu(8193, 8193).validate().unwrap_err().to_string();
|
||||
assert!(message.contains("(8192 pixels per side, 16777216 pixels in all)"), "{message}");
|
||||
assert!(message.contains("(8192 pixels per side, 33554432 pixels in all)"), "{message}");
|
||||
assert!(!message.contains("CPU resource profile"), "{message}");
|
||||
}
|
||||
|
||||
@@ -883,7 +895,7 @@ mod tests {
|
||||
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"hdr","path":"not-portable.hdr"}}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"exposure_ev":0}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"samples":0}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"max_depth":17}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"max_depth":65}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"sky","intensity":101}}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"lighting":{"environment":[-1,0,0]}}}}),
|
||||
json!({"camera":Camera { fov: 0.0, ..Default::default() },"job":{"method":"gpu_path","options":{"lens":{"aperture_radius_mm":1,"focus_distance_mm":10}}}}),
|
||||
@@ -1021,7 +1033,7 @@ mod cpu_budget_tests {
|
||||
let old = CpuJob::default();
|
||||
assert!(old.path.is_none());
|
||||
assert!(serde_json::to_value(&old).unwrap().get("path").is_none());
|
||||
for depth in [1, 8, 16] {
|
||||
for depth in [1, 8, 16, 64] {
|
||||
let setup:RenderSetup=serde_json::from_value(json!({"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"path":{"max_depth":depth},"progressive":{"samples_per_pixel":7,"seed":9}}}})).unwrap();
|
||||
setup.validate().unwrap();
|
||||
let mut document = crate::Document::default();
|
||||
@@ -1032,7 +1044,7 @@ mod cpu_budget_tests {
|
||||
}
|
||||
for raw in [
|
||||
json!({"path":{"max_depth":0},"progressive":{"samples_per_pixel":7,"seed":9}}),
|
||||
json!({"path":{"max_depth":17},"progressive":{"samples_per_pixel":7,"seed":9}}),
|
||||
json!({"path":{"max_depth":65},"progressive":{"samples_per_pixel":7,"seed":9}}),
|
||||
json!({"path":{"max_depth":8}}),
|
||||
json!({"path":{"max_depth":8},"progressive":{"samples_per_pixel":7,"seed":9},"shadows":false}),
|
||||
] {
|
||||
@@ -1073,7 +1085,7 @@ fn cpu_denoise_saved_flag_roundtrips_offline_and_keeps_false_omitted() {
|
||||
setup.validate().unwrap();
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else { panic!("CPU job expected") };
|
||||
options.denoise = true;
|
||||
assert!(setup.validate().unwrap_err().to_string().contains("CPU denoising"));
|
||||
assert_eq!(setup.validate().unwrap_err(), DocError::Invalid("LimitExceeded: output requires 1..8192 sides and at most 33554432 pixels".into()),);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1220,7 +1232,9 @@ mod cpu_adaptive_saved_tests {
|
||||
serde_json::from_value::<RenderSetup>(cap).unwrap().validate().is_err(),
|
||||
"16 pilots cannot be hidden outside Compact's 64-sample cap"
|
||||
);
|
||||
for (budget, maximum) in [(CpuBudget::Compact, 64), (CpuBudget::Studio, 1024), (CpuBudget::Workstation, 4096)] {
|
||||
for (budget, maximum) in
|
||||
[(CpuBudget::Compact, 64), (CpuBudget::Studio, 1024), (CpuBudget::Workstation, solidcraft_render::job::MAX_CPU_SAMPLES_PER_PIXEL)]
|
||||
{
|
||||
let mut runtime = runtime.clone();
|
||||
runtime.budget = budget;
|
||||
runtime.progressive = Some(CpuProgressiveOptions { samples_per_pixel: maximum - 16, seed: 81 });
|
||||
@@ -1385,3 +1399,39 @@ mod cpu_celestial_saved_tests {
|
||||
assert!(defaults.get("sun").is_none() && defaults.get("sky").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_quality_saved_intent_preserves_output_size_and_validates_the_internal_grid() {
|
||||
let gpu: RenderSetup = serde_json::from_value(serde_json::json!({
|
||||
"width":7680,"height":4320,"job":{"method":"gpu_path","options":{
|
||||
"samples":128,"supersampling":2,"denoise":true,"denoise_diagnostics":true,
|
||||
"denoise_settings":{"passes":2,"strength":0.5,"firefly_suppression":false}
|
||||
}}
|
||||
}))
|
||||
.unwrap();
|
||||
gpu.validate().unwrap();
|
||||
let reopened: RenderSetup = serde_json::from_value(serde_json::to_value(&gpu).unwrap()).unwrap();
|
||||
assert_eq!(reopened, gpu);
|
||||
assert_eq!((reopened.width, reopened.height), (7680, 4320));
|
||||
let mut cpu: RenderSetup = serde_json::from_value(serde_json::json!({
|
||||
"width":7680,"height":4320,"job":{"method":"cpu_raytrace","options":{
|
||||
"threads":4,"budget":"workstation","progressive":{"samples_per_pixel":128},"supersampling":2
|
||||
}}
|
||||
}))
|
||||
.unwrap();
|
||||
cpu.validate().unwrap();
|
||||
assert_eq!((cpu.width, cpu.height), (7680, 4320));
|
||||
cpu.width = 1024;
|
||||
cpu.height = 1024;
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut cpu.job else { panic!("expected CPU job") };
|
||||
options.budget = solidcraft_render::job::CpuBudget::Compact;
|
||||
options.progressive.as_mut().unwrap().samples_per_pixel = 8;
|
||||
assert!(cpu.validate().is_err(), "X2 must charge four million internal pixels to Compact's progressive limit");
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut cpu.job else { panic!("expected CPU job") };
|
||||
options.supersampling = 1;
|
||||
cpu.validate().unwrap();
|
||||
let mut invalid = gpu;
|
||||
invalid.width = 8193;
|
||||
invalid.height = 1;
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Prepare an explicitly shaded OPUS copy and physical CPU/GPU reference setups.
|
||||
//! No render is started. Geometry and the original OPUS Master setup are retained.
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_engine::{
|
||||
Session,
|
||||
doc::{Document, FeatureKind, MeshData, render_settings::RenderSetup},
|
||||
};
|
||||
use solidcraft_geom::prelude::*;
|
||||
use std::{error::Error, fs, path::PathBuf, sync::Arc};
|
||||
|
||||
fn require(condition: bool, message: &str) -> Result<(), Box<dyn Error>> {
|
||||
if condition { Ok(()) } else { Err(message.into()) }
|
||||
}
|
||||
|
||||
const CURVED: &[&str] = &[
|
||||
"A3 - continuous sixteen facet freeform glass knot",
|
||||
"A3 - six lobe quilted aspheric witness optic",
|
||||
"Glass - smoked rounded toroid",
|
||||
"Glass - clear calibration sphere",
|
||||
"O2 - Lens 1 - Biconvex crown",
|
||||
"O2 - Lens 2 - Biconcave flint",
|
||||
"O2 - Lens 3 - Thick meniscus",
|
||||
"O2 - Lens 4 - Plano convex magnifier",
|
||||
"O2 - Lens 5 - Aspheric correction optic",
|
||||
"R2 - clear chest reactor lens",
|
||||
"R2 - near clear glass eye",
|
||||
"R2 - far clear glass eye",
|
||||
"S2 - amber solid ellipsoid",
|
||||
"S2 - cobalt fluted solid decanter",
|
||||
"R2 - thorax ivory swept breastplates",
|
||||
"S2 - glass gallery turned wood pedestal -217",
|
||||
"S2 - glass gallery turned wood pedestal 211",
|
||||
];
|
||||
|
||||
fn scene_counts(scene: &Value) -> Result<(usize, u64), Box<dyn Error>> {
|
||||
let bodies = scene["bodies"].as_array().ok_or("missing scene bodies")?;
|
||||
let triangles = bodies
|
||||
.iter()
|
||||
.try_fold(0u64, |sum, body| sum.checked_add(body["triangles"].as_u64().ok_or("missing triangle count")?).ok_or("triangle sum overflow"))?;
|
||||
Ok((bodies.len(), triangles))
|
||||
}
|
||||
|
||||
fn physical(master: &RenderSetup, cpu: bool, pilot: bool) -> Result<Value, Box<dyn Error>> {
|
||||
let mut setup = serde_json::to_value(master)?;
|
||||
let mut optics = setup["optics"].as_array().ok_or("missing optics")?.clone();
|
||||
for binding in &mut optics {
|
||||
binding["optics"]["shadow_transmission"] = json!(false);
|
||||
}
|
||||
setup["optics"] = json!(optics);
|
||||
setup["mode"] = json!("final");
|
||||
setup["width"] = json!(if pilot { 320 } else { 3840 });
|
||||
setup["height"] = json!(if pilot { 180 } else { 2160 });
|
||||
let samples = if pilot { 8u32 } else { 4096u32 };
|
||||
let mut options = setup["job"]["options"].clone();
|
||||
options["texture_filtering"] = json!(true);
|
||||
options["linear_exr"] = json!(true);
|
||||
options["max_depth"] = json!(64);
|
||||
options["samples"] = json!(samples);
|
||||
options["nested_media"] = json!(true);
|
||||
options["denoise"] = json!(false);
|
||||
if cpu {
|
||||
let lighting = &options["lighting"];
|
||||
let area_lights = lighting["area_lights"]
|
||||
.as_array()
|
||||
.ok_or("missing area lights")?
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let radiance: [f64; 3] = serde_json::from_value(a["radiance"].clone())?;
|
||||
let intensity = radiance.into_iter().fold(0.0, f64::max);
|
||||
let color = radiance.map(|v| if intensity > 0.0 { v / intensity } else { 0.0 });
|
||||
// GPU stores half-edges; the CPU rectangle contract takes full sides.
|
||||
let u: [f64; 3] = serde_json::from_value(a["u"].clone())?;
|
||||
let v: [f64; 3] = serde_json::from_value(a["v"].clone())?;
|
||||
Ok(json!({"center_mm":a["center"],"edge_u_mm":u.map(|x|x*2.0),"edge_v_mm":v.map(|x|x*2.0),"color":color,"intensity":intensity}))
|
||||
})
|
||||
.collect::<Result<Vec<Value>, serde_json::Error>>()?;
|
||||
let cpu_options = json!({
|
||||
"budget":"workstation", "texture_filtering":true,
|
||||
"progressive":{"samples_per_pixel":samples,"seed":4619},
|
||||
"path":{"max_depth":64}, "primary_samples":1, "shadows":true,
|
||||
"lights":[], "area_lights":area_lights,"area_samples":1,
|
||||
"punctual_lights":lighting["punctual_lights"],
|
||||
"environment":{"color":lighting["environment"],"intensity":1.0},
|
||||
"aging":options["aging"], "denoise":false,
|
||||
"output":{"tone_map":options["tone_map"],"linear_exr":true}
|
||||
});
|
||||
setup["job"] = json!({"method":"cpu_raytrace","transparent_background":false,"options":cpu_options});
|
||||
} else {
|
||||
setup["job"]["options"] = options;
|
||||
}
|
||||
let checked: RenderSetup = serde_json::from_value(setup.clone())?;
|
||||
checked.validate()?;
|
||||
require(checked.camera == master.camera, "reference camera differs from Master")?;
|
||||
require(checked.surfaces == master.surfaces && checked.coats == master.coats, "reference lost surface maps or coats")?;
|
||||
Ok(setup)
|
||||
}
|
||||
|
||||
fn crease_audit(mesh: &MeshData) -> Result<Value, Box<dyn Error>> {
|
||||
let positions: Vec<Vec3> = mesh.positions.as_chunks::<3>().0.iter().map(|p| Vec3::new(p[0], p[1], p[2])).collect();
|
||||
let triangles = mesh.triangles.as_chunks::<3>().0;
|
||||
let mut weld = std::collections::HashMap::new();
|
||||
let mut canonical = Vec::with_capacity(positions.len());
|
||||
for p in &positions {
|
||||
let next = weld.len();
|
||||
canonical.push(*weld.entry([(p.x + 0.0).to_bits(), (p.y + 0.0).to_bits(), (p.z + 0.0).to_bits()]).or_insert(next));
|
||||
}
|
||||
let mut edges = std::collections::HashMap::<(usize, usize), (usize, Option<usize>, usize)>::new();
|
||||
let mut face_normals = Vec::with_capacity(triangles.len());
|
||||
for (i, t) in triangles.iter().enumerate() {
|
||||
let [a, b, c] = t.map(|k| positions.get(k as usize).copied());
|
||||
let (Some(a), Some(b), Some(c)) = (a, b, c) else { return Err("audit index missing".into()) };
|
||||
face_normals.push((b - a).cross(c - a).normalized().ok_or("audit triangle degenerate")?);
|
||||
for k in 0..3 {
|
||||
let (a, b) = (canonical[t[k] as usize], canonical[t[(k + 1) % 3] as usize]);
|
||||
edges
|
||||
.entry((a.min(b), a.max(b)))
|
||||
.and_modify(|e| {
|
||||
e.1 = Some(i * 3 + k);
|
||||
e.2 += 1;
|
||||
})
|
||||
.or_insert((i * 3 + k, None, 1));
|
||||
}
|
||||
}
|
||||
let normal = |corner: usize| -> Result<Vec3, Box<dyn Error>> {
|
||||
let n = mesh.corner_normals.get(corner * 3..corner * 3 + 3).ok_or("audit normal missing")?;
|
||||
Ok(Vec3::new(n[0], n[1], n[2]))
|
||||
};
|
||||
let mut bins = [0u64; 7];
|
||||
let mut sharp = 0u64;
|
||||
let mut sharp_discontinuous = 0u64;
|
||||
let mut boundary_or_nonmanifold = 0u64;
|
||||
for (_, (a, b, count)) in edges {
|
||||
let Some(b) = b.filter(|_| count == 2) else {
|
||||
boundary_or_nonmanifold += 1;
|
||||
continue;
|
||||
};
|
||||
let angle = face_normals[a / 3].dot(face_normals[b / 3]).clamp(-1.0, 1.0).acos().to_degrees();
|
||||
let bin = [1.0, 5.0, 10.0, 22.5, 45.0, 90.0].iter().position(|&max| angle <= max).unwrap_or(6);
|
||||
bins[bin] += 1;
|
||||
if angle > 10.0 + 1e-9 {
|
||||
sharp += 1;
|
||||
let (ak, bk) = (a % 3, b % 3);
|
||||
let same_a = normal(a)?.dist(normal((b / 3) * 3 + (bk + 1) % 3)?) <= 1e-8;
|
||||
let same_b = normal((a / 3) * 3 + (ak + 1) % 3)?.dist(normal(b)?) <= 1e-8;
|
||||
if !same_a || !same_b {
|
||||
sharp_discontinuous += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"manifold_edge_angle_bins_degrees":[[0,1],[1,5],[5,10],[10,22.5],[22.5,45],[45,90],[90,180]],
|
||||
"manifold_edge_counts":bins,"geometric_sharp_edges_above_10_degrees_before":sharp,
|
||||
"geometric_sharp_edges_above_10_degrees_after":sharp,"sharp_edges_with_shading_discontinuity":sharp_discontinuous,
|
||||
"boundary_or_nonmanifold_edges":boundary_or_nonmanifold}))
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let baseline = PathBuf::from(args.next().ok_or("usage: opus_quality_fixture BASELINE.sc NEW_OUTPUT_DIRECTORY")?);
|
||||
let output = PathBuf::from(args.next().ok_or("missing new output directory")?);
|
||||
require(args.next().is_none(), "unexpected argument")?;
|
||||
fs::create_dir(&output)?;
|
||||
let original = fs::read(&baseline)?;
|
||||
let mut doc: Document = solidcraft_io::read_design(&original)?;
|
||||
let master = doc.render.setups.get("OPUS Master").cloned().ok_or("missing OPUS Master")?;
|
||||
require(master.surfaces.len() == 104 && master.optics.len() == 30 && master.coats.len() == 6, "fixture bindings differ from OPUS baseline")?;
|
||||
require(doc.components.len() == 82 && doc.assembly.joints.len() == 81 && doc.assembly.links.len() == 15, "fixture assembly counts differ")?;
|
||||
let baseline_doc = doc.clone();
|
||||
let mut baseline_session = Session::new(doc.clone());
|
||||
let baseline_scene = baseline_session.execute("render.scene", &json!({}))?;
|
||||
require(scene_counts(&baseline_scene)? == (511, 729874), "baseline scene geometry counts differ")?;
|
||||
let mut edits = Vec::new();
|
||||
for feature in &mut doc.features {
|
||||
if let FeatureKind::MeshImport { meshes, .. } = &mut feature.kind {
|
||||
for mesh in meshes {
|
||||
if CURVED.contains(&mesh.name.as_str()) {
|
||||
let positions = mesh.positions.clone();
|
||||
let triangles = mesh.triangles.clone();
|
||||
mesh.generate_crease_normals(10.0)?;
|
||||
require(
|
||||
Arc::ptr_eq(&positions, &mesh.positions) && Arc::ptr_eq(&triangles, &mesh.triangles),
|
||||
"normal generation changed geometry arrays",
|
||||
)?;
|
||||
edits.push(json!({"feature":feature.id,"body":mesh.name,"crease_angle_degrees":10.0,"triangles":mesh.triangles.len()/3,"normal_values":mesh.corner_normals.len(),"crease_audit":crease_audit(mesh)?}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
require(edits.len() == CURVED.len(), "a curated normal target is missing or duplicated")?;
|
||||
doc.name = format!("{} — curated 10 degree shading reference", doc.name);
|
||||
let mut session = Session::new(doc);
|
||||
let mut setup_receipts = Vec::new();
|
||||
for (cpu, pilot, name) in [
|
||||
(true, false, "OPUS Physical CPU 4K"),
|
||||
(false, false, "OPUS Physical GPU 4K"),
|
||||
(true, true, "OPUS Physical CPU Pilot"),
|
||||
(false, true, "OPUS Physical GPU Pilot"),
|
||||
] {
|
||||
let setup = physical(&master, cpu, pilot)?;
|
||||
setup_receipts.push(session.execute("render.settings.set", &json!({"name":name,"set":setup}))?);
|
||||
}
|
||||
require(session.doc.render.setups.get("OPUS Master") == Some(&master), "original Master setup changed")?;
|
||||
let scene = session.execute("render.scene", &json!({}))?;
|
||||
require(scene == baseline_scene, "shading changed captured geometry, names, bounds or stable targets")?;
|
||||
require(
|
||||
session.doc.components == baseline_doc.components
|
||||
&& session.doc.occurrences == baseline_doc.occurrences
|
||||
&& session.doc.assembly == baseline_doc.assembly,
|
||||
"assembly, pose or occurrence metadata changed",
|
||||
)?;
|
||||
let copy_path = output.join("OPUS-04-physical-reference.sc");
|
||||
let copy = solidcraft_io::write_design_container(&session.doc)?;
|
||||
fs::write(©_path, ©)?;
|
||||
let reopened = solidcraft_io::read_design(©)?;
|
||||
require(reopened == *session.doc, "copy changed on cold reopen")?;
|
||||
fs::write(output.join("baseline-scene.json"), serde_json::to_vec_pretty(&baseline_scene)?)?;
|
||||
fs::write(output.join("reference-setups.json"), serde_json::to_vec_pretty(&setup_receipts)?)?;
|
||||
let report = json!({
|
||||
"baseline":baseline,"reference":copy_path,"baseline_bytes":original.len(),"reference_bytes":copy.len(),
|
||||
"geometry":{"bodies":511,"triangles":729874,"unchanged":true},
|
||||
"assembly":{"components":82,"joints":81,"couplings":15,"unchanged":true},
|
||||
"bindings_per_reference":{"surfaces":104,"optics":30,"coats":6,"aging":7,"area_lights":4,"punctual_lights":7},
|
||||
"curated_normal_edits":edits,"master_unchanged":true,"camera_unchanged":true,
|
||||
"assembly_and_saved_pose_unchanged":true,"recorded_motion_step15_applied":false,
|
||||
"reference_shadow_transmission":false,"render_started":false,
|
||||
"qualification":"fixture preparation and public settings/scene commands only; no transport convergence claim"
|
||||
});
|
||||
fs::write(output.join("fixture-manifest.json"), serde_json::to_vec_pretty(&report)?)?;
|
||||
println!("{}", serde_json::to_string_pretty(&report)?);
|
||||
Ok(())
|
||||
}
|
||||
@@ -261,9 +261,9 @@ fn an_older_two_sided_extrude_with_a_negative_distance_keeps_its_sides() {
|
||||
let mut base = fixture(json!({"direction":"positive","distance":"10 mm","distance2":"10 mm"}));
|
||||
run(&mut base, "parameters.add", json!({"name":"width","expression":"20 mm"}));
|
||||
let mut saved: Value = serde_json::from_str(&base.doc.to_json()).unwrap();
|
||||
// As the last release saved it: the format before this build's pending changes.
|
||||
let version: u32 = saved["format"].as_str().unwrap().trim_start_matches("solidcraft/").parse().unwrap();
|
||||
saved["format"] = json!(format!("solidcraft/{}", version - 1));
|
||||
// Format 50 predates the v51 second-side migration. A moving current-1 tag
|
||||
// can already include that migration after unrelated format changes.
|
||||
saved["format"] = json!("solidcraft/50");
|
||||
saved.as_object_mut().unwrap().remove("format_pending");
|
||||
for (distance, lo, hi) in [("-width", -20.0, 10.0), ("-0 mm", -10.0, 0.0), ("-10 mm", -10.0, 10.0)] {
|
||||
let mut file = saved.clone();
|
||||
|
||||
@@ -39,7 +39,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
|
||||
.at("SOLID", "INSERT")
|
||||
.icon("import")
|
||||
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made)"),
|
||||
.params("path: .3mf, .stl, .obj, .ply (with faces), .glb or .gltf file (its meshes join the design as mesh bodies in a new component named after the file → component, component_name, occurrence; a glTF file's node tree becomes nested components held as one rigid group until component.unlock, a mesh placed again another occurrence, its materials the bodies' appearances, and its metres millimetres); name?; up?: z|y for a glTF file (default y: glTF has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence; z: the file's axes are kept); units?: mm|cm|m|in|ft for an STL or OBJ file, or a PLY file with faces (default mm): the unit its coordinates are in, scaled to millimetres as the file is read (a 3MF or glTF file states its own). The design keeps the millimetre mesh and not the unit, so insert the file again to try another; origin?: bool (default false: the mesh keeps its file's coordinates; true: once read, scaled and stood up, the import's occurrence is moved so the box around its bodies is centred on X and Y of the active component with its lowest point on Z = 0 → moved_to_origin: [x, y, z], the shift made); crease_angle_degrees?: 0..180 (0 clears imported shading normals for the previous flat-shading behavior; positive angles regenerate angle-weighted normals across manifold edges below this crease angle; omitted keeps authored OBJ normals/smoothing groups, or flat shading when absent; geometry stays unchanged)"),
|
||||
CommandSpec::new("file.save", "Save", save)
|
||||
.icon("save")
|
||||
.key("Ctrl+S")
|
||||
@@ -262,11 +262,19 @@ fn created_bodies(s: &Session, feature: u64) -> Vec<String> {
|
||||
/// Add a MeshImport feature for a 3MF, STL, OBJ or glTF file, in a new component named after the
|
||||
/// file. A glTF file's nodes become components in it, and its materials the bodies' looks; it is
|
||||
/// in metres with Y up, so unless `y_up` is false its component is turned to Z up (#1505).
|
||||
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>, unit: Option<(&'static str, f64)>, origin: bool) -> Result<Value> {
|
||||
fn add_mesh(
|
||||
s: &mut Session,
|
||||
path: &str,
|
||||
name: Option<&str>,
|
||||
y_up: Option<bool>,
|
||||
unit: Option<(&'static str, f64)>,
|
||||
origin: bool,
|
||||
crease_angle: Option<f64>,
|
||||
) -> Result<Value> {
|
||||
let err = |e: solidcraft_io::IoError| EngineError::Other(format!("{path}: {e}"));
|
||||
let bytes = solidcraft_io::vfs::read(path, solidcraft_io::MAX_3MF_BYTES).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let gltf = solidcraft_io::is_gltf_path(path);
|
||||
let (mf, plan, looks) = if gltf {
|
||||
let (mut mf, plan, looks) = if gltf {
|
||||
let dir = std::path::Path::new(path).parent().map(std::path::Path::to_path_buf).unwrap_or_default();
|
||||
let mut load =
|
||||
|name: &str| solidcraft_io::vfs::read(&dir.join(name).to_string_lossy(), solidcraft_io::MAX_GLTF_BYTES).map_err(|e| e.to_string());
|
||||
@@ -278,6 +286,19 @@ fn add_mesh(s: &mut Session, path: &str, name: Option<&str>, y_up: Option<bool>,
|
||||
} else {
|
||||
(solidcraft_io::mesh_import_feature_in(&bytes, path, unit.map_or(1.0, |(_, mm)| mm)).map_err(err)?, None, Vec::new())
|
||||
};
|
||||
if let Some(angle) = crease_angle
|
||||
&& let solidcraft_doc::FeatureKind::MeshImport { meshes, .. } = &mut mf.kind
|
||||
{
|
||||
for mesh in meshes {
|
||||
if angle == 0.0 {
|
||||
// Keep the original flat display path, including its normalization
|
||||
// and storage behavior, when the caller opts out of imported shading.
|
||||
mesh.corner_normals = Default::default();
|
||||
} else {
|
||||
mesh.generate_crease_normals(angle).map_err(|e| bad("file.insert_mesh", e.to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
|
||||
let cname = import::component_name(path, name);
|
||||
let tree = match &plan {
|
||||
@@ -365,7 +386,16 @@ fn insert_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let up = mesh_up(p, path, cmd)?;
|
||||
let unit = mesh_units(p, path, cmd)?;
|
||||
add_mesh(s, path, str_(p, "name"), up, unit, mesh_origin(p, cmd)?)
|
||||
let crease_angle = match p.get("crease_angle_degrees") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(value) => Some(
|
||||
value
|
||||
.as_f64()
|
||||
.filter(|v| v.is_finite() && (0.0..=180.0).contains(v))
|
||||
.ok_or_else(|| bad(cmd, "crease_angle_degrees must be between 0 and 180 degrees"))?,
|
||||
),
|
||||
};
|
||||
add_mesh(s, path, str_(p, "name"), up, unit, mesh_origin(p, cmd)?, crease_angle)
|
||||
}
|
||||
|
||||
/// `origin`: whether a mesh import is moved to the origin (#12543). Off unless asked, so a mesh
|
||||
@@ -458,7 +488,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let r = if solidcraft_io::is_step_path(path) {
|
||||
add_step(&mut fresh, path, None, &step_options(p, "doc.open")?)?
|
||||
} else {
|
||||
add_mesh(&mut fresh, path, None, mesh_up(p, path, "doc.open")?, mesh_units(p, path, "doc.open")?, mesh_origin(p, "doc.open")?)?
|
||||
add_mesh(&mut fresh, path, None, mesh_up(p, path, "doc.open")?, mesh_units(p, path, "doc.open")?, mesh_origin(p, "doc.open")?, None)?
|
||||
};
|
||||
// The design is the file: its component is the one being worked in.
|
||||
if let Some(c) = r["component"].as_u64() {
|
||||
@@ -834,7 +864,7 @@ fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> {
|
||||
for body in &world.bodies {
|
||||
let mesh = body.cached_mesh()?;
|
||||
bytes =
|
||||
bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(12)).saturating_add(24);
|
||||
bytes.saturating_add(mesh.positions.len().saturating_mul(24)).saturating_add(mesh.triangles.len().saturating_mul(84)).saturating_add(32);
|
||||
triangles = triangles.saturating_add(mesh.triangles.len());
|
||||
if bytes > 8 << 20 || triangles > 100_000 {
|
||||
return None;
|
||||
@@ -844,6 +874,7 @@ fn saved_preview(s: &Session) -> Option<solidcraft_io::DesignPreview> {
|
||||
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: mesh.triangles.iter().flatten().copied().collect(),
|
||||
color: s.doc.body_color(body),
|
||||
corner_normals: mesh.triangles.iter().flatten().filter_map(|&i| mesh.normals.get(i as usize)).flat_map(|n| [n.x, n.y, n.z]).collect(),
|
||||
});
|
||||
}
|
||||
Some(preview)
|
||||
@@ -1983,6 +2014,95 @@ mod tests {
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_import_explicit_crease_normals_are_undoable_and_survive_cached_reopen() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-mesh-normals-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("fold.obj");
|
||||
std::fs::write(&path, "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\nf 1 2 3\nf 2 1 4\n").unwrap();
|
||||
let mut session = Session::default();
|
||||
let before = session.doc.to_json();
|
||||
for bad_angle in [json!(-1), json!(181), json!("smooth")] {
|
||||
let result = session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":bad_angle}));
|
||||
assert!(result.unwrap_err().to_string().contains("crease_angle_degrees"));
|
||||
assert_eq!(session.doc.to_json(), before);
|
||||
}
|
||||
session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":40})).unwrap();
|
||||
let normals = |s: &Session| s.model.state().bodies[0].mesh().normals.clone();
|
||||
let expected = normals(&session);
|
||||
assert!(expected[0].y > 0.2 && expected[0].z > 0.9);
|
||||
session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(session.model.state().bodies.is_empty());
|
||||
session.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(normals(&session), expected);
|
||||
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
|
||||
let cached = solidcraft_io::read_design_geometry(&bytes).expect("shaded mesh retains native cache");
|
||||
assert!(!cached.bodies[0].body.triangle_corner_normals().unwrap().is_empty());
|
||||
let saved_path = dir.join("shaded.sc");
|
||||
std::fs::write(&saved_path, &bytes).unwrap();
|
||||
let mut reopened = Session::default();
|
||||
reopened.execute("doc.open", &json!({"path":saved_path})).unwrap();
|
||||
assert_eq!(normals(&reopened), expected);
|
||||
let doc = solidcraft_io::read_design(&bytes).unwrap();
|
||||
let cold = Session::new(doc);
|
||||
assert_eq!(normals(&cold), expected);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_import_zero_crease_restores_legacy_flat_shading_and_survives_reopen() {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-obj-flat-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let vertices = "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\n";
|
||||
let faces = "f 1 2 3\nf 2 1 4\n";
|
||||
let reference_path = dir.join("reference.obj");
|
||||
std::fs::write(&reference_path, format!("{vertices}{faces}")).unwrap();
|
||||
let mut reference = Session::default();
|
||||
reference.execute("file.insert_mesh", &json!({"path":reference_path})).unwrap();
|
||||
let display = |s: &Session| {
|
||||
let mesh = s.model.state().bodies[0].mesh();
|
||||
(mesh.positions.clone(), mesh.triangles.clone(), mesh.normals.clone())
|
||||
};
|
||||
let expected = display(&reference);
|
||||
for (name, source) in
|
||||
[("groups", format!("{vertices}s 1\n{faces}")), ("authored", format!("{vertices}vn 0 1 1\nf 1//1 2//1 3//1\nf 2//1 1//1 4//1\n"))]
|
||||
{
|
||||
let path = dir.join(format!("{name}.obj"));
|
||||
std::fs::write(&path, source).unwrap();
|
||||
let mut annotated = Session::default();
|
||||
annotated.execute("file.insert_mesh", &json!({"path":path})).unwrap();
|
||||
assert_ne!(display(&annotated).2, expected.2, "{name} must exercise imported shading");
|
||||
let mut session = Session::default();
|
||||
session.execute("file.insert_mesh", &json!({"path":path, "crease_angle_degrees":0})).unwrap();
|
||||
assert_eq!(display(&session), expected, "{name} uses the exact former flat display path");
|
||||
let meshes = session
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.find_map(|f| match &f.kind {
|
||||
solidcraft_doc::FeatureKind::MeshImport { meshes, .. } => Some(meshes),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(meshes.iter().all(|mesh| mesh.corner_normals.is_empty()));
|
||||
session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(session.model.state().bodies.is_empty());
|
||||
session.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(display(&session), expected);
|
||||
let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap();
|
||||
let cached = solidcraft_io::read_design_geometry(&bytes).unwrap();
|
||||
assert!(cached.bodies[0].body.triangle_corner_normals().unwrap().is_empty());
|
||||
let saved_path = dir.join(format!("{name}.sc"));
|
||||
std::fs::write(&saved_path, &bytes).unwrap();
|
||||
let mut reopened = Session::default();
|
||||
reopened.execute("doc.open", &json!({"path":saved_path})).unwrap();
|
||||
assert_eq!(display(&reopened), expected);
|
||||
let cold = Session::new(solidcraft_io::read_design(&bytes).unwrap());
|
||||
assert_eq!(display(&cold), expected);
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// #9170: a design holding a mesh body (an inserted STL) keeps its saved model and opens from
|
||||
/// it. One mesh body left the cache out for the whole model, so every feature, the boxes
|
||||
/// too, was evaluated again on opening.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,30 @@
|
||||
use super::*;
|
||||
use solidcraft_render::job::{CpuHost, JobId};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
// Validate each live receipt before comparing deterministic accounting. RAM
|
||||
// snapshots and other retained leases can change between otherwise equal checks.
|
||||
fn preflight_without_memory_observations(mut reply: Value) -> Value {
|
||||
let total = reply["admission"]["estimated_total_bytes"].as_u64().unwrap();
|
||||
let memory = reply["admission"]["host_memory"].as_object_mut().expect("native preflight reports its RAM admission");
|
||||
let required = memory["required_bytes"].as_u64().unwrap();
|
||||
let admitted = memory["admitted_bytes"].as_u64().unwrap();
|
||||
let available = memory["available_bytes"].as_u64().unwrap();
|
||||
let capacity = memory["capacity_bytes"].as_u64().unwrap();
|
||||
assert_eq!(required, total, "native debit includes the complete public admission");
|
||||
assert!(required <= available, "accepted native debit fits its available RAM: {memory:?}");
|
||||
assert_eq!(admitted.checked_add(available), Some(capacity), "native ledger accounts for the entire capacity snapshot");
|
||||
assert!(
|
||||
matches!(memory["source"].as_str(), Some("system_available" | "system_and_cgroup" | "conservative_fallback")),
|
||||
"native receipt identifies its capacity source"
|
||||
);
|
||||
// Keep required_bytes and every non-observation field in the exact comparison.
|
||||
for field in ["admitted_bytes", "available_bytes", "capacity_bytes", "source"] {
|
||||
memory.remove(field).unwrap();
|
||||
}
|
||||
reply
|
||||
}
|
||||
|
||||
fn session() -> (Session, CpuHost) {
|
||||
let host = CpuHost::cooperative();
|
||||
let mut s = Session::default();
|
||||
@@ -128,7 +152,7 @@ fn saved_gpu_job_routes_before_dispatch_and_reaches_preflight_and_start_without_
|
||||
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
|
||||
let checked = s.execute("render.preflight", &named).unwrap();
|
||||
let inline = s.execute("render.preflight", &json!({"settings":saved})).unwrap();
|
||||
assert_eq!(checked, inline);
|
||||
assert_eq!(preflight_without_memory_observations(checked.clone()), preflight_without_memory_observations(inline));
|
||||
assert_eq!(checked["resolved"]["executor"], "gpu-path");
|
||||
assert_eq!(checked["options"]["exposure_ev"], 1.75);
|
||||
assert_eq!(checked["options"]["samples"], 7);
|
||||
@@ -209,7 +233,10 @@ fn saved_gpu_job_routes_before_dispatch_and_reaches_preflight_and_start_without_
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.revision, s.undo.len(), s.redo.len(), s.is_dirty());
|
||||
let checked = s.execute("render.preflight", &json!({"setup":"HDR"})).unwrap();
|
||||
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap());
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone()),
|
||||
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap())
|
||||
);
|
||||
assert_eq!(checked["environment"]["source_sha256"], info["sha256"]);
|
||||
assert_eq!(checked["environment"]["kind"], "radiance_hdr");
|
||||
assert_eq!(checked["environment"]["color_policy"], "declared_linear_srgb_d65");
|
||||
@@ -253,7 +280,10 @@ fn saved_cpu_job_reopens_and_supplies_exact_options_and_one_exposure_to_check_an
|
||||
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
|
||||
let named = json!({"setup":"CPU"});
|
||||
let checked = s.execute("render.preflight", &named).unwrap();
|
||||
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":saved})).unwrap());
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone()),
|
||||
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":saved})).unwrap())
|
||||
);
|
||||
assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
|
||||
assert_eq!(checked["request"]["integrator"], "raytrace");
|
||||
assert_eq!(checked["request"]["raytrace"]["exposure_ev"], 1.25);
|
||||
@@ -300,7 +330,10 @@ fn saved_cpu_hdr_reopens_without_source_and_drives_identical_check_start_and_rea
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.revision, s.undo.len(), s.redo.len(), s.is_dirty());
|
||||
let checked = s.execute("render.preflight", &json!({"setup":"HDR"})).unwrap();
|
||||
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap());
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone()),
|
||||
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":captured["setup"]})).unwrap())
|
||||
);
|
||||
assert_eq!(checked["request"]["raytrace"]["exposure_ev"], 1.25);
|
||||
assert_eq!(checked["environment"]["samples"], 64);
|
||||
assert_eq!(checked["environment"]["rotation_radians"], 0.75);
|
||||
@@ -308,7 +341,10 @@ fn saved_cpu_hdr_reopens_without_source_and_drives_identical_check_start_and_rea
|
||||
let runtime = json!({"integrator":"raytrace","settings":{"width":9,"height":9,"background":[13,27,41]},
|
||||
"raytrace":{"lights":[],"shadows":false,"primary_samples":4,"exposure_ev":1.25},
|
||||
"environment":{"resource":info["sha256"],"rotation_radians":0.75,"intensity":2,"samples":64}});
|
||||
assert_eq!(checked, s.execute("render.preflight", &runtime).unwrap());
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone()),
|
||||
preflight_without_memory_observations(s.execute("render.preflight", &runtime).unwrap())
|
||||
);
|
||||
for environment in [
|
||||
json!({"resource":"0".repeat(64)}),
|
||||
json!({"resource":info["sha256"],"path":"must-not-open.hdr"}),
|
||||
@@ -425,7 +461,7 @@ fn additional_executor_routes_by_capability_and_survives_document_adoption() {
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!(session.execute("render.preflight", &request).unwrap()["resolved"]["executor"], "test-progressive");
|
||||
assert!(session.execute("render.status", &json!({"job":id})).is_err());
|
||||
for bad in [json!({"samples":0}), json!({"max_depth":17}), json!({"seed":-1}), json!({"samples":4097})] {
|
||||
for bad in [json!({"samples":0}), json!({"max_depth":65}), json!({"seed":-1}), json!({"samples":65537})] {
|
||||
let mut params = request.clone();
|
||||
for (key, value) in bad.as_object().unwrap() {
|
||||
params[key] = value.clone();
|
||||
@@ -1512,7 +1548,9 @@ fn saved_cpu_denoise_reports_its_actual_native_request_and_keeps_regrade_raw() {
|
||||
s.execute(
|
||||
"render.settings.set",
|
||||
&json!({"name":"CPU denoise","set":{"width":8,"height":8,
|
||||
"job":{"method":"cpu_raytrace","options":{"denoise":true,"progressive":{"samples_per_pixel":3,"seed":81},"output":{"linear_exr":true}}}}}),
|
||||
"job":{"method":"cpu_raytrace","options":{"threads":2,"supersampling":2,"denoise":true,
|
||||
"denoise_settings":{"passes":2,"strength":0.5,"firefly_suppression":false},"denoise_diagnostics":true,
|
||||
"progressive":{"samples_per_pixel":3,"seed":81},"output":{"linear_exr":true}}}}}),
|
||||
)
|
||||
.unwrap();
|
||||
s = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
|
||||
@@ -1524,7 +1562,13 @@ fn saved_cpu_denoise_reports_its_actual_native_request_and_keeps_regrade_raw() {
|
||||
assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
|
||||
assert_eq!(checked["request"]["raytrace"]["denoise"], true);
|
||||
assert_eq!(checked["denoising"]["backend"], "native_cpu");
|
||||
assert_eq!(checked["denoising"]["passes"], 3);
|
||||
assert_eq!(checked["denoising"]["passes"], 2);
|
||||
assert_eq!(checked["denoising"]["steps"], json!([1, 2]));
|
||||
assert_eq!(checked["denoising"]["settings"], json!({"passes":2,"strength":0.5,"firefly_suppression":false}));
|
||||
assert_eq!(checked["denoising"]["outlier_policy"], "disabled");
|
||||
assert_eq!(checked["request"]["raytrace"]["threads"], 2);
|
||||
assert_eq!(checked["request"]["raytrace"]["supersampling"], 2);
|
||||
assert_eq!(checked["request"]["raytrace"]["denoise_diagnostics"], true);
|
||||
assert_eq!(checked["denoising"]["raw_linear_accumulator_preserved"], true);
|
||||
let started = s.execute("render.start", &json!({"setup":"CPU denoise"})).unwrap();
|
||||
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
|
||||
@@ -1534,13 +1578,43 @@ fn saved_cpu_denoise_reports_its_actual_native_request_and_keeps_regrade_raw() {
|
||||
assert_eq!(json!(status.request), checked["request"]);
|
||||
let frame = handle.frame().unwrap().unwrap();
|
||||
let raw = frame.linear_exr().unwrap().to_vec();
|
||||
assert_eq!((frame.info.width, frame.info.height), (8, 8));
|
||||
assert_eq!(image::load_from_memory(frame.png()).unwrap().to_rgba8().dimensions(), (8, 8));
|
||||
let report = status.denoise_diagnostics.unwrap();
|
||||
assert_eq!((report.width, report.height), (16, 16));
|
||||
assert_eq!(report.counts.iter().sum::<u64>(), 256);
|
||||
let mask = frame.denoise_mask_png().unwrap().to_vec();
|
||||
let mask_info = frame.denoise_mask_info().unwrap();
|
||||
assert_eq!((mask_info.width, mask_info.height), (8, 8));
|
||||
assert_eq!(mask_info.bytes, mask.len() as u64);
|
||||
assert_ne!(mask_info.sha256, frame.info.png_sha256);
|
||||
let decoded = image::load_from_memory(&mask).unwrap().to_rgba8();
|
||||
assert_eq!(decoded.dimensions(), (8, 8));
|
||||
assert!(decoded.pixels().all(|pixel| solidcraft_render::job::DenoiseGuideClass::ALL.iter().any(|class| class.color() == pixel.0)));
|
||||
let status_reply = s.execute("render.status", &json!({"job":id})).unwrap();
|
||||
assert_eq!(status_reply["denoise_mask"], json!(mask_info));
|
||||
assert_eq!(status_reply["denoise_diagnostics"], json!(report));
|
||||
let viewed = s.execute("render.result.view", &json!({"view":"denoise_mask"})).unwrap();
|
||||
assert_eq!(viewed, json!({"view":"denoise_mask","job":id}));
|
||||
let path = std::env::temp_dir().join(format!("solidcraft-cpu-guide-mask-{}-{}.png", std::process::id(), id.session));
|
||||
let exported = s.execute("render.export", &json!({"job":id,"path":path,"format":"denoise_mask"})).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), mask);
|
||||
assert_eq!(exported["format"], "denoise_mask");
|
||||
assert_eq!(exported["encoding"], "categorical_srgb");
|
||||
assert_eq!(exported["denoise_mask"], json!(mask_info));
|
||||
assert_eq!(exported["frame"]["png_sha256"], frame.info.png_sha256);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
let result = s.execute("render.result.regrade", &json!({"job":id,"presentation":{"exposure_ev":-1.0}})).unwrap();
|
||||
let derivative: JobId = serde_json::from_value(result["job"].clone()).unwrap();
|
||||
let derivative = s.render_job_handle(derivative).unwrap();
|
||||
let finished = derivative.wait_timeout(std::time::Duration::from_secs(30)).unwrap();
|
||||
assert_eq!(finished.progress.phase, Phase::Completed);
|
||||
assert!(!finished.presentation.as_ref().unwrap().denoise_applied, "regrade never pretends to reconstruct absent geometry guides");
|
||||
assert_eq!(derivative.frame().unwrap().unwrap().linear_exr().unwrap(), raw);
|
||||
let derivative_frame = derivative.frame().unwrap().unwrap();
|
||||
assert_eq!(derivative_frame.linear_exr().unwrap(), raw);
|
||||
assert_eq!(derivative_frame.denoise_mask_png().unwrap(), mask);
|
||||
assert_eq!(json!(derivative_frame.denoise_mask_info()), json!(Some(mask_info)));
|
||||
assert_eq!(finished.denoise_diagnostics, Some(report));
|
||||
assert_eq!(frame.linear_exr().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&document, &s.doc));
|
||||
assert_eq!((s.revision, s.evaluations, s.undo.len(), s.redo.len()), before);
|
||||
@@ -1594,12 +1668,14 @@ fn saved_cpu_optics_match_runtime_capture_pixels_prefixes_cancel_and_accounting(
|
||||
let extra_bytes = (extra_slots * std::mem::size_of::<solidcraft_render::job::DielectricOptions>()) as u64;
|
||||
assert_eq!(saved["retained_request_bytes"].as_u64().unwrap() + extra_bytes, runtime["retained_request_bytes"].as_u64().unwrap());
|
||||
// Two options copies belong to image work; a third belongs to the retained
|
||||
// immutable request. All other admission fields must remain exactly equal.
|
||||
let mut expected_runtime_admission = saved["admission"].clone();
|
||||
// immutable request. All other deterministic admission fields remain exact.
|
||||
let mut expected_runtime_admission = preflight_without_memory_observations(saved.clone())["admission"].clone();
|
||||
for (field, copies) in [("preparation_bytes", 2), ("estimated_total_bytes", 3)] {
|
||||
expected_runtime_admission[field] = json!(saved["admission"][field].as_u64().unwrap() + copies * extra_bytes);
|
||||
}
|
||||
assert_eq!(expected_runtime_admission, runtime["admission"]);
|
||||
expected_runtime_admission["host_memory"]["required_bytes"] =
|
||||
json!(saved["admission"]["host_memory"]["required_bytes"].as_u64().unwrap() + 3 * extra_bytes);
|
||||
assert_eq!(expected_runtime_admission, preflight_without_memory_observations(runtime.clone())["admission"]);
|
||||
assert_eq!(saved["refraction"], true);
|
||||
let original_mesh = scene.meshes[0].mesh.clone();
|
||||
assert!(!scene.has_transmission(), "saved CPU lowering leaves the captured product appearance and physical material unchanged");
|
||||
@@ -1925,7 +2001,10 @@ fn saved_cpu_aging_cold_reopen_resolves_exact_target_and_changes_real_png_withou
|
||||
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
|
||||
let checked = s.execute("render.preflight", &json!({"setup":"CPU aging"})).unwrap();
|
||||
assert_eq!(checked["request"]["raytrace"]["aging"][0]["target"]["body_name"], name);
|
||||
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":saved})).unwrap());
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone()),
|
||||
preflight_without_memory_observations(s.execute("render.preflight", &json!({"settings":saved})).unwrap())
|
||||
);
|
||||
let started = s.execute("render.start", &json!({"setup":"CPU aging"})).unwrap();
|
||||
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
|
||||
let handle = s.render_job_handle(id).unwrap();
|
||||
@@ -2229,8 +2308,9 @@ fn runtime_checked_hdr_keeps_two_stable_glass_targets_and_exact_cpu_options_with
|
||||
raster["environment"]["path"] = json!("must-not-open-raster-stable-glass.hdr");
|
||||
assert!(s.execute("render.preflight", &raster).unwrap_err().to_string().contains("stable optics require"));
|
||||
let mut over_cap = params.clone();
|
||||
let optical_limit = solidcraft_render::job::MAX_OPTICAL_TARGETS;
|
||||
over_cap["settings"]["optics"] = json!(
|
||||
(0..9)
|
||||
(0..=optical_limit)
|
||||
.map(|i| {
|
||||
let mut row = params["settings"]["optics"][0].clone();
|
||||
row["target"]["definition"] = json!(format!("Unique unknown optical body {i}"));
|
||||
@@ -2239,7 +2319,7 @@ fn runtime_checked_hdr_keeps_two_stable_glass_targets_and_exact_cpu_options_with
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
over_cap["environment"]["path"] = json!("must-not-open-over-cap-stable-glass.hdr");
|
||||
assert!(s.execute("render.preflight", &over_cap).unwrap_err().to_string().contains("at most eight"));
|
||||
assert!(s.execute("render.preflight", &over_cap).unwrap_err().to_string().contains(&format!("at most {optical_limit}")));
|
||||
let mut changed_hdr = params.clone();
|
||||
changed_hdr["environment"]["expected_sha256"] = json!("0".repeat(64));
|
||||
assert!(s.execute("render.preflight", &changed_hdr).unwrap_err().to_string().contains("changed since selection"));
|
||||
@@ -2277,7 +2357,7 @@ fn saved_cpu_path_maps_glass_and_targeted_aging_reach_real_preflight_and_render_
|
||||
s.execute(
|
||||
"render.settings.set",
|
||||
&json!({"name":"Mixed","set":{"camera":camera,"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{
|
||||
"progressive":{"samples_per_pixel":3,"seed":9},"path":{"max_depth":4},"lights":[],
|
||||
"texture_filtering":true,"progressive":{"samples_per_pixel":3,"seed":9},"path":{"max_depth":4},"lights":[],
|
||||
"environment":{"color":[1,1,1],"intensity":1},"output":{"linear_exr":true},
|
||||
"aging":[{"kind":"fading","target":panel["stable_target"],"strength":0.25,"coverage":1.0,"upward_bias":0.0}]
|
||||
}}}}),
|
||||
@@ -2301,6 +2381,8 @@ fn saved_cpu_path_maps_glass_and_targeted_aging_reach_real_preflight_and_render_
|
||||
let history = (s.revision, s.evaluations, s.undo.len(), s.redo.len());
|
||||
let checked = s.execute("render.preflight", &json!({"setup":"Mixed"})).unwrap();
|
||||
assert_eq!(checked["textures"]["bodies"], 1);
|
||||
assert_eq!(checked["textures"]["filtering"], true);
|
||||
assert_eq!(checked["request"]["raytrace"]["texture_filtering"], true);
|
||||
assert_eq!(checked["request"]["raytrace"]["path"]["max_depth"], 4);
|
||||
assert_eq!(checked["request"]["raytrace"]["dielectrics"][0]["body_name"], "Glass");
|
||||
assert_eq!(checked["request"]["raytrace"]["dielectrics"][0]["boundary"], "closed_mesh");
|
||||
@@ -2470,7 +2552,11 @@ fn saved_coat_cpu_direct_and_path_render_match_independent_normal_incidence_laye
|
||||
let mut plain = setup.clone();
|
||||
plain.coats = Default::default();
|
||||
let plain_check = s.execute("render.preflight", &json!({"settings":plain})).unwrap();
|
||||
assert_eq!(checked["admission"], plain_check["admission"], "coat uses existing inline surface metadata and the same bounded producer stages");
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone())["admission"],
|
||||
preflight_without_memory_observations(plain_check.clone())["admission"],
|
||||
"coat uses existing inline surface metadata and the same bounded producer stages"
|
||||
);
|
||||
assert_eq!(checked["retained_request_bytes"], plain_check["retained_request_bytes"]);
|
||||
let mut zero = setup;
|
||||
let row = zero.coats.iter().next().unwrap().clone();
|
||||
@@ -3648,3 +3734,66 @@ fn tessellated_cpu_sheet_saved_and_prospective_bindings_certify_and_render_witho
|
||||
assert!(Arc::ptr_eq(&document, &s.doc) && Arc::ptr_eq(&geometry, &s.model.state()));
|
||||
assert_eq!(history, (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty()));
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn saved_final_cpu_image_preserves_mode_executor_and_authored_quality_through_start() {
|
||||
let mut session = Session::default();
|
||||
let host = CpuHost::for_platform().unwrap();
|
||||
session.set_render_job_host(Some(host)).unwrap();
|
||||
session.execute("solid.box", &json!({"length":2,"width":3,"height":4})).unwrap();
|
||||
session
|
||||
.execute(
|
||||
"render.settings.set",
|
||||
&json!({"name":"Final OPUS reference","set":{
|
||||
"mode":"final","width":2,"height":2,"job":{"method":"cpu_raytrace","options":{
|
||||
"budget":"workstation","path":{"max_depth":64},"progressive":{"samples_per_pixel":3,"seed":81},
|
||||
"lights":[],"environment":{"color":[0.1,0.2,0.3],"intensity":1},"output":{"linear_exr":true}
|
||||
}}}}),
|
||||
)
|
||||
.unwrap();
|
||||
let params = json!({"setup":"Final OPUS reference"});
|
||||
let before = session.doc.clone();
|
||||
let preflight = session.execute("render.preflight", ¶ms).unwrap();
|
||||
assert_eq!(preflight["resolved"]["profile"]["mode"], "final");
|
||||
assert_eq!(preflight["resolved"]["executor"], "cpu-raytrace");
|
||||
let started = session.execute("render.start", ¶ms).unwrap();
|
||||
let id = serde_json::from_value(started["job"].clone()).unwrap();
|
||||
let job = session.render_job_handle(id).unwrap();
|
||||
let status = job.wait_timeout(std::time::Duration::from_secs(30)).unwrap();
|
||||
assert_eq!(status.progress.phase, solidcraft_render::job::Phase::Completed);
|
||||
assert_eq!(status.resolved.profile.mode, solidcraft_render::RenderMode::Final);
|
||||
assert_eq!(status.resolved.executor.as_str(), "cpu-raytrace");
|
||||
let options = status.request.raytrace.unwrap();
|
||||
assert_eq!(options.path.unwrap().max_depth, 64);
|
||||
assert_eq!(options.progressive.unwrap().samples_per_pixel, 3);
|
||||
assert!(job.frame().unwrap().unwrap().linear_master_info().is_some());
|
||||
assert!(Arc::ptr_eq(&before, &session.doc));
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn cpu_quality_limits_refuse_before_input_io_and_scene_capture() {
|
||||
let mut s = Session::default();
|
||||
let host = CpuHost::for_platform().unwrap();
|
||||
s.set_render_job_host(Some(host.clone())).unwrap();
|
||||
let document = s.doc.clone();
|
||||
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len());
|
||||
for (width, height, options) in [
|
||||
(1024, 1024, json!({"supersampling":2,"progressive":{"samples_per_pixel":8}})),
|
||||
(8, 8, json!({"threads":65})),
|
||||
(8, 8, json!({"supersampling":3})),
|
||||
(8, 8, json!({"denoise_settings":{"passes":4}})),
|
||||
(8193, 1, json!({"budget":"workstation","denoise_diagnostics":true})),
|
||||
] {
|
||||
let request = json!({"integrator":"raytrace","settings":{"width":width,"height":height},
|
||||
"raytrace":options,"environment":{"path":"must-not-open-quality-controls.hdr"}});
|
||||
for command in ["render.preflight", "render.start"] {
|
||||
let error = s.execute(command, &request).unwrap_err().to_string();
|
||||
assert!(!error.contains("must-not-open-quality-controls.hdr"), "quality must refuse before source IO: {error}");
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
assert!(Arc::ptr_eq(&document, &s.doc));
|
||||
assert_eq!(before, (s.revision, s.evaluations, s.undo.len(), s.redo.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ use solidcraft_render::{
|
||||
job::{ImageRequest, Integrator, JobId, RaytraceOptions},
|
||||
};
|
||||
|
||||
const DENOISE_OUTLIER_POLICY: &str = "suppress isolated high radiance on locally matching guides in display output";
|
||||
const DENOISE_OPTICAL_POLICY: &str =
|
||||
"supported sharp reflections and smooth glass use separate reflected and transmitted endpoint geometry and material guides";
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct Start {
|
||||
@@ -48,11 +52,16 @@ impl Default for EnvironmentRequest {
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct ProgressiveStart {
|
||||
supersampling: Option<u32>,
|
||||
denoise_settings: solidcraft_render::job::DenoiseSettings,
|
||||
denoise_diagnostics: bool,
|
||||
caustics: Option<solidcraft_render::job::CausticOptions>,
|
||||
studio_weights: Option<[f64; 3]>,
|
||||
aging: solidcraft_doc::render_aging::SavedAgingStack,
|
||||
#[serde(default)]
|
||||
nested_media: bool,
|
||||
texture_filtering: bool,
|
||||
metal_energy_compensation: bool,
|
||||
volume: Option<solidcraft_render::job::volume::HomogeneousVolume>,
|
||||
adaptive: Option<solidcraft_render::job::adaptive::AdaptiveSampling>,
|
||||
transmission_samples: Option<u32>,
|
||||
@@ -193,8 +202,9 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
|
||||
"samples":options.samples,"max_depth":options.max_depth,"seed":options.seed,
|
||||
"lens":options.lens,"lighting":options.lighting,"tone_map":options.tone_map,
|
||||
"denoise":options.denoise,"environment":options.environment,
|
||||
"supersampling":options.supersampling,"denoise_settings":options.denoise_settings,"denoise_diagnostics":options.denoise_diagnostics,
|
||||
"finishing":options.finishing,"backdrop":options.backdrop,"linear_exr":options.linear_exr,
|
||||
"caustics":options.caustics,"adaptive":options.adaptive,"transmission_samples":options.transmission_samples,"volume":options.volume,"nested_media":options.nested_media,"aging":options.aging
|
||||
"texture_filtering":options.texture_filtering,"metal_energy_compensation":options.metal_energy_compensation,"caustics":options.caustics,"adaptive":options.adaptive,"transmission_samples":options.transmission_samples,"volume":options.volume,"nested_media":options.nested_media,"aging":options.aging
|
||||
})
|
||||
}
|
||||
};
|
||||
@@ -246,7 +256,7 @@ pub(crate) fn prepare_with_metadata(
|
||||
params.get("raytrace").and_then(Value::as_object).is_some_and(|o| ["clear_coats", "textures"].iter().any(|key| o.contains_key(*key)));
|
||||
if gpu_raytrace
|
||||
|| params.get("environment").is_some_and(|e| e.get("kind").is_some())
|
||||
|| ["lens", "lighting", "tone_map"].iter().any(|key| params.get(*key).is_some())
|
||||
|| ["lens", "lighting", "tone_map", "supersampling", "denoise_settings", "denoise_diagnostics"].iter().any(|key| params.get(*key).is_some())
|
||||
|| params.get("denoise").is_some_and(|value| value != &json!(false))
|
||||
{
|
||||
return Err(bad(
|
||||
@@ -350,7 +360,15 @@ pub(crate) fn prepare_with_metadata(
|
||||
let defaults = RenderSetup::default();
|
||||
(defaults.width, defaults.height)
|
||||
};
|
||||
options.budget.validate_image(dimensions.0, dimensions.1, options.camera_sampling().map_err(job_error)?).map_err(job_error)?;
|
||||
let trace_dimensions = if options.supersampling == 1 && !options.denoise && !options.denoise_diagnostics {
|
||||
dimensions
|
||||
} else {
|
||||
solidcraft_render::job::supersampled_dimensions(dimensions.0, dimensions.1, options.supersampling).map_err(job_error)?
|
||||
};
|
||||
options.budget.validate_image(trace_dimensions.0, trace_dimensions.1, options.camera_sampling().map_err(job_error)?).map_err(job_error)?;
|
||||
if options.denoise || options.denoise_diagnostics {
|
||||
solidcraft_render::job::validate_cpu_denoise_image(trace_dimensions.0, trace_dimensions.1).map_err(job_error)?;
|
||||
}
|
||||
if let Some(lens) = options.lens {
|
||||
let camera = input
|
||||
.setup
|
||||
@@ -430,6 +448,19 @@ pub(crate) fn prepare_with_metadata(
|
||||
}
|
||||
options.dielectrics = super::optical_bindings::resolve_cpu_path(s, &scene, bindings, options.path.is_some(), command)?;
|
||||
options.validate().map_err(job_error)?;
|
||||
// An explicit optical binding owns transmission. Viewport opacity is
|
||||
// only a display proxy and must not enter CPU transport.
|
||||
for optical in &options.dielectrics {
|
||||
let body = scene
|
||||
.meshes
|
||||
.get_mut(optical.body_index)
|
||||
.filter(|body| body.name == optical.body_name)
|
||||
.ok_or_else(|| bad(command, "saved optical target changed during capture"))?;
|
||||
body.opacity = 1.0;
|
||||
for face in &mut body.faces {
|
||||
face.opacity = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
super::coat_bindings::apply(s, &mut scene, &coats, input.raytrace.as_ref(), command)?;
|
||||
if !input.aging.is_empty() {
|
||||
@@ -519,7 +550,13 @@ pub(crate) fn prepare_with_metadata(
|
||||
}
|
||||
let textures = if let Some(bindings) = saved_surfaces {
|
||||
let overrides = super::surface_bindings::resolve(s, &scene, bindings, command)?;
|
||||
super::textures::capture_with_resources(&scene, &overrides, Some(&s.doc.render.texture_resources), command)?
|
||||
super::textures::capture_with_resources_and_filtering(
|
||||
&scene,
|
||||
&overrides,
|
||||
Some(&s.doc.render.texture_resources),
|
||||
command,
|
||||
input.raytrace.as_ref().is_some_and(|options| options.texture_filtering),
|
||||
)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -535,9 +572,13 @@ pub(crate) fn prepare_with_metadata(
|
||||
background: settings.background,
|
||||
transparent_background: input.transparent_background,
|
||||
};
|
||||
let environment_bytes = environment.as_ref().map_or(Ok(0), |e| e.required_bytes()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let submission =
|
||||
let environment_bytes = environment
|
||||
.as_ref()
|
||||
.map_or(Ok(0), |e| e.required_bytes_with_importance(request.raytrace.as_ref().is_some_and(|options| options.environment_importance_sampling)))
|
||||
.map_err(|e| bad(command, e.to_string()))?;
|
||||
let mut submission =
|
||||
host.prepare_submission_with_metadata(scene, request, Default::default(), environment, cancel.clone(), retained).map_err(job_error)?;
|
||||
submission.select_image_mode(settings.mode).map_err(job_error)?;
|
||||
let request = submission.request();
|
||||
let mut admission = submission.admission();
|
||||
let retained_lights = request.raytrace.as_ref().map_or(Ok(0), |o| o.retained_bytes()).map_err(job_error)?;
|
||||
@@ -579,12 +620,16 @@ pub(crate) fn prepare_with_metadata(
|
||||
if !coats.is_empty() {
|
||||
reply["coats"] = json!(coats);
|
||||
}
|
||||
if request.raytrace.as_ref().is_some_and(|options| options.denoise) {
|
||||
if let Some(options) = request.raytrace.as_ref().filter(|options| options.denoise) {
|
||||
reply["denoising"] = json!({
|
||||
"method":"edge_aware_atrous", "backend":"native_cpu", "passes":3, "steps":[1,2,4],
|
||||
"method":"edge_aware_atrous", "backend":"native_cpu", "passes":options.denoise_settings.passes,
|
||||
"steps":denoise_steps(options.denoise_settings.passes), "settings":options.denoise_settings,
|
||||
"diagnostics_requested":options.denoise_diagnostics,
|
||||
"outlier_policy":if options.denoise_settings.firefly_suppression {DENOISE_OUTLIER_POLICY} else {"disabled"},
|
||||
"optical_policy":DENOISE_OPTICAL_POLICY,
|
||||
"guides":"deterministic_center_ray_albedo_normal_camera_relative_position_depth",
|
||||
"bypass":"glass, sharp CPU mirror overrides, smooth metal/coat, emitter, background and partial coverage first hits",
|
||||
"limitation":"biased spatial filter; first-hit guides do not reconstruct reflection/refraction or depth-of-field correspondences",
|
||||
"bypass":"unsupported or ambiguous optical paths, emitter, background and partial coverage",
|
||||
"limitation":"biased spatial filter; optical guides do not decompose reflected and transmitted radiance or reconstruct depth-of-field correspondences",
|
||||
"raw_linear_accumulator_preserved":true,"raw_linear_exr_preserved":true,
|
||||
"output_stage":"linear_before_tone_map", "work_units":"one pixel visit per pass, each with at most 25 taps",
|
||||
"checkpoints":"every complete published pass uses the same filter"
|
||||
@@ -710,16 +755,22 @@ fn prepare_progressive(
|
||||
options.lighting = input.lighting;
|
||||
options.tone_map = input.tone_map.unwrap_or_default();
|
||||
options.denoise = input.denoise;
|
||||
options.denoise_settings = input.denoise_settings;
|
||||
options.denoise_diagnostics = input.denoise_diagnostics;
|
||||
options.supersampling = input.supersampling.unwrap_or(1);
|
||||
options.finishing = input.finishing;
|
||||
options.backdrop = input.backdrop;
|
||||
options.linear_exr = input.linear_exr;
|
||||
options.aging = super::effects::resolve_aging(s, &scene, &input.aging, command)?;
|
||||
options.nested_media = input.nested_media;
|
||||
options.texture_filtering = input.texture_filtering;
|
||||
options.metal_energy_compensation = input.metal_energy_compensation;
|
||||
options.volume = input.volume;
|
||||
options.caustics = input.caustics;
|
||||
options.adaptive = input.adaptive;
|
||||
options.transmission_samples = input.transmission_samples.unwrap_or(1);
|
||||
options.validate().map_err(job_error)?;
|
||||
solidcraft_render::job::supersampled_dimensions(settings.width, settings.height, options.supersampling).map_err(job_error)?;
|
||||
if let Some(super::environment::Input::BuiltinHdr { id, .. }) = &input.environment
|
||||
&& options.lighting.as_ref().is_some_and(|lighting| lighting.sun.is_some())
|
||||
&& super::library::owns_sun(id, command)?
|
||||
@@ -794,10 +845,17 @@ fn prepare_progressive(
|
||||
height: settings.height,
|
||||
background: settings.background,
|
||||
transparent_background: input.transparent_background,
|
||||
textures: super::textures::capture_with_resources(&scene, &input.raytrace.textures, Some(&s.doc.render.texture_resources), command)?,
|
||||
textures: super::textures::capture_with_resources_and_filtering(
|
||||
&scene,
|
||||
&input.raytrace.textures,
|
||||
Some(&s.doc.render.texture_resources),
|
||||
command,
|
||||
input.texture_filtering,
|
||||
)?,
|
||||
};
|
||||
let refraction = input.progressive && scene.has_transmission();
|
||||
let submission = host.prepare_submission_with_metadata(scene, request, options, None, cancel.clone(), retained).map_err(job_error)?;
|
||||
let mut submission = host.prepare_submission_with_metadata(scene, request, options, None, cancel.clone(), retained).map_err(job_error)?;
|
||||
submission.select_image_mode(settings.mode).map_err(job_error)?;
|
||||
let request = submission.request();
|
||||
let mut admission = submission.admission();
|
||||
let retained_options_bytes = options.retained_bytes().map_err(job_error)?;
|
||||
@@ -814,10 +872,14 @@ fn prepare_progressive(
|
||||
}
|
||||
if options.denoise {
|
||||
reply["denoising"] = json!({
|
||||
"method":"edge_aware_atrous", "passes":3, "steps":[1,2,4],
|
||||
"method":"edge_aware_atrous", "passes":options.denoise_settings.passes,
|
||||
"steps":denoise_steps(options.denoise_settings.passes), "settings":options.denoise_settings,
|
||||
"diagnostics_requested":options.denoise_diagnostics,
|
||||
"outlier_policy":if options.denoise_settings.firefly_suppression {DENOISE_OUTLIER_POLICY} else {"disabled"},
|
||||
"optical_policy":DENOISE_OPTICAL_POLICY,
|
||||
"guides":"deterministic_center_ray_albedo_normal_position_depth",
|
||||
"bypass":"glass, smooth metal/coat, emitter, background and partial coverage first hits",
|
||||
"limitation":"first-hit guides do not reconstruct reflection/refraction or depth-of-field correspondences",
|
||||
"bypass":"unsupported or ambiguous optical paths, emitter, background and partial coverage",
|
||||
"limitation":"optical guides do not decompose reflected and transmitted radiance or reconstruct depth-of-field correspondences",
|
||||
"raw_linear_accumulator_preserved":true, "output_stage":"linear_before_tone_map"
|
||||
});
|
||||
}
|
||||
@@ -952,12 +1014,20 @@ enum ExportFormat {
|
||||
#[default]
|
||||
Png,
|
||||
Exr,
|
||||
DenoiseMask,
|
||||
}
|
||||
impl ExportFormat {
|
||||
fn extension(self) -> &'static str {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png => "png",
|
||||
Self::Exr => "exr",
|
||||
Self::DenoiseMask => "denoise_mask",
|
||||
}
|
||||
}
|
||||
fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Self::Png | Self::DenoiseMask => "png",
|
||||
Self::Exr => "exr",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -988,6 +1058,9 @@ pub(super) fn export(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let frame = s.render_jobs.frame(job).map_err(job_error)?;
|
||||
let bytes = match input.format {
|
||||
ExportFormat::Png => frame.png(),
|
||||
ExportFormat::DenoiseMask => frame
|
||||
.denoise_mask_png()
|
||||
.ok_or_else(|| bad("render.export", "this completed job has no guide mask; enable denoising diagnostics before rendering"))?,
|
||||
ExportFormat::Exr => frame
|
||||
.linear_exr()
|
||||
.ok_or_else(|| bad("render.export", "this completed job has no raw linear master; enable retention before rendering"))?,
|
||||
@@ -998,10 +1071,14 @@ pub(super) fn export(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
if input.overwrite { solidcraft_io::write_atomic(path, bytes, false) } else { solidcraft_io::safe_write::create_atomic(path, bytes) };
|
||||
result.map_err(|e| bad("render.export", format!("could not export completed {}: {e}", input.format.extension())))?;
|
||||
Ok(
|
||||
json!({"job":job,"path":input.path,"bytes":bytes.len(),"format":input.format.extension(),"encoding":if input.format==ExportFormat::Exr {"scene_linear_srgb_d65"} else {"srgb"},"alpha":if input.format==ExportFormat::Exr {"coverage_premultiplied"} else {"straight"},"frame":frame.info,"linear_master":frame.linear_master_info(),"presentation":frame.presentation()}),
|
||||
json!({"job":job,"path":input.path,"bytes":bytes.len(),"format":input.format.name(),"encoding":match input.format {ExportFormat::Exr=>"scene_linear_srgb_d65",ExportFormat::DenoiseMask=>"categorical_srgb",ExportFormat::Png=>"srgb"},"alpha":match input.format {ExportFormat::Exr=>"coverage_premultiplied",ExportFormat::DenoiseMask=>"opaque",ExportFormat::Png=>"straight"},"frame":frame.info,"linear_master":frame.linear_master_info(),"presentation":frame.presentation(),"denoise_mask":frame.denoise_mask_info()}),
|
||||
)
|
||||
}
|
||||
|
||||
fn denoise_steps(passes: u32) -> Vec<u32> {
|
||||
(0..passes.min(3)).map(|pass| 1 << pass).collect()
|
||||
}
|
||||
|
||||
fn validate_studio_weights(weights: Option<[f64; 3]>, command: &str) -> Result<Option<[f64; 3]>> {
|
||||
if weights.is_some_and(|weights| weights.iter().any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))) {
|
||||
return Err(bad(command, "studio visibility weights must be finite in 0..1"));
|
||||
|
||||
@@ -784,7 +784,7 @@ fn invalid_saved_jobs_never_partially_replace_a_setup() {
|
||||
save(&mut s, json!({"job":{"method":"cpu_raytrace","options":{"primary_samples":4}},"exposure_ev":2}));
|
||||
for patch in [
|
||||
json!({"width":800,"job":{"method":"cpu_raytrace","options":{"primary_samples":3}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"samples":4097}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"samples":65537}}}),
|
||||
json!({"job":{"method":"cpu_raytrace","options":{"emissions":[]}}}),
|
||||
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"hdr","path":"not-opened.hdr"}}}}),
|
||||
json!({"job":{"method":"raster"}}),
|
||||
|
||||
@@ -115,11 +115,23 @@ pub(super) fn validate_targets(scene: &ProductScene, overrides: &[Override], com
|
||||
pub(super) fn capture(scene: &ProductScene, overrides: &[Override], command: &str) -> Result<Option<Arc<RuntimeTextures>>> {
|
||||
capture_with_resources(scene, overrides, None, command)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn capture_with_resources(
|
||||
scene: &ProductScene,
|
||||
overrides: &[Override],
|
||||
resources: Option<&solidcraft_doc::render_texture_resources::TextureResources>,
|
||||
command: &str,
|
||||
) -> Result<Option<Arc<RuntimeTextures>>> {
|
||||
capture_with_resources_and_filtering(scene, overrides, resources, command, false)
|
||||
}
|
||||
|
||||
pub(super) fn capture_with_resources_and_filtering(
|
||||
scene: &ProductScene,
|
||||
overrides: &[Override],
|
||||
resources: Option<&solidcraft_doc::render_texture_resources::TextureResources>,
|
||||
command: &str,
|
||||
filtering: bool,
|
||||
) -> Result<Option<Arc<RuntimeTextures>>> {
|
||||
if overrides.is_empty() {
|
||||
return Ok(None);
|
||||
@@ -164,7 +176,7 @@ pub(super) fn capture_with_resources(
|
||||
flip_v: input.flip_v,
|
||||
});
|
||||
}
|
||||
let resources = RuntimeTextures::new(bindings).map_err(|e| bad(command, e.to_string()))?;
|
||||
let resources = RuntimeTextures::new(bindings).map_err(|e| bad(command, e.to_string()))?.with_filtering(filtering);
|
||||
resources.validate_scene(scene).map_err(|e| bad(command, e.to_string()))?;
|
||||
Ok(Some(Arc::new(resources)))
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ fn bounded_dielectrics<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::R
|
||||
impl<'de> serde::de::Visitor<'de> for Inputs {
|
||||
type Value = Vec<Override>;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("at most 32 disjoint dielectric body overrides")
|
||||
f.write_str("at most 128 dielectric body overrides")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error> {
|
||||
let mut result = Vec::new();
|
||||
@@ -74,7 +74,7 @@ impl TryFrom<String> for Boundary {
|
||||
}
|
||||
pub(super) fn apply(scene: &mut ProductScene, overrides: &[Override], command: &str) -> Result<()> {
|
||||
if overrides.len() > solidcraft_render::job::MAX_GPU_DIELECTRICS {
|
||||
return Err(bad(command, "at most 32 runtime dielectric overrides are supported"));
|
||||
return Err(bad(command, "at most 128 runtime dielectric overrides are supported"));
|
||||
}
|
||||
let mut ordinals = std::collections::BTreeSet::new();
|
||||
for input in overrides {
|
||||
|
||||
@@ -65,12 +65,12 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.icon("settings")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("width, height: 1..16384; custom?: bool. Validated Render output draft request; headless echo only, no design edits or job."),
|
||||
.params("width, height: 1..16384; custom?: bool. Validated Render output draft request; headless echo only, no design edits or job. The UI separately limits new selections to 8192 per side, 33554432 output pixels and the selected rendering profile."),
|
||||
CommandSpec::new("render.result.view", "View Render Image", result_view)
|
||||
.icon("render_image")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("view: model|image|fit|actual. Validated result canvas request; headless echo only, never starts a job."),
|
||||
.params("view: model|image|fit|actual|denoise_mask. Guide-mask view requires a selected completed result captured with denoising diagnostics. Validated result canvas request; headless echo only, never starts a job."),
|
||||
CommandSpec::new("render.result.close", "Close Render Image", close_result)
|
||||
.icon("close")
|
||||
.key("Escape")
|
||||
@@ -275,8 +275,18 @@ struct ResultView {
|
||||
}
|
||||
fn result_view(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let input: ResultView = serde_json::from_value(params.clone()).map_err(|e| bad("render.result.view", e.to_string()))?;
|
||||
if !matches!(input.view.as_str(), "model" | "image" | "fit" | "actual") {
|
||||
return Err(bad("render.result.view", "view must be model, image, fit or actual"));
|
||||
if !matches!(input.view.as_str(), "model" | "image" | "fit" | "actual" | "denoise_mask") {
|
||||
return Err(bad("render.result.view", "view must be model, image, fit, actual or denoise_mask"));
|
||||
}
|
||||
if input.view == "denoise_mask" {
|
||||
s.render_jobs.collect().map_err(crate::render_jobs::job_error)?;
|
||||
let job = s.render_selected_result().ok_or_else(|| bad("render.result.view", "no completed result is selected"))?;
|
||||
let frame = s.render_jobs.frame(job).map_err(crate::render_jobs::job_error)?;
|
||||
if frame.denoise_mask_png().is_none() {
|
||||
return Err(bad("render.result.view", "the selected result has no denoising guide mask"));
|
||||
}
|
||||
s.render_jobs.set_comparison(None);
|
||||
return Ok(json!({"view":input.view,"job":job}));
|
||||
}
|
||||
if input.view == "model" {
|
||||
s.render_jobs.set_comparison(None);
|
||||
@@ -297,7 +307,16 @@ fn output_viewer_requests_are_validated_headless_echoes() {
|
||||
let mut s = Session::default();
|
||||
let document = s.doc.clone();
|
||||
let before = (s.revision, s.undo.len(), s.evaluations);
|
||||
for size in [json!({"width":1280,"height":720}), json!({"width":1920,"height":1080})] {
|
||||
// Headless requests keep the established per-side range. They allocate no
|
||||
// image; the UI and actual renderer independently enforce their envelopes.
|
||||
for size in [
|
||||
json!({"width":1280,"height":720}),
|
||||
json!({"width":1920,"height":1080}),
|
||||
json!({"width":7680,"height":4320}),
|
||||
json!({"width":16384,"height":1}),
|
||||
json!({"width":1,"height":16384}),
|
||||
json!({"width":16384,"height":16384}),
|
||||
] {
|
||||
assert_eq!(s.execute("render.output.size", &size).unwrap(), size);
|
||||
}
|
||||
let custom = json!({"width":1920,"height":1080,"custom":true});
|
||||
@@ -307,10 +326,18 @@ fn output_viewer_requests_are_validated_headless_echoes() {
|
||||
let request = json!({"view":view});
|
||||
assert_eq!(s.execute("render.result.view", &request).unwrap(), request);
|
||||
}
|
||||
assert!(s.execute("render.result.view", &json!({"view":"denoise_mask"})).is_err());
|
||||
assert_eq!(s.execute("render.result.close", &json!({})).unwrap(), json!({"view":"model"}));
|
||||
for request in
|
||||
[json!(null), json!({}), json!({"width":0,"height":1}), json!({"width":u64::MAX,"height":1}), json!({"width":1,"height":1,"extra":true})]
|
||||
{
|
||||
for request in [
|
||||
json!(null),
|
||||
json!({}),
|
||||
json!({"width":0,"height":1}),
|
||||
json!({"width":1,"height":0}),
|
||||
json!({"width":16385,"height":1}),
|
||||
json!({"width":1,"height":16385}),
|
||||
json!({"width":u64::MAX,"height":1}),
|
||||
json!({"width":1,"height":1,"extra":true}),
|
||||
] {
|
||||
assert!(s.execute("render.output.size", &request).is_err());
|
||||
}
|
||||
for request in [json!(null), json!({"view":"unknown"}), json!({"view":"image","extra":true})] {
|
||||
|
||||
@@ -14,6 +14,7 @@ fn imported_box() -> Session {
|
||||
positions: mesh.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: mesh.triangles.iter().flatten().copied().collect(),
|
||||
color: Some([0.2, 0.5, 0.8]),
|
||||
corner_normals: Default::default(),
|
||||
}],
|
||||
},
|
||||
Some("Imported mesh"),
|
||||
@@ -39,9 +40,14 @@ fn edits_share_an_imported_meshs_triangles_with_undo() {
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let (p, t) = arrays(&s);
|
||||
assert!(std::sync::Arc::ptr_eq(&p, &positions) && std::sync::Arc::ptr_eq(&t, &triangles), "shared after undo");
|
||||
let saved =
|
||||
serde_json::to_value(MeshData { name: "M".into(), positions: vec![1.5, 2.0, 3.0].into(), triangles: vec![0, 0, 0].into(), color: None })
|
||||
.unwrap();
|
||||
let saved = serde_json::to_value(MeshData {
|
||||
name: "M".into(),
|
||||
positions: vec![1.5, 2.0, 3.0].into(),
|
||||
triangles: vec![0, 0, 0].into(),
|
||||
color: None,
|
||||
corner_normals: Default::default(),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(saved, json!({"name":"M","positions":[1.5,2.0,3.0],"triangles":[0,0,0]}));
|
||||
let back: MeshData = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(&back.positions[..], &[1.5, 2.0, 3.0]);
|
||||
@@ -561,6 +567,7 @@ fn open_in_easyprint_reports_a_host_that_cannot_start_and_a_model_over_the_limit
|
||||
positions: vec![0., 0., 0., 1., 0., 0., 0., 1., 0.].into(),
|
||||
triangles: [0u32, 1, 2].repeat(2_100_000).into(),
|
||||
color: None,
|
||||
corner_normals: Default::default(),
|
||||
}],
|
||||
},
|
||||
Some("Imported mesh"),
|
||||
|
||||
@@ -323,6 +323,118 @@ pub struct MassProperties {
|
||||
}
|
||||
|
||||
impl Mesh {
|
||||
/// Generate angle-weighted corner normals without changing positions or connectivity.
|
||||
/// Only opposite, two-face manifold edges join a smoothing fan. A group of zero
|
||||
/// stays flat; different groups, boundaries and creases never share a normal.
|
||||
/// Degenerate faces do not join or weight a fan; their corner metadata uses
|
||||
/// a unit fallback so mesh-body validation can discard repeated-index faces.
|
||||
/// `groups == None` is an explicitly requested crease policy for unannotated meshes.
|
||||
pub fn crease_corner_normals(&self, angle: f64, groups: Option<&[u32]>) -> Result<Vec<[Vec3; 3]>, String> {
|
||||
if !angle.is_finite() || !(0.0..=std::f64::consts::PI).contains(&angle) || groups.is_some_and(|g| g.len() != self.triangles.len()) {
|
||||
return Err("invalid mesh smoothing angle or group count".into());
|
||||
}
|
||||
let corners = self.triangles.len().checked_mul(3).ok_or("mesh smoothing size overflow")?;
|
||||
let mut parent = Vec::new();
|
||||
parent.try_reserve_exact(corners).map_err(|_| "could not reserve mesh smoothing fans")?;
|
||||
parent.extend(0..corners);
|
||||
let mut weld = std::collections::HashMap::new();
|
||||
weld.try_reserve(self.positions.len()).map_err(|_| "could not reserve mesh smoothing vertices")?;
|
||||
let mut canonical = Vec::new();
|
||||
canonical.try_reserve_exact(self.positions.len()).map_err(|_| "could not reserve mesh smoothing vertex indices")?;
|
||||
for p in &self.positions {
|
||||
if !p.is_finite() {
|
||||
return Err("non-finite mesh smoothing position".into());
|
||||
}
|
||||
let next = weld.len();
|
||||
canonical.push(*weld.entry([(p.x + 0.0).to_bits(), (p.y + 0.0).to_bits(), (p.z + 0.0).to_bits()]).or_insert(next));
|
||||
}
|
||||
let mut normals = Vec::new();
|
||||
normals.try_reserve_exact(self.triangles.len()).map_err(|_| "could not reserve mesh smoothing normals")?;
|
||||
// A third incident face makes an edge non-manifold and prevents a join.
|
||||
let mut edges = std::collections::HashMap::<(usize, usize), (usize, Option<usize>, usize)>::new();
|
||||
edges.try_reserve(corners).map_err(|_| "could not reserve mesh smoothing edges")?;
|
||||
for (i, t) in self.triangles.iter().enumerate() {
|
||||
let [a, b, c] = t.map(|v| self.positions.get(v as usize).copied());
|
||||
let (Some(a), Some(b), Some(c)) = (a, b, c) else { return Err("mesh smoothing index is missing".into()) };
|
||||
let n = (b - a)
|
||||
.cross(c - a)
|
||||
.normalized()
|
||||
.filter(|n| n.is_finite() && [b - a, c - b, a - c].iter().all(|edge| edge.normalized().is_some_and(|v| v.is_finite())));
|
||||
let Some(n) = n else {
|
||||
// An unusable face must not turn its neighbours' shared edge into
|
||||
// a non-manifold edge or contribute a fabricated fan direction.
|
||||
normals.push(Vec3::ZERO);
|
||||
continue;
|
||||
};
|
||||
normals.push(n);
|
||||
for k in 0..3 {
|
||||
let (a, b) = (canonical[t[k] as usize], canonical[t[(k + 1) % 3] as usize]);
|
||||
edges
|
||||
.entry((a.min(b), a.max(b)))
|
||||
.and_modify(|e| {
|
||||
e.1 = Some(i * 3 + k);
|
||||
e.2 += 1;
|
||||
})
|
||||
.or_insert((i * 3 + k, None, 1));
|
||||
}
|
||||
}
|
||||
fn root(parent: &mut [usize], mut k: usize) -> usize {
|
||||
while parent[k] != k {
|
||||
parent[k] = parent[parent[k]];
|
||||
k = parent[k];
|
||||
}
|
||||
k
|
||||
}
|
||||
let cosine = angle.cos();
|
||||
for (_, (first, second, count)) in edges {
|
||||
let Some(second) = second.filter(|_| count == 2) else { continue };
|
||||
let (a, b) = (first / 3, second / 3);
|
||||
if normals[a].dot(normals[b]) < cosine || groups.is_some_and(|g| g[a] == 0 || g[a] != g[b]) {
|
||||
continue;
|
||||
}
|
||||
let (ak, bk) = (first % 3, second % 3);
|
||||
// Consistent winding is required; do not smooth across an ambiguous join.
|
||||
if canonical[self.triangles[a][ak] as usize] != canonical[self.triangles[b][(bk + 1) % 3] as usize]
|
||||
|| canonical[self.triangles[a][(ak + 1) % 3] as usize] != canonical[self.triangles[b][bk] as usize]
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (x, y) in [(first, b * 3 + (bk + 1) % 3), (a * 3 + (ak + 1) % 3, second)] {
|
||||
let (x, y) = (root(&mut parent, x), root(&mut parent, y));
|
||||
parent[y] = x;
|
||||
}
|
||||
}
|
||||
let mut sums = Vec::new();
|
||||
sums.try_reserve_exact(corners).map_err(|_| "could not reserve mesh smoothing sums")?;
|
||||
sums.resize(corners, Vec3::ZERO);
|
||||
for (i, t) in self.triangles.iter().enumerate() {
|
||||
if normals[i] == Vec3::ZERO {
|
||||
continue;
|
||||
}
|
||||
for k in 0..3 {
|
||||
let p = self.positions[t[k] as usize];
|
||||
let a = (self.positions[t[(k + 1) % 3] as usize] - p).normalized().ok_or("degenerate smoothing corner")?;
|
||||
let b = (self.positions[t[(k + 2) % 3] as usize] - p).normalized().ok_or("degenerate smoothing corner")?;
|
||||
let weight = a.dot(b).clamp(-1.0, 1.0).acos();
|
||||
let r = root(&mut parent, i * 3 + k);
|
||||
sums[r] += normals[i] * weight;
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
out.try_reserve_exact(self.triangles.len()).map_err(|_| "could not reserve mesh shading result")?;
|
||||
for (i, n) in normals.into_iter().enumerate() {
|
||||
if n == Vec3::ZERO {
|
||||
out.push([Vec3::Z; 3]);
|
||||
continue;
|
||||
}
|
||||
out.push(std::array::from_fn(|k| {
|
||||
let r = root(&mut parent, i * 3 + k);
|
||||
sums[r].normalized().filter(|v| v.dot(n) > 0.0).unwrap_or(n)
|
||||
}));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`MassProperties`] of a closed mesh, by the divergence theorem over the tetrahedra its
|
||||
/// triangles make with the origin. `None` unless every edge (by vertex index: weld first) is
|
||||
/// shared by exactly two triangles that cross it in opposite directions (closed, consistently
|
||||
|
||||
@@ -21,6 +21,70 @@ fn cube_mesh(s: f64) -> Mesh {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crease_normals_respect_angles_groups_and_duplicate_vertex_seams() {
|
||||
let theta = 30f64.to_radians();
|
||||
let mesh = Mesh {
|
||||
positions: vec![Vec3::ZERO, Vec3::X, Vec3::Y, Vec3::new(0., -theta.cos(), theta.sin())],
|
||||
triangles: vec![[0, 1, 2], [1, 0, 3]],
|
||||
..Default::default()
|
||||
};
|
||||
let flat = mesh.crease_corner_normals(20f64.to_radians(), None).unwrap();
|
||||
assert!((flat[0][0] - Vec3::Z).len() < 1e-12);
|
||||
assert!((flat[1][1] - Vec3::new(0., theta.sin(), theta.cos())).len() < 1e-12);
|
||||
let smooth = mesh.crease_corner_normals(40f64.to_radians(), None).unwrap();
|
||||
let expected = Vec3::new(0., (theta / 2.).sin(), (theta / 2.).cos());
|
||||
assert!((smooth[0][0] - expected).len() < 1e-12);
|
||||
assert_eq!(smooth[0][0], smooth[1][1]);
|
||||
assert_eq!(mesh.crease_corner_normals(1., Some(&[1, 2])).unwrap(), flat);
|
||||
assert_eq!(mesh.crease_corner_normals(1., Some(&[0, 0])).unwrap(), flat);
|
||||
assert_eq!(mesh.crease_corner_normals(1., Some(&[7, 7])).unwrap(), smooth);
|
||||
let split = Mesh {
|
||||
positions: mesh.triangles.iter().flatten().map(|&i| mesh.positions[i as usize]).collect(),
|
||||
triangles: vec![[0, 1, 2], [3, 4, 5]],
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(split.crease_corner_normals(1., None).unwrap(), smooth);
|
||||
assert!(mesh.crease_corner_normals(f64::NAN, None).is_err());
|
||||
assert!(mesh.crease_corner_normals(1., Some(&[1])).is_err());
|
||||
let cube = cube_mesh(1.);
|
||||
let normals = cube.crease_corner_normals(45f64.to_radians(), None).unwrap();
|
||||
for (t, n) in cube.triangles.iter().zip(normals) {
|
||||
let [a, b, c] = t.map(|i| cube.positions[i as usize]);
|
||||
let face = (b - a).cross(c - a).normalized().unwrap();
|
||||
for n in n {
|
||||
assert!((n - face).len() < 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crease_normals_skip_degenerate_faces_without_breaking_valid_fans() {
|
||||
let theta = 30f64.to_radians();
|
||||
let mut mesh = Mesh {
|
||||
positions: vec![Vec3::ZERO, Vec3::X, Vec3::Y, Vec3::new(0., -theta.cos(), theta.sin())],
|
||||
triangles: vec![[0, 1, 2], [1, 0, 3]],
|
||||
..Default::default()
|
||||
};
|
||||
let expected = mesh.crease_corner_normals(40f64.to_radians(), None).unwrap();
|
||||
// Repeated indices, coincident vertices and distinct collinear vertices all
|
||||
// touch the healthy pair's shared edge. None may break that smoothing fan.
|
||||
mesh.positions.extend([Vec3::X, Vec3::X * 2.]);
|
||||
mesh.triangles.extend([[0, 1, 1], [0, 1, 4], [0, 1, 5]]);
|
||||
let groups = vec![7; mesh.triangles.len()];
|
||||
for groups in [None, Some(groups.as_slice())] {
|
||||
let normals = mesh.crease_corner_normals(40f64.to_radians(), groups).unwrap();
|
||||
assert_eq!(&normals[..2], expected.as_slice());
|
||||
assert_eq!(&normals[2..], &[[Vec3::Z; 3]; 3]);
|
||||
assert!(normals.iter().flatten().all(|n| n.is_finite() && (n.len() - 1.).abs() < 1e-12));
|
||||
}
|
||||
mesh.triangles.push([0, 1, u32::MAX]);
|
||||
assert!(mesh.crease_corner_normals(1., None).unwrap_err().contains("index is missing"));
|
||||
mesh.triangles.pop();
|
||||
mesh.positions[0] = Vec3::new(f64::INFINITY, 0., 0.);
|
||||
assert!(mesh.crease_corner_normals(1., None).unwrap_err().contains("non-finite"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_edge_masks_keep_identity_and_break_only_certified_sharp_patches() {
|
||||
let mut mesh =
|
||||
|
||||
@@ -30,9 +30,8 @@ pub(super) fn preview_divisor(options: &JobOptions, completed: u32, budget: Opti
|
||||
// The shader divides a finished pixel exactly once, then leaves that mean alone.
|
||||
Ok(if production < target { production as f32 } else { 1.0 })
|
||||
}
|
||||
pub(super) fn preview_paths(task: &Task, budgets: Option<&[u8]>) -> Result<u64> {
|
||||
pub(super) fn preview_paths(task: &Task, pixels: u64, budgets: Option<&[u8]>) -> Result<u64> {
|
||||
let options = task.options.adaptive.ok_or_else(|| internal("missing adaptive preview options"))?;
|
||||
let pixels = u64::from(task.request.width) * u64::from(task.request.height);
|
||||
if task.sample <= options.pilot_samples {
|
||||
return Ok(pixels * u64::from(task.sample));
|
||||
}
|
||||
@@ -62,44 +61,38 @@ pub(super) fn report(task: &Task) -> Result<SamplingReport> {
|
||||
transmission_strata: task.options.transmission_samples,
|
||||
};
|
||||
let Some(options) = task.options.adaptive else { return Ok(report) };
|
||||
let bytes = pixels * 16;
|
||||
let context = &task.context;
|
||||
let scopes = scopes(&context.device);
|
||||
let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("adaptive budget report") });
|
||||
encoder.copy_buffer_to_buffer(&task.accumulation, bytes * 2, &task.readback, 0, bytes);
|
||||
let submission = context.queue.submit([encoder.finish()]);
|
||||
checked(scopes)?;
|
||||
let slice = task.readback.slice(0..bytes);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |result| {
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
context.wait(submission, TIMEOUT)?;
|
||||
rx.try_recv().map_err(gpu)?.map_err(gpu)?;
|
||||
let result = (|| {
|
||||
let data = slice.get_mapped_range().map_err(gpu)?;
|
||||
report.maximum_production_samples = 0;
|
||||
report.production_samples = 0;
|
||||
report.pixels_at_cap = 0;
|
||||
for (index, record) in data.as_chunks::<16>().0.iter().enumerate() {
|
||||
if index % 4096 == 0 {
|
||||
task.cancel.check()?;
|
||||
}
|
||||
let value = f32::from_le_bytes([record[0], record[1], record[2], record[3]]);
|
||||
if !value.is_finite() || value.fract() != 0.0 || value < options.min_samples as f32 || value > task.options.samples as f32 {
|
||||
return Err(JobError::new(ErrorCode::Internal, "invalid GPU adaptive production count"));
|
||||
}
|
||||
let count = value as u32;
|
||||
report.minimum_production_samples = report.minimum_production_samples.min(count);
|
||||
report.maximum_production_samples = report.maximum_production_samples.max(count);
|
||||
report.production_samples += u64::from(count);
|
||||
report.pixels_at_cap += u64::from(count == task.options.samples);
|
||||
}
|
||||
report.primary_path_evaluations =
|
||||
pixels * u64::from(report.pilot_samples_per_pixel) + report.production_samples * u64::from(report.transmission_strata);
|
||||
task.cancel.check()?;
|
||||
Ok(report)
|
||||
})();
|
||||
task.readback.unmap();
|
||||
result
|
||||
report.maximum_production_samples = 0;
|
||||
report.production_samples = 0;
|
||||
report.pixels_at_cap = 0;
|
||||
for page in &task.pages {
|
||||
let bytes = u64::from(page.pixels) * 16;
|
||||
task.readback_data(
|
||||
bytes,
|
||||
|encoder| {
|
||||
encoder.copy_buffer_to_buffer(&page.accumulation, bytes * 2, &task.readback, 0, bytes);
|
||||
Ok(())
|
||||
},
|
||||
|data| {
|
||||
for (index, record) in data.as_chunks::<16>().0.iter().enumerate() {
|
||||
if index % 4096 == 0 {
|
||||
task.cancel.check()?;
|
||||
}
|
||||
let value = f32::from_le_bytes([record[0], record[1], record[2], record[3]]);
|
||||
if !value.is_finite() || value.fract() != 0.0 || value < options.min_samples as f32 || value > task.options.samples as f32 {
|
||||
return Err(JobError::new(ErrorCode::Internal, "invalid GPU adaptive production count"));
|
||||
}
|
||||
let count = value as u32;
|
||||
report.minimum_production_samples = report.minimum_production_samples.min(count);
|
||||
report.maximum_production_samples = report.maximum_production_samples.max(count);
|
||||
report.production_samples += u64::from(count);
|
||||
report.pixels_at_cap += u64::from(count == task.options.samples);
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
}
|
||||
report.primary_path_evaluations =
|
||||
pixels * u64::from(report.pilot_samples_per_pixel) + report.production_samples * u64::from(report.transmission_strata);
|
||||
task.cancel.check()?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
+275
-28
@@ -1,8 +1,11 @@
|
||||
//! Optional output filtering; never writes the Monte Carlo accumulation buffer.
|
||||
use super::{Context, Result, SHADER, TIMEOUT, Task, checked, placeholder_bind, scopes, warm_up};
|
||||
use solidcraft_render::job::{DenoiseDiagnostics, DenoiseGuideClass, resolve_denoise_class};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
pub(super) const BYTES_PER_PIXEL: u64 = 80; // three guide vec4s and two colour vec4s
|
||||
pub(super) const GUIDE_BYTES_PER_PIXEL: u64 = 96; // two complete surface guides
|
||||
pub(super) const BYTES_PER_PIXEL: u64 = GUIDE_BYTES_PER_PIXEL + 32; // two colour vec4s
|
||||
const FILTER: &str = include_str!("denoise.wgsl");
|
||||
const GUIDES: &str = include_str!("denoise_guides.wgsl");
|
||||
#[cfg(test)]
|
||||
@@ -14,6 +17,7 @@ mod tests;
|
||||
pub(super) struct Pipelines {
|
||||
guide: wgpu::ComputePipeline,
|
||||
filter: wgpu::ComputePipeline,
|
||||
classify: wgpu::ComputePipeline,
|
||||
filter_layout: wgpu::BindGroupLayout,
|
||||
}
|
||||
|
||||
@@ -66,10 +70,19 @@ impl Pipelines {
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let classify = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("denoise guide classes"),
|
||||
layout: Some(&layout),
|
||||
module: &shader,
|
||||
entry_point: Some("guide_classes"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
checked(error_scopes)?;
|
||||
warm_up(device, &context.queue, &guide, &placeholder_bind(device, &context.layout, 7)?, &context.healthy)?;
|
||||
warm_up(device, &context.queue, &filter, &placeholder_bind(device, &filter_layout, 4)?, &context.healthy)?;
|
||||
Ok(Self { guide, filter, filter_layout })
|
||||
warm_up(device, &context.queue, &classify, &placeholder_bind(device, &filter_layout, 4)?, &context.healthy)?;
|
||||
Ok(Self { guide, filter, classify, filter_layout })
|
||||
}
|
||||
|
||||
fn shared(context: &Context) -> Result<&Self> {
|
||||
@@ -83,17 +96,30 @@ impl Pipelines {
|
||||
|
||||
pub(super) struct Resources {
|
||||
adaptive: Option<AdaptiveInput>,
|
||||
layout: Option<super::image_pages::Layout>,
|
||||
input: Option<wgpu::Buffer>,
|
||||
guides_ready: AtomicBool,
|
||||
guide_pipeline: wgpu::ComputePipeline,
|
||||
guide_bind: wgpu::BindGroup,
|
||||
filter_pipeline: wgpu::ComputePipeline,
|
||||
classify_pipeline: wgpu::ComputePipeline,
|
||||
filter_uniform: wgpu::Buffer,
|
||||
filter_binds: Vec<wgpu::BindGroup>,
|
||||
output: wgpu::Buffer,
|
||||
temporary: wgpu::Buffer,
|
||||
// Retained for diagnostic tests; bind groups also retain the guide allocation.
|
||||
_guides: wgpu::Buffer,
|
||||
}
|
||||
|
||||
type Window = super::image_pages::Window;
|
||||
|
||||
fn full_window(task: &Task) -> Window {
|
||||
let pixels = task.request.width * task.request.height;
|
||||
Window { start: 0, pixels, core_start: 0, core_pixels: pixels }
|
||||
}
|
||||
|
||||
impl Resources {
|
||||
#[cfg(test)]
|
||||
pub(super) fn new(
|
||||
context: &Context,
|
||||
path_uniform: &wgpu::Buffer,
|
||||
@@ -101,6 +127,29 @@ impl Resources {
|
||||
accumulation: &wgpu::Buffer,
|
||||
size: u64,
|
||||
adaptive_preview: bool,
|
||||
) -> Result<Self> {
|
||||
Self::build(context, path_uniform, scene, accumulation, size, adaptive_preview, None)
|
||||
}
|
||||
|
||||
pub(super) fn new_for_layout(
|
||||
context: &Context,
|
||||
path_uniform: &wgpu::Buffer,
|
||||
scene: [&wgpu::Buffer; 5],
|
||||
accumulation: &wgpu::Buffer,
|
||||
adaptive_preview: bool,
|
||||
layout: super::image_pages::Layout,
|
||||
) -> Result<Self> {
|
||||
Self::build(context, path_uniform, scene, accumulation, u64::from(layout.max_window_pixels()) * 16, adaptive_preview, Some(layout))
|
||||
}
|
||||
|
||||
fn build(
|
||||
context: &Context,
|
||||
path_uniform: &wgpu::Buffer,
|
||||
scene: [&wgpu::Buffer; 5],
|
||||
accumulation: &wgpu::Buffer,
|
||||
size: u64,
|
||||
adaptive_preview: bool,
|
||||
layout: Option<super::image_pages::Layout>,
|
||||
) -> Result<Self> {
|
||||
let pipelines = Pipelines::shared(context)?;
|
||||
let device = &context.device;
|
||||
@@ -113,9 +162,11 @@ impl Resources {
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
let guides = buffer("denoise first-hit guides", size * 3);
|
||||
let output = buffer("denoise filtered colour", size);
|
||||
let temporary = buffer("denoise intermediate colour", size);
|
||||
let input = layout.filter(|layout| layout.is_paged()).map(|layout| buffer("denoise gathered film window", size * u64::from(layout.planes)));
|
||||
let accumulation = input.as_ref().unwrap_or(accumulation);
|
||||
let guides = buffer("denoise first-hit guide window", size * (GUIDE_BYTES_PER_PIXEL / 16));
|
||||
let output = buffer("denoise filtered colour window", size);
|
||||
let temporary = buffer("denoise intermediate colour window", size);
|
||||
let [triangles, nodes, environment, textures, failures] = scene;
|
||||
let entries: Vec<_> = [path_uniform, triangles, nodes, &guides, environment, textures, failures]
|
||||
.into_iter()
|
||||
@@ -126,7 +177,7 @@ impl Resources {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("denoise guides"), layout: &context.layout, entries: &entries });
|
||||
let filter_uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("denoise parameters"),
|
||||
contents: &[0; 32],
|
||||
contents: &[0; 48],
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let filter_binds = [(accumulation, &output), (&output, &temporary), (&temporary, &output)]
|
||||
@@ -148,37 +199,81 @@ impl Resources {
|
||||
checked(error_scopes)?;
|
||||
Ok(Self {
|
||||
adaptive,
|
||||
layout,
|
||||
input,
|
||||
guides_ready: AtomicBool::new(false),
|
||||
guide_pipeline: pipelines.guide.clone(),
|
||||
guide_bind,
|
||||
filter_pipeline: pipelines.filter.clone(),
|
||||
classify_pipeline: pipelines.classify.clone(),
|
||||
filter_uniform,
|
||||
filter_binds,
|
||||
output,
|
||||
temporary,
|
||||
_guides: guides,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn output(&self) -> &wgpu::Buffer {
|
||||
&self.output
|
||||
pub(super) fn output(&self, passes: u32) -> &wgpu::Buffer {
|
||||
if passes == 2 { &self.temporary } else { &self.output }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn run(&self, task: &Task) -> Result<()> {
|
||||
self.run_with_input(task, false)
|
||||
self.prepare_window(task, full_window(task), false)?;
|
||||
self.run_filter(task, false)
|
||||
}
|
||||
pub(super) fn run_adaptive_preview(&self, task: &Task) -> Result<()> {
|
||||
self.adaptive.as_ref().ok_or_else(|| super::gpu("missing adaptive denoise preview input"))?.normalize(task)?;
|
||||
self.run_with_input(task, true)
|
||||
|
||||
fn prepare_window(&self, task: &Task, window: Window, preview: bool) -> Result<()> {
|
||||
if let Some(input) = &self.input {
|
||||
let context = &task.context;
|
||||
context.ensure_available()?;
|
||||
let submission = super::checked_creation(&context.device, || {
|
||||
let mut encoder =
|
||||
context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("denoise film window gather") });
|
||||
let planes = if preview { task.page_layout.planes } else { 1 };
|
||||
for plane in 0..planes {
|
||||
task.copy_film_range(&mut encoder, window.start, window.pixels, plane, input, u64::from(plane) * u64::from(window.pixels) * 16)?;
|
||||
}
|
||||
Ok(context.queue.submit([encoder.finish()]))
|
||||
})?;
|
||||
context.wait(submission, TIMEOUT)?;
|
||||
}
|
||||
// The immutable scene permits caching only when the entire guide image
|
||||
// is resident. A paged job reuses this allocation for every window.
|
||||
if self.input.is_some() || !self.guides_ready.load(Ordering::Relaxed) {
|
||||
self.generate_guides_window(task, window)?;
|
||||
self.guides_ready.store(true, Ordering::Relaxed);
|
||||
}
|
||||
if preview {
|
||||
self.adaptive.as_ref().ok_or_else(|| super::gpu("missing adaptive denoise preview input"))?.normalize_window(task, window)?;
|
||||
}
|
||||
task.cancel.check()
|
||||
}
|
||||
fn run_with_input(&self, task: &Task, preview: bool) -> Result<()> {
|
||||
let pixels = task.request.width * task.request.height;
|
||||
|
||||
#[cfg(test)]
|
||||
fn generate_guides(&self, task: &Task) -> Result<()> {
|
||||
self.generate_guides_window(task, full_window(task))
|
||||
}
|
||||
|
||||
fn generate_guides_window(&self, task: &Task, window: Window) -> Result<()> {
|
||||
// Camera dimensions, footprints and random keys use global pixels.
|
||||
// Only guide storage uses the current window's local index.
|
||||
let guide_flags = u32::from(task.photons.is_some()) | (u32::from(task.optical_resolve.is_some()) << 1);
|
||||
let mut budget = super::dispatch::Budget::default();
|
||||
let mut offset = 0;
|
||||
while offset < pixels {
|
||||
while offset < window.pixels {
|
||||
task.cancel.check()?;
|
||||
let count = budget.count(pixels - offset);
|
||||
let count = budget.count(window.pixels - offset);
|
||||
let mut parameters = task.parameters(count)?;
|
||||
if let Some(bytes) = parameters.get_mut(12..16) {
|
||||
bytes.copy_from_slice(&offset.to_le_bytes());
|
||||
let global = window.start + offset;
|
||||
for (start, word) in
|
||||
[(12, global), (28, guide_flags), (super::PARAMETER_BYTES - 16, window.start), (super::PARAMETER_BYTES - 12, window.pixels)]
|
||||
{
|
||||
parameters
|
||||
.get_mut(start..start + 4)
|
||||
.ok_or_else(|| super::gpu("missing denoise window parameters"))?
|
||||
.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
task.context.queue.write_buffer(&task.uniform, 0, ¶meters);
|
||||
let started = std::time::Instant::now();
|
||||
@@ -186,12 +281,151 @@ impl Resources {
|
||||
budget.observe(count, started.elapsed());
|
||||
offset += count;
|
||||
}
|
||||
self.run_filter(task, preview)
|
||||
task.cancel.check()
|
||||
}
|
||||
|
||||
/// Assemble only each core after filtering its complete dependency halo.
|
||||
/// The source film and every pixel/sample identity remain unchanged.
|
||||
pub(super) fn read_filtered(&self, task: &Task, preview: bool) -> Result<Vec<[f32; 4]>> {
|
||||
let Some(layout) = self.layout.filter(|layout| layout.is_paged()) else {
|
||||
let window = full_window(task);
|
||||
self.prepare_window(task, window, preview)?;
|
||||
self.run_filter_window(task, preview, window)?;
|
||||
return task.read_linear_buffer(self.output(task.options.denoise_settings.passes));
|
||||
};
|
||||
let mut values = super::scene::reserved((task.request.width * task.request.height) as usize)?;
|
||||
let source = self.output(task.options.denoise_settings.passes);
|
||||
for window in layout.windows() {
|
||||
self.prepare_window(task, window, preview)?;
|
||||
self.run_filter_window(task, preview, window)?;
|
||||
let bytes = u64::from(window.core_pixels) * 16;
|
||||
task.readback_data(
|
||||
bytes,
|
||||
|encoder| {
|
||||
encoder.copy_buffer_to_buffer(source, u64::from(window.core_start - window.start) * 16, &task.readback, 0, bytes);
|
||||
Ok(())
|
||||
},
|
||||
|data| {
|
||||
for (index, pixel) in data.as_chunks::<16>().0.iter().enumerate() {
|
||||
if index % 4096 == 0 {
|
||||
task.cancel.check()?;
|
||||
}
|
||||
let mut value = [0.0; 4];
|
||||
for (value, bytes) in value.iter_mut().zip(pixel.as_chunks::<4>().0) {
|
||||
*value = f32::from_le_bytes(*bytes) / task.normalization_samples() as f32;
|
||||
if !value.is_finite() || *value < 0.0 {
|
||||
return Err(super::gpu("nonfinite or negative filtered path radiance"));
|
||||
}
|
||||
}
|
||||
values.push(value);
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
}
|
||||
if values.len() != (task.request.width * task.request.height) as usize {
|
||||
return Err(super::gpu("denoise windows do not cover the complete trace grid"));
|
||||
}
|
||||
task.validate_failures(None)?;
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
/// Each core contributes once; filter halos never enter the category counts
|
||||
/// or the conservative X2 category resolve.
|
||||
pub(super) fn diagnostics(&self, task: &Task) -> Result<(DenoiseDiagnostics, Vec<u8>)> {
|
||||
let output = &task.output_request;
|
||||
let capacity =
|
||||
usize::try_from(u64::from(output.width) * u64::from(output.height) * 4).map_err(|_| super::gpu("denoise mask exceeds address space"))?;
|
||||
let mut rgba = super::scene::reserved(capacity)?;
|
||||
let mut report = DenoiseDiagnostics::new(task.request.width, task.request.height);
|
||||
if let Some(layout) = self.layout.filter(|layout| layout.is_paged()) {
|
||||
for window in layout.windows() {
|
||||
self.diagnostic_window(task, window, &mut report, &mut rgba)?;
|
||||
}
|
||||
} else {
|
||||
self.diagnostic_window(task, full_window(task), &mut report, &mut rgba)?;
|
||||
}
|
||||
if rgba.len() != capacity {
|
||||
return Err(super::gpu("denoise diagnostic windows do not cover the final grid"));
|
||||
}
|
||||
task.cancel.check()?;
|
||||
Ok((report, rgba))
|
||||
}
|
||||
|
||||
fn diagnostic_window(&self, task: &Task, window: Window, report: &mut DenoiseDiagnostics, rgba: &mut Vec<u8>) -> Result<()> {
|
||||
self.prepare_window(task, window, false)?;
|
||||
let bind = self.filter_binds.first().ok_or_else(|| super::gpu("missing diagnostic bind group"))?;
|
||||
let mut budget = super::dispatch::Budget::default();
|
||||
let mut offset = 0;
|
||||
while offset < window.pixels {
|
||||
task.cancel.check()?;
|
||||
let count = budget.count(window.pixels - offset);
|
||||
let mut bytes = Vec::with_capacity(48);
|
||||
for word in
|
||||
[task.request.width, task.request.height, 0, offset, count, task.normalization_samples(), 0, 0, window.start, window.pixels, 0, 0]
|
||||
{
|
||||
bytes.extend_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
task.context.queue.write_buffer(&self.filter_uniform, 0, &bytes);
|
||||
let started = std::time::Instant::now();
|
||||
dispatch(&task.context, &self.classify_pipeline, bind, count)?;
|
||||
budget.observe(count, started.elapsed());
|
||||
offset += count;
|
||||
}
|
||||
let scale = task.options.supersampling as usize;
|
||||
let width = task.request.width as usize;
|
||||
let first_row = window.core_start as usize / width;
|
||||
let core_rows = window.core_pixels as usize / width;
|
||||
if !(1..=2).contains(&scale) || !first_row.is_multiple_of(scale) || !core_rows.is_multiple_of(scale) {
|
||||
return Err(super::gpu("diagnostic window does not own complete supersampling cells"));
|
||||
}
|
||||
let byte_count = u64::from(window.core_pixels) * 16;
|
||||
task.readback_data(
|
||||
byte_count,
|
||||
|encoder| {
|
||||
encoder.copy_buffer_to_buffer(&self.output, u64::from(window.core_start - window.start) * 16, &task.readback, 0, byte_count);
|
||||
Ok(())
|
||||
},
|
||||
|data| {
|
||||
for y in 0..core_rows / scale {
|
||||
task.cancel.check()?;
|
||||
for x in 0..task.output_request.width as usize {
|
||||
let mut classes = [DenoiseGuideClass::Unavailable; 4];
|
||||
for dy in 0..scale {
|
||||
for dx in 0..scale {
|
||||
let pixel = (y * scale + dy) * width + x * scale + dx;
|
||||
let bytes: [u8; 4] = data
|
||||
.get(pixel * 16..pixel * 16 + 4)
|
||||
.and_then(|bytes| bytes.try_into().ok())
|
||||
.ok_or_else(|| super::gpu("missing denoise guide classification"))?;
|
||||
let code = f32::from_le_bytes(bytes);
|
||||
if !code.is_finite() || code < 0.0 || code.fract() != 0.0 {
|
||||
return Err(super::gpu("invalid denoise guide classification"));
|
||||
}
|
||||
let class =
|
||||
DenoiseGuideClass::from_code(code as u32).ok_or_else(|| super::gpu("unknown denoise guide classification"))?;
|
||||
report.record(class);
|
||||
*classes.get_mut(dy * scale + dx).ok_or_else(|| super::gpu("invalid diagnostic supersample scale"))? = class;
|
||||
}
|
||||
}
|
||||
let class = resolve_denoise_class(classes.into_iter().take(scale * scale));
|
||||
rgba.extend_from_slice(&class.color());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn run_filter(&self, task: &Task, preview: bool) -> Result<()> {
|
||||
let pixels = task.request.width * task.request.height;
|
||||
for (pass, bind) in self.filter_binds.iter().enumerate() {
|
||||
self.run_filter_window(task, preview, full_window(task))
|
||||
}
|
||||
|
||||
fn run_filter_window(&self, task: &Task, preview: bool, window: Window) -> Result<()> {
|
||||
let settings = task.options.denoise_settings;
|
||||
settings.validate()?;
|
||||
for (pass, bind) in self.filter_binds.iter().take(settings.passes as usize).enumerate() {
|
||||
let bind = if preview && pass == 0 {
|
||||
&self.adaptive.as_ref().ok_or_else(|| super::gpu("missing adaptive denoise preview input"))?.filter_bind
|
||||
} else {
|
||||
@@ -199,11 +433,24 @@ impl Resources {
|
||||
};
|
||||
let mut budget = super::dispatch::Budget::default();
|
||||
let mut offset = 0;
|
||||
while offset < pixels {
|
||||
while offset < window.pixels {
|
||||
task.cancel.check()?;
|
||||
let count = budget.count(pixels - offset);
|
||||
let mut bytes = Vec::with_capacity(32);
|
||||
for word in [task.request.width, task.request.height, 1 << pass, offset, count, task.normalization_samples(), 0, 0] {
|
||||
let count = budget.count(window.pixels - offset);
|
||||
let mut bytes = Vec::with_capacity(48);
|
||||
for word in [
|
||||
task.request.width,
|
||||
task.request.height,
|
||||
1 << pass,
|
||||
offset,
|
||||
count,
|
||||
task.normalization_samples(),
|
||||
(settings.strength as f32).to_bits(),
|
||||
u32::from(settings.firefly_suppression),
|
||||
window.start,
|
||||
window.pixels,
|
||||
0,
|
||||
0,
|
||||
] {
|
||||
bytes.extend_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
task.context.queue.write_buffer(&self.filter_uniform, 0, &bytes);
|
||||
@@ -301,16 +548,16 @@ impl AdaptiveInput {
|
||||
});
|
||||
Self { _pixels: pixels, uniform, pipeline, bind, filter_bind }
|
||||
}
|
||||
fn normalize(&self, task: &Task) -> Result<()> {
|
||||
fn normalize_window(&self, task: &Task, window: Window) -> Result<()> {
|
||||
let options = task.options.adaptive.ok_or_else(|| super::gpu("missing adaptive preview options"))?;
|
||||
let pixels = task.request.width * task.request.height;
|
||||
let pixels = window.pixels;
|
||||
let mut budget = super::dispatch::Budget::default();
|
||||
let mut offset = 0;
|
||||
while offset < pixels {
|
||||
task.cancel.check()?;
|
||||
let count = budget.count(pixels - offset);
|
||||
let mut bytes = Vec::with_capacity(32);
|
||||
for word in [task.request.width, task.request.height, task.sample, offset, count, options.pilot_samples, 0, 0] {
|
||||
for word in [task.request.width, window.pixels / task.request.width, task.sample, offset, count, options.pilot_samples, 0, 0] {
|
||||
bytes.extend_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
task.context.queue.write_buffer(&self.uniform, 0, &bytes);
|
||||
|
||||
@@ -1,48 +1,104 @@
|
||||
// Original three-pass edge-avoiding a-trous filter inspired by Dammertz et al.
|
||||
// Original bounded edge-avoiding a-trous filter inspired by Dammertz et al.
|
||||
// Inputs/outputs keep accumulated linear premultiplied radiance and raw alpha.
|
||||
struct FilterParams { image: vec4<u32>, work: vec4<u32> };
|
||||
struct FilterParams { image: vec4<u32>, work: vec4<u32>, window: vec4<u32> };
|
||||
@group(0) @binding(0) var<uniform> f: FilterParams;
|
||||
@group(0) @binding(1) var<storage,read> guides: array<vec4<f32>>;
|
||||
@group(0) @binding(2) var<storage,read> source: array<vec4<f32>>;
|
||||
@group(0) @binding(3) var<storage,read_write> filtered: array<vec4<f32>>;
|
||||
fn filter_pixels()->u32 {return select(f.image.x*f.image.y,f.window.y,f.window.y!=0u);}
|
||||
|
||||
fn kernel(x:i32)->f32 {
|
||||
if x==0 { return 6.0; }
|
||||
if abs(x)==1 { return 4.0; }
|
||||
return 1.0;
|
||||
}
|
||||
fn max_component(v:vec3<f32>)->f32 { return max(max(v.x,v.y),v.z); }
|
||||
// Both optical branches must agree; body identity alone cannot preserve
|
||||
// material, normal or depth edges on a single reflected object.
|
||||
fn geometry_weight(a:vec4<f32>,n:vec4<f32>,p:vec4<f32>,b:vec4<f32>,m:vec4<f32>,r:vec4<f32>,step:f32)->f32 {
|
||||
if a.w!=b.w {return 0.0;}
|
||||
if a.w==0.0 {return 1.0;}
|
||||
let da=a.xyz-b.xyz; let dp=r.xyz-p.xyz;
|
||||
let plane=abs(dot(dp,n.xyz))+abs(dot(dp,m.xyz));
|
||||
let footprint=max(p.w,r.w)*step;
|
||||
let depth=abs(n.w-m.w)/max(8.0*footprint,0.000001);
|
||||
return exp(-dot(da,da)/0.04-plane/max(0.05*footprint,0.000001)-depth)*pow(max(dot(n.xyz,m.xyz),0.0),32.0);
|
||||
}
|
||||
@compute @workgroup_size(64)
|
||||
fn atrous(@builtin(global_invocation_id) gid:vec3<u32>) {
|
||||
let pixel=f.image.w+gid.x;
|
||||
if gid.x>=f.work.x || pixel>=f.image.x*f.image.y { return; }
|
||||
let c=source[pixel];
|
||||
let a=guides[pixel*3u]; let n=guides[pixel*3u+1u]; let p=guides[pixel*3u+2u];
|
||||
if a.w==0.0 || c.w<f32(f.work.y)*0.999 { filtered[pixel]=c; return; }
|
||||
let local=f.image.w+gid.x;
|
||||
let pixel=f.window.x+local;
|
||||
if gid.x>=f.work.x || local>=filter_pixels() || pixel>=f.image.x*f.image.y { return; }
|
||||
let c=source[local];
|
||||
let a=guides[local*6u]; let n=guides[local*6u+1u]; let p=guides[local*6u+2u];
|
||||
// Optical correspondence is a negative, finite tag in the same slot.
|
||||
let optical=a.w<0.0;
|
||||
if a.w==0.0 || c.w<f32(f.work.y)*0.999 || (optical && f.image.z>2u) { filtered[local]=c; return; }
|
||||
let xy=vec2<i32>(i32(pixel%f.image.x),i32(pixel/f.image.x));
|
||||
var mean=vec3<f32>(0.0); var total=0.0;
|
||||
let samples=f32(f.work.y);
|
||||
let color=c.xyz/samples;
|
||||
let center_peak=max_component(color);
|
||||
let first_pass=f.image.z==1u && !optical && f.work.w!=0u;
|
||||
var support_count=0u;
|
||||
var support_peak=0.0;
|
||||
for(var y=-2;y<=2;y+=1) { for(var x=-2;x<=2;x+=1) {
|
||||
let q=xy+vec2<i32>(x,y)*i32(f.image.z);
|
||||
if any(q<vec2<i32>(0)) || q.x>=i32(f.image.x) || q.y>=i32(f.image.y) { continue; }
|
||||
let j=u32(q.y)*f.image.x+u32(q.x);
|
||||
let b=guides[j*3u]; let m=guides[j*3u+1u]; let r=guides[j*3u+2u];
|
||||
let global=u32(q.y)*f.image.x+u32(q.x);
|
||||
if global<f.window.x || global-f.window.x>=filter_pixels() {continue;}
|
||||
let j=global-f.window.x;
|
||||
let b=guides[j*6u]; let m=guides[j*6u+1u]; let r=guides[j*6u+2u];
|
||||
let v=source[j];
|
||||
if b.w!=a.w || abs(v.w-c.w)>samples*0.01 { continue; }
|
||||
let da=a.xyz-b.xyz; let dp=r.xyz-p.xyz;
|
||||
let plane=abs(dot(dp,n.xyz))+abs(dot(dp,m.xyz));
|
||||
let scale=max(0.05*max(p.w,r.w)*f32(f.image.z),0.000001);
|
||||
let depth=abs(n.w-m.w)/max(8.0*max(p.w,r.w)*f32(f.image.z),0.000001);
|
||||
let dc=max_component(abs(color-v.xyz/samples));
|
||||
let color_scale=0.25+0.5*max(max_component(color),max_component(v.xyz/samples));
|
||||
let weight=kernel(x)*kernel(y)*exp(-dot(da,da)/0.04-plane/scale-depth-dc/color_scale)
|
||||
*pow(max(dot(n.xyz,m.xyz),0.0),32.0);
|
||||
let guide_weight=geometry_weight(a,n,p,b,m,r,f32(f.image.z))
|
||||
*geometry_weight(guides[local*6u+3u],guides[local*6u+4u],guides[local*6u+5u],
|
||||
guides[j*6u+3u],guides[j*6u+4u],guides[j*6u+5u],f32(f.image.z));
|
||||
let tap_color=v.xyz/samples;
|
||||
let tap_peak=max_component(tap_color);
|
||||
// A genuine bright feature has bright compatible neighbours. Only the
|
||||
// immediate 3x3 support may identify an isolated first-pass outlier.
|
||||
if first_pass && abs(x)<=1 && abs(y)<=1 && (x!=0 || y!=0) && guide_weight>=0.5 {
|
||||
support_count+=1u;
|
||||
support_peak=max(support_peak,tap_peak);
|
||||
}
|
||||
let high=max(center_peak,tap_peak);
|
||||
let bound=16.0*(0.25+min(center_peak,tap_peak));
|
||||
var contrast=1.0;
|
||||
if f.work.w!=0u && high>bound {
|
||||
let ratio=bound/high;
|
||||
contrast=ratio*ratio;
|
||||
}
|
||||
let dc=max_component(abs(color-tap_color));
|
||||
let color_scale=0.25+0.5*high;
|
||||
let weight=kernel(x)*kernel(y)*guide_weight*exp(-select(1.0,4.0,optical)*dc/color_scale)*contrast;
|
||||
if weight>0.0 {
|
||||
total+=weight;
|
||||
// Online convex averaging avoids overflow of radiance times kernel.
|
||||
mean+=(v.xyz-mean)*(weight/total);
|
||||
}
|
||||
}}
|
||||
if first_pass && support_count>=5u && center_peak>16.0*(0.25+support_peak) {
|
||||
let limit=2.0*support_peak+0.25;
|
||||
let mean_peak=max_component(mean/samples);
|
||||
if mean_peak>limit { mean*=limit/mean_peak; }
|
||||
}
|
||||
// The centre always contributes; retain original coverage without filtering.
|
||||
filtered[pixel]=vec4<f32>(mean,c.w);
|
||||
filtered[local]=vec4<f32>(mix(c.xyz,mean,bitcast<f32>(f.work.z)),c.w);
|
||||
}
|
||||
|
||||
// Stable DenoiseGuideClass codes. This diagnostic writes only the filter scratch
|
||||
// output, never accumulation or guide data. Coverage matches the filter gate.
|
||||
@compute @workgroup_size(64)
|
||||
fn guide_classes(@builtin(global_invocation_id) gid:vec3<u32>) {
|
||||
let local=f.image.w+gid.x;
|
||||
let pixel=f.window.x+local;
|
||||
if gid.x>=f.work.x || local>=filter_pixels() || pixel>=f.image.x*f.image.y {return;}
|
||||
let guide=guides[local*6u];
|
||||
var reason=u32(guide.x);
|
||||
if guide.w!=0.0 {
|
||||
reason=select(1u,2u,guide.w<0.0);
|
||||
if source[local].w<f32(f.work.y)*0.999 {reason=16u;}
|
||||
}
|
||||
filtered[local]=vec4<f32>(f32(reason),0.0,0.0,1.0);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,216 @@
|
||||
// Original deterministic first-hit guides. Uses the production intersection and
|
||||
// material/normal texture evaluation, without advancing the path random stream.
|
||||
// Deterministic display guides use the production intersections and resolved
|
||||
// appearance without changing the path random stream or failure counters.
|
||||
struct GuideLook {
|
||||
albedo: vec3<f32>, normal: vec3<f32>, metallic: f32, roughness: f32,
|
||||
};
|
||||
struct GuideRay {
|
||||
point: PrecisePoint, direction: vec3<f32>, excluded: u32, minimum: f32,
|
||||
};
|
||||
struct GuideInterface {
|
||||
reflected: vec3<f32>, transmitted: vec3<f32>, fresnel: vec3<f32>,
|
||||
};
|
||||
const GUIDE_MISS: u32 = 0xffffffffu;
|
||||
// Zero body tags bypass filtering. Store the exact early-return reason in the
|
||||
// otherwise-unused albedo lane; positive/negative tags retain their old layout.
|
||||
fn guide_bypass(base:u32,reason:u32) {
|
||||
accumulation[base]=vec4<f32>(f32(reason),0.0,0.0,0.0);
|
||||
}
|
||||
|
||||
fn guide_outward(t:Triangle)->vec3<f32> {
|
||||
return unit(cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz));
|
||||
}
|
||||
fn guide_normal(t:Triangle,bary:vec3<f32>,geometric:vec3<f32>,direction:vec3<f32>)->vec3<f32> {
|
||||
let interpolated=unit(t.n0.xyz*bary.x+t.n1.xyz*bary.y+t.n2.xyz*bary.z);
|
||||
let winding=select(interpolated,-interpolated,dot(interpolated,geometric)<0.0);
|
||||
return select(geometric,winding,dot(winding,-direction)>0.0);
|
||||
}
|
||||
fn guide_look(t:Triangle,hit:Hit,direction:vec3<f32>,position:vec3<f32>,travel:f32,bounce:u32)->GuideLook {
|
||||
let outward=guide_outward(t);
|
||||
let geometric=select(-outward,outward,dot(outward,-direction)>=0.0);
|
||||
let record=texture_record(t.optics.w);
|
||||
let gradients=texture_gradients(record,geometric,direction,hit.distance,travel,bounce);
|
||||
let mapped=texture_surface(record,t.base.xyz,t.parameters.x,t.parameters.y,position,gradients,(u32(t.interior.z)&2u)!=0u);
|
||||
var look=t;
|
||||
look.base=vec4<f32>(mapped.color,t.base.w);
|
||||
look.parameters=vec4<f32>(mapped.metallic,mapped.roughness,t.parameters.zw);
|
||||
look=aged_surface(look,position,outward);
|
||||
let normal=guide_normal(t,hit.bary,geometric,direction);
|
||||
let perturbed=texture_normal(record,mapped.normal,normal,geometric);
|
||||
return GuideLook(look.base.xyz,select(normal,perturbed,dot(perturbed,-direction)>0.0),look.parameters.x,look.parameters.y);
|
||||
}
|
||||
fn guide_sharp(t:Triangle,look:GuideLook)->bool {
|
||||
return (look.metallic>0.5 && look.roughness<0.25) || (t.coat.x>0.0 && t.coat.y<0.05);
|
||||
}
|
||||
fn guide_depth(position:vec3<f32>)->f32 {return dot(position-u.eye.xyz,-u.back.xyz);}
|
||||
fn guide_footprint(depth:f32)->f32 {
|
||||
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;}
|
||||
return max(footprint,0.000001);
|
||||
}
|
||||
fn guide_write(base:u32,id:f32,look:GuideLook,position:vec3<f32>,primary_footprint:f32) {
|
||||
let depth=guide_depth(position);
|
||||
accumulation[base]=vec4<f32>(look.albedo,id);
|
||||
accumulation[base+1u]=vec4<f32>(look.normal,depth);
|
||||
accumulation[base+2u]=vec4<f32>(position,max(primary_footprint,guide_footprint(depth)));
|
||||
}
|
||||
fn guide_vacuum_origin(ray:CameraRay)->bool {
|
||||
if u.lighting.w==0.0 {return true;}
|
||||
let boundary=origin_boundary(ray.origin,ray.direction);
|
||||
if !boundary.valid {return false;}
|
||||
if boundary.hit.triangle==GUIDE_MISS {return true;}
|
||||
let t=triangle(boundary.hit.triangle);
|
||||
return t.interior.w==0.0 && dot(guide_outward(t),ray.direction)<0.0;
|
||||
}
|
||||
fn guide_smooth_glass(t:Triangle)->bool {
|
||||
return t.base.w>0.0 && all(t.glass==vec4<f32>(0.0)) && t.interior.x==0.0 && t.interior.w==0.0;
|
||||
}
|
||||
fn guide_interface(t:Triangle,bary:vec3<f32>,direction:vec3<f32>,geometric:vec3<f32>,ni:f32,nt:f32)->GuideInterface {
|
||||
var normal=geometric;
|
||||
// The guide-only sampling.w bit zero mirrors photon geometric normals.
|
||||
if u.volume_albedo.w==0.0 && (u.sampling.w&1u)==0u {normal=guide_normal(t,bary,geometric,direction);}
|
||||
var fresnel=dielectric(clamp(dot(normal,-direction),0.0,1.0),ni,nt);
|
||||
var transmitted=refract(direction,normal,ni/nt);
|
||||
if (fresnel.z>=0.0 && dot(transmitted,geometric)>=0.0) || dot(reflect(direction,normal),geometric)<=0.0 {
|
||||
normal=geometric;
|
||||
fresnel=dielectric(clamp(dot(normal,-direction),0.0,1.0),ni,nt);
|
||||
transmitted=refract(direction,normal,ni/nt);
|
||||
}
|
||||
// Keep a numerical zero from Snell so a degenerate branch is rejected.
|
||||
if any(transmitted!=vec3<f32>(0.0)) {transmitted=unit(transmitted);}
|
||||
return GuideInterface(reflect(direction,normal),transmitted,fresnel);
|
||||
}
|
||||
fn guide_launch(t:Triangle,hit:Hit,geometric:vec3<f32>,direction:vec3<f32>)->GuideRay {
|
||||
if t.base.w>0.0 && t.parameters.w<0.0 {
|
||||
// Closed glass continues on the precise boundary, suppressing only the
|
||||
// producing triangle and keeping strictly positive adjacent crossings.
|
||||
return GuideRay(surface_point_precise(t,hit.bary),direction,hit.triangle,0.0);
|
||||
}
|
||||
let shift=select(geometric,direction,dot(direction,geometric)<0.0)*(2.0*u.lighting.x);
|
||||
if t.base.w>0.0 && (u.sampling.w&2u)!=0u {
|
||||
// Optical resolve uses a split launch for thin sheets.
|
||||
let point=surface_point_precise(t,hit.bary);
|
||||
return GuideRay(PrecisePoint(point.hi,point.lo+shift),direction,GUIDE_MISS,u.lighting.x);
|
||||
}
|
||||
return GuideRay(PrecisePoint(surface_point(t,hit.bary)+shift,vec3<f32>(0.0)),direction,GUIDE_MISS,u.lighting.x);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn denoise_guides(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let pixel=u.image.w+gid.x;
|
||||
if gid.x>=u.sampling.z || pixel>=u.image.x*u.image.y { return; }
|
||||
let base=pixel*3u;
|
||||
let base=film_index(pixel)*6u;
|
||||
accumulation[base]=vec4<f32>(0.0);
|
||||
accumulation[base+1u]=vec4<f32>(0.0);
|
||||
accumulation[base+2u]=vec4<f32>(0.0);
|
||||
accumulation[base+3u]=vec4<f32>(0.0);
|
||||
accumulation[base+4u]=vec4<f32>(0.0);
|
||||
accumulation[base+5u]=vec4<f32>(0.0);
|
||||
let xy=vec2<f32>(f32(pixel%u.image.x)+0.5,f32(pixel/u.image.x)+0.5);
|
||||
let uv=vec2<f32>(xy.x/f32(u.image.x)*2.0-1.0,1.0-xy.y/f32(u.image.y)*2.0);
|
||||
let ray=primary_ray(uv,vec2<f32>(0.0));
|
||||
let hit=trace(ray.origin,ray.direction,false);
|
||||
if hit.triangle==0xffffffffu { return; }
|
||||
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);
|
||||
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 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_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; }
|
||||
accumulation[base]=vec4<f32>(t.base.xyz,t.optics.w);
|
||||
accumulation[base+1u]=vec4<f32>(n,depth);
|
||||
accumulation[base+2u]=vec4<f32>(position,max(footprint,0.000001));
|
||||
let camera=primary_ray(uv,vec2<f32>(0.0));
|
||||
let primary=trace(camera.origin,camera.direction,false);
|
||||
if u.environment.w>0.0 && emitter_hit_bounded(camera.origin,camera.direction,u.lighting.x).distance<primary.distance {guide_bypass(base,4u);return;}
|
||||
if primary.triangle==GUIDE_MISS {guide_bypass(base,3u);return;}
|
||||
if u.volume_min.w>0.0 {guide_bypass(base,8u);return;}
|
||||
let t=triangle(primary.triangle);
|
||||
let position=surface_point(t,primary.bary);
|
||||
let outward=guide_outward(t);
|
||||
let entering=dot(outward,-camera.direction)>0.0;
|
||||
let geometric=select(-outward,outward,entering);
|
||||
let primary_footprint=guide_footprint(guide_depth(position));
|
||||
let primary_travel=length(position-camera.origin);
|
||||
let closed=t.base.w>0.0 && t.parameters.w<0.0;
|
||||
var reflected=vec3<f32>(0.0);
|
||||
var transmitted=vec3<f32>(0.0);
|
||||
var has_reflection=false;
|
||||
var has_transmission=false;
|
||||
if t.base.w<=0.0 {
|
||||
let look=guide_look(t,primary,camera.direction,position,primary_travel,0u);
|
||||
if !guide_sharp(t,look) {guide_write(base,t.optics.w,look,position,primary_footprint);return;}
|
||||
if look.metallic<0.99 || look.roughness>0.05 || t.coat.x>0.0 {guide_bypass(base,5u);return;}
|
||||
reflected=reflect(camera.direction,look.normal);
|
||||
has_reflection=true;
|
||||
} else {
|
||||
if any(t.glass!=vec4<f32>(0.0)) {guide_bypass(base,5u);return;}
|
||||
if t.interior.x!=0.0 {guide_bypass(base,8u);return;}
|
||||
if t.interior.w!=0.0 {guide_bypass(base,6u);return;}
|
||||
if closed && !entering {guide_bypass(base,6u);return;}
|
||||
let bend=guide_interface(t,primary.bary,camera.direction,geometric,1.0,t.parameters.z);
|
||||
reflected=bend.reflected;
|
||||
let f=bend.fresnel;
|
||||
has_reflection=max(f.x,f.y)>0.0;
|
||||
has_transmission=f.z>=0.0;
|
||||
transmitted=bend.transmitted;
|
||||
if !closed && has_transmission {
|
||||
transmitted=camera.direction;
|
||||
// Use the same sheet weights without calling the transport helper
|
||||
// that can write a failure counter on a tangent absorbing sheet.
|
||||
if t.parameters.w>0.0 && !(f.z>0.0) {guide_bypass(base,10u);return;}
|
||||
var attenuation=vec3<f32>(1.0);
|
||||
if t.parameters.w>0.0 {attenuation=exp(-t.optics.xyz*(t.parameters.w/f.z));}
|
||||
has_transmission=any(sheet_polarization(f.x,attenuation).transmission+sheet_polarization(f.y,attenuation).transmission>vec3<f32>(0.0));
|
||||
}
|
||||
}
|
||||
if t.optics.w<1.0 || t.optics.w>511.0 {guide_bypass(base,15u);return;}
|
||||
if u.eye.w>0.5 && u.right.w>0.0 {guide_bypass(base,7u);return;}
|
||||
if u.sampling.y<2u || (closed && has_transmission && u.sampling.y<3u) {guide_bypass(base,9u);return;}
|
||||
if !has_reflection && !has_transmission {guide_bypass(base,10u);return;}
|
||||
if (has_reflection && dot(reflected,geometric)<=0.000001) || (has_transmission && dot(transmitted,geometric)>=-0.000001) {guide_bypass(base,10u);return;}
|
||||
// A certified root closed entry proves vacuum along this primary segment.
|
||||
// A mirror or sheet can be hit first even when a nested camera starts inside glass.
|
||||
if !closed && !guide_vacuum_origin(camera) {guide_bypass(base,6u);return;}
|
||||
|
||||
var reflected_body=0u;
|
||||
var transmitted_body=0u;
|
||||
var reflected_look:GuideLook;
|
||||
var reflected_position=vec3<f32>(0.0);
|
||||
// Stage 0 is the reflection endpoint, 1 is the same closed body's exit,
|
||||
// and 2 is the forward endpoint. One call site bounds the extra BVH work.
|
||||
let forward_stage=select(2u,1u,closed);
|
||||
var stage=select(forward_stage,0u,has_reflection);
|
||||
var ray=guide_launch(t,primary,geometric,select(transmitted,reflected,has_reflection));
|
||||
var travel=primary_travel;
|
||||
var bounce=1u;
|
||||
for (var event=0u;event<3u;event+=1u) {
|
||||
let hit=trace_precise(ray.point.hi,ray.point.lo,ray.direction,false,ray.excluded,ray.minimum,stage==1u);
|
||||
// A secondary optical branch has no surface correspondence. The primary
|
||||
// pixel is still on glass/mirror, rather than direct background/emission.
|
||||
if u.environment.w>0.0 && emitter_hit_precise(ray.point.hi,ray.point.lo,ray.direction,ray.minimum).distance<hit.distance {guide_bypass(base,0u);return;}
|
||||
if hit.triangle==GUIDE_MISS {guide_bypass(base,0u);return;}
|
||||
let next=triangle(hit.triangle);
|
||||
travel+=hit.distance;
|
||||
if stage==1u {
|
||||
let normal=-guide_outward(next);
|
||||
if next.optics.w!=t.optics.w || !guide_smooth_glass(next) || next.parameters.w>=0.0 || dot(normal,ray.direction)>=-0.000001 {guide_bypass(base,11u);return;}
|
||||
let exit=guide_interface(next,hit.bary,ray.direction,normal,t.parameters.z,1.0);
|
||||
if exit.fresnel.z<0.0 {guide_bypass(base,12u);return;}
|
||||
if dot(exit.transmitted,normal)>=-0.000001 {guide_bypass(base,10u);return;}
|
||||
ray=guide_launch(next,hit,normal,exit.transmitted);
|
||||
stage=2u;
|
||||
bounce=2u;
|
||||
continue;
|
||||
}
|
||||
if next.base.w>0.0 {guide_bypass(base,13u);return;}
|
||||
if next.optics.w<1.0 || next.optics.w>511.0 {guide_bypass(base,15u);return;}
|
||||
let endpoint_position=surface_point(next,hit.bary);
|
||||
let endpoint_look=guide_look(next,hit,ray.direction,endpoint_position,travel,bounce);
|
||||
if guide_sharp(next,endpoint_look) {guide_bypass(base,14u);return;}
|
||||
if stage==0u {
|
||||
reflected_body=u32(next.optics.w);
|
||||
if has_transmission {
|
||||
reflected_look=endpoint_look;
|
||||
reflected_position=endpoint_position;
|
||||
ray=guide_launch(t,primary,geometric,transmitted);
|
||||
stage=forward_stage;
|
||||
travel=primary_travel;
|
||||
continue;
|
||||
}
|
||||
} else {transmitted_body=u32(next.optics.w);}
|
||||
let identity=0xa0000000u|(u32(t.optics.w)<<18u)|(reflected_body<<9u)|transmitted_body;
|
||||
guide_write(base,bitcast<f32>(identity),endpoint_look,endpoint_position,primary_footprint);
|
||||
if reflected_body!=0u && transmitted_body!=0u {
|
||||
guide_write(base+3u,f32(reflected_body),reflected_look,reflected_position,primary_footprint);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use solidcraft_render::{
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
type Guide = [[f64; 4]; 3];
|
||||
type Guide = [[f64; 4]; 6];
|
||||
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a.into_iter().zip(b).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
@@ -23,18 +23,25 @@ fn xyz(a: [f64; 4]) -> [f64; 3] {
|
||||
// Independent f64 gather oracle: all inputs are averaged linear values, so the
|
||||
// GPU's accumulated-sample scaling is absent from this numerical implementation.
|
||||
fn reference(width: usize, input: &[[f64; 4]], guides: &[Guide], step: usize) -> Vec<[f64; 4]> {
|
||||
reference_settings(width, input, guides, step, 1.0, true)
|
||||
}
|
||||
fn reference_settings(width: usize, input: &[[f64; 4]], guides: &[Guide], step: usize, strength: f64, fireflies: bool) -> Vec<[f64; 4]> {
|
||||
let height = input.len() / width;
|
||||
let kernel = [1.0, 4.0, 6.0, 4.0, 1.0];
|
||||
input
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, ¢er)| {
|
||||
let [a, n, p] = guides[i];
|
||||
if a[3] == 0.0 || center[3] < 0.999 {
|
||||
let [a, n, p, ar, nr, pr] = guides[i];
|
||||
let optical = a[3] < 0.0;
|
||||
if a[3] == 0.0 || center[3] < 0.999 || (optical && step > 2) {
|
||||
return center;
|
||||
}
|
||||
let mut result = [0.0; 4];
|
||||
let mut total = 0.0;
|
||||
let maximum = |v: [f64; 3]| v.into_iter().fold(0.0, f64::max);
|
||||
let center_peak = maximum(xyz(center));
|
||||
let mut support = Vec::new();
|
||||
for (ky, &wy) in kernel.iter().enumerate() {
|
||||
for (kx, &wx) in kernel.iter().enumerate() {
|
||||
let x = (i % width) as isize + (kx as isize - 2) * step as isize;
|
||||
@@ -43,7 +50,7 @@ fn reference(width: usize, input: &[[f64; 4]], guides: &[Guide], step: usize) ->
|
||||
continue;
|
||||
}
|
||||
let j = y as usize * width + x as usize;
|
||||
let [b, m, q] = guides[j];
|
||||
let [b, m, q, br, mr, qr] = guides[j];
|
||||
let value = input[j];
|
||||
if a[3] != b[3] || (value[3] - center[3]).abs() > 0.01 {
|
||||
continue;
|
||||
@@ -53,13 +60,29 @@ fn reference(width: usize, input: &[[f64; 4]], guides: &[Guide], step: usize) ->
|
||||
let plane = dot(position, xyz(n)).abs() + dot(position, xyz(m)).abs();
|
||||
let scale = (0.05 * p[3].max(q[3]) * step as f64).max(1e-6);
|
||||
let depth = (n[3] - m[3]).abs() / (8.0 * p[3].max(q[3]) * step as f64).max(1e-6);
|
||||
let color = diff(center, value);
|
||||
let maximum = |v: [f64; 3]| v.into_iter().fold(0.0, f64::max);
|
||||
let color_scale = 0.25 + 0.5 * maximum(xyz(center)).max(maximum(xyz(value)));
|
||||
let weight = wx
|
||||
* wy
|
||||
* (-dot(albedo, albedo) / 0.04 - plane / scale - depth - maximum(color.map(f64::abs)) / color_scale).exp()
|
||||
* dot(xyz(n), xyz(m)).max(0.0).powi(32);
|
||||
let mut guide_weight = (-dot(albedo, albedo) / 0.04 - plane / scale - depth).exp() * dot(xyz(n), xyz(m)).max(0.0).powi(32);
|
||||
if ar[3] != br[3] {
|
||||
continue;
|
||||
}
|
||||
if ar[3] != 0.0 {
|
||||
let da = diff(ar, br);
|
||||
let dp = diff(qr, pr);
|
||||
let footprint = pr[3].max(qr[3]) * step as f64;
|
||||
let plane = dot(dp, xyz(nr)).abs() + dot(dp, xyz(mr)).abs();
|
||||
let depth = (nr[3] - mr[3]).abs() / (8.0 * footprint).max(1e-6);
|
||||
guide_weight *=
|
||||
(-dot(da, da) / 0.04 - plane / (0.05 * footprint).max(1e-6) - depth).exp() * dot(xyz(nr), xyz(mr)).max(0.0).powi(32);
|
||||
}
|
||||
let tap_peak = maximum(xyz(value));
|
||||
if !optical && step == 1 && (1..=3).contains(&kx) && (1..=3).contains(&ky) && i != j && guide_weight >= 0.5 {
|
||||
support.push(tap_peak);
|
||||
}
|
||||
let color = maximum(diff(center, value).map(f64::abs));
|
||||
let high = center_peak.max(tap_peak);
|
||||
let bound = 16.0 * (0.25 + center_peak.min(tap_peak));
|
||||
let contrast = if !fireflies || high <= bound { 1.0 } else { (bound / high).powi(2) };
|
||||
let color_weight = if optical { 4.0 } else { 1.0 };
|
||||
let weight = wx * wy * guide_weight * (-color_weight * color / (0.25 + 0.5 * high)).exp() * contrast;
|
||||
for k in 0..3 {
|
||||
result[k] += value[k] * weight;
|
||||
}
|
||||
@@ -69,13 +92,26 @@ fn reference(width: usize, input: &[[f64; 4]], guides: &[Guide], step: usize) ->
|
||||
for v in result.iter_mut().take(3) {
|
||||
*v /= total;
|
||||
}
|
||||
if fireflies && support.len() >= 5 {
|
||||
let support_peak = support.into_iter().fold(0.0, f64::max);
|
||||
let cap = 2.0 * support_peak + 0.25;
|
||||
let peak = maximum(xyz(result));
|
||||
if center_peak > 16.0 * (0.25 + support_peak) && peak > cap {
|
||||
for v in &mut result[..3] {
|
||||
*v *= cap / peak;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (value, original) in result.iter_mut().take(3).zip(center) {
|
||||
*value = original * (1.0 - strength) + *value * strength;
|
||||
}
|
||||
result[3] = center[3];
|
||||
result
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn guide(x: usize, y: usize) -> Guide {
|
||||
[[0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0, 4.0], [x as f64, y as f64, 0.0, 1.0]]
|
||||
[[0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0, 4.0], [x as f64, y as f64, 0.0, 1.0], [0.0; 4], [0.0; 4], [0.0; 4]]
|
||||
}
|
||||
fn fixture(size: usize) -> (Vec<[f64; 4]>, Vec<Guide>) {
|
||||
let mut pixels = Vec::new();
|
||||
@@ -219,15 +255,15 @@ fn denoise_admission_counts_guides_and_ping_pong_only_when_enabled() {
|
||||
let request = request(32);
|
||||
let plain = super::super::admission(&scene, &request, JobOptions::default()).unwrap();
|
||||
let filtered = super::super::admission(&scene, &request, JobOptions { denoise: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(filtered.pixel_scratch_bytes - plain.pixel_scratch_bytes, 32 * 32 * 80);
|
||||
assert_eq!(filtered.estimated_total_bytes - plain.estimated_total_bytes, 32 * 32 * 80);
|
||||
assert_eq!(filtered.pixel_scratch_bytes - plain.pixel_scratch_bytes, 32 * 32 * 128);
|
||||
assert_eq!(filtered.estimated_total_bytes - plain.estimated_total_bytes, 32 * 32 * 128);
|
||||
let large = ImageRequest { width: 2000, height: 2000, ..request.clone() };
|
||||
assert!(super::super::admission(&scene, &large, JobOptions::default()).is_ok());
|
||||
// The production envelope deliberately admits this former 512 MiB refusal.
|
||||
// Still charge the guides/ping-pong in full, and reject outside the new edge.
|
||||
let large_plain = super::super::admission(&scene, &large, JobOptions::default()).unwrap();
|
||||
let large_filtered = super::super::admission(&scene, &large, JobOptions { denoise: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(large_filtered.pixel_scratch_bytes - large_plain.pixel_scratch_bytes, 2000 * 2000 * 80);
|
||||
assert_eq!(large_filtered.pixel_scratch_bytes - large_plain.pixel_scratch_bytes, 2000 * 2000 * 128);
|
||||
assert!(large_filtered.estimated_total_bytes > 512 * 1024 * 1024);
|
||||
assert!(large_filtered.estimated_total_bytes <= solidcraft_render::job::GPU_PATH_LIMITS.max_scratch_bytes);
|
||||
let excessive = ImageRequest { width: (solidcraft_render::job::GPU_PATH_LIMITS.max_dimension + 1) as u32, ..request.clone() };
|
||||
@@ -238,7 +274,7 @@ fn denoise_admission_counts_guides_and_ping_pong_only_when_enabled() {
|
||||
assert!(serde_json::from_str::<JobOptions>(r#"{"denoise":"true"}"#).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn gpu_atrous_matches_f64_guides_and_leaves_input_unchanged() {
|
||||
fn gpu_atrous_matches_f64_with_firefly_line_and_material_boundary() {
|
||||
let Some(context) = context() else {
|
||||
return;
|
||||
};
|
||||
@@ -249,9 +285,27 @@ fn gpu_atrous_matches_f64_guides_and_leaves_input_unchanged() {
|
||||
input[y * 13 + x][0] += 0.8;
|
||||
}
|
||||
}
|
||||
for y in 0..13 {
|
||||
input[y * 13 + 10] = [256.0, 64.0, 16.0, 1.0];
|
||||
}
|
||||
let isolated = 6 * 13 + 3;
|
||||
let material_boundary = 2 * 13 + 6;
|
||||
input[isolated] = [512.0, 128.0, 32.0, 1.0];
|
||||
input[material_boundary] = input[isolated];
|
||||
// Same body but incompatible albedo. The neighbouring floor cannot
|
||||
// supply support for capping a highlight on this material patch.
|
||||
guides[material_boundary][0] = [1.0; 4];
|
||||
let optical_glint = 2 * 13 + 8;
|
||||
for y in 1..=3 {
|
||||
for x in 7..=9 {
|
||||
guides[y * 13 + x][0][3] = f64::from(f32::from_bits(0xa004_0400));
|
||||
}
|
||||
}
|
||||
input[optical_glint] = [1024.0, 256.0, 64.0, 1.0];
|
||||
guides[0][0][3] = 0.0;
|
||||
input[1][3] = 0.5;
|
||||
let bytes: Vec<u8> = input.iter().flatten().flat_map(|x| (*x as f32).to_le_bytes()).collect();
|
||||
let samples = 8;
|
||||
let bytes: Vec<u8> = input.iter().flatten().flat_map(|x| (*x as f32 * samples as f32).to_le_bytes()).collect();
|
||||
let device = &context.device;
|
||||
let make = |label, bytes: &[u8]| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
@@ -262,25 +316,193 @@ fn gpu_atrous_matches_f64_guides_and_leaves_input_unchanged() {
|
||||
};
|
||||
let raw = make("denoise fixture raw", &bytes);
|
||||
let dummy = make("denoise unused geometry", &[0; 64]);
|
||||
let mut task = Task::new(context.clone(), plane(), request(13), JobOptions::default(), CancelToken::default()).unwrap();
|
||||
task.sample = 1;
|
||||
let options = JobOptions {
|
||||
denoise_settings: solidcraft_render::job::DenoiseSettings { firefly_suppression: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let mut task = Task::new(context.clone(), plane(), request(13), options, CancelToken::default()).unwrap();
|
||||
task.sample = samples;
|
||||
let filter = Resources::new(&context, &task.uniform, [&dummy, &dummy, &dummy, &dummy, &dummy], &raw, bytes.len() as u64, false).unwrap();
|
||||
let guide_bytes: Vec<u8> = guides.iter().flatten().flatten().flat_map(|x| (*x as f32).to_le_bytes()).collect();
|
||||
context.queue.write_buffer(&filter._guides, 0, &guide_bytes);
|
||||
let before = task.read_linear_buffer(&raw).unwrap();
|
||||
filter.run_filter(&task, false).unwrap();
|
||||
assert_eq!(before, task.read_linear_buffer(&raw).unwrap());
|
||||
let actual = task.read_linear_buffer(filter.output()).unwrap();
|
||||
let mut expected = input;
|
||||
for step in [1, 2, 4] {
|
||||
expected = reference(13, &expected, &guides, step);
|
||||
}
|
||||
let actual = task.read_linear_buffer(filter.output(3)).unwrap();
|
||||
let first_pass = reference(13, &input, &guides, 1);
|
||||
assert!(first_pass[isolated][0] < 1.5);
|
||||
let expected = reference(13, &reference(13, &first_pass, &guides, 2), &guides, 4);
|
||||
for (a, b) in actual.iter().zip(&expected) {
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
assert!((f64::from(*x) - *y).abs() < 2e-5, "{a:?} vs {b:?}");
|
||||
assert_eq!(a[3].to_bits(), (b[3] as f32).to_bits());
|
||||
for (x, y) in a[..3].iter().zip(&b[..3]) {
|
||||
assert!((f64::from(*x) - *y).abs() < 2e-5 * y.abs().max(1.0), "{a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
assert!(actual[isolated][0] < 0.7, "the isolated firefly must come down to its neighbourhood");
|
||||
assert!(actual[6 * 13 + 10][0] > 254.0, "a supported bright line must survive");
|
||||
assert!(actual[material_boundary][0] > 511.0, "incompatible guide support must preserve the material highlight");
|
||||
assert!(actual[optical_glint][0] > 1023.0, "a single optical glint with matching guides must survive");
|
||||
}
|
||||
#[test]
|
||||
fn gpu_optical_filter_preserves_edges_on_the_same_reflected_body() {
|
||||
let Some(context) = context() else {
|
||||
return;
|
||||
};
|
||||
let device = &context.device;
|
||||
let input: Vec<[f32; 4]> = (0..25).map(|i| if i == 12 { [0.8, 0.8, 0.8, 1.0] } else { [0.4, 0.4, 0.4, 1.0] }).collect();
|
||||
let bytes: Vec<_> = input.iter().flatten().flat_map(|x| x.to_le_bytes()).collect();
|
||||
let raw = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("mixed glass edge radiance"),
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
});
|
||||
let dummy = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("unused optical edge geometry"),
|
||||
contents: &[0; 64],
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
});
|
||||
let mut task = Task::new(context.clone(), plane(), request(5), JobOptions::default(), CancelToken::default()).unwrap();
|
||||
task.sample = 1;
|
||||
let filter = Resources::new(&context, &task.uniform, [&dummy, &dummy, &dummy, &dummy, &dummy], &raw, bytes.len() as u64, false).unwrap();
|
||||
for edge in 0..4 {
|
||||
let mut g = guide(0, 0);
|
||||
g[0][3] = f64::from(f32::from_bits(0xa004_0403));
|
||||
g[3] = [0.5, 0.5, 0.5, 2.0];
|
||||
g[4] = [0.0, 0.0, 1.0, 4.0];
|
||||
g[5] = [0.0, 0.0, 0.0, 1.0];
|
||||
let mut guides = vec![g; 25];
|
||||
match edge {
|
||||
0 => guides[12][3] = [1.0, 1.0, 1.0, 2.0],
|
||||
1 => guides[12][4] = [1.0, 0.0, 0.0, 4.0],
|
||||
2 => guides[12][4][3] = 1000.0,
|
||||
_ => guides[12][5][2] = 10.0,
|
||||
}
|
||||
let packed: Vec<_> = guides.iter().flatten().flatten().flat_map(|x| (*x as f32).to_le_bytes()).collect();
|
||||
context.queue.write_buffer(&filter._guides, 0, &packed);
|
||||
filter.run_filter(&task, false).unwrap();
|
||||
let actual = task.read_linear_buffer(filter.output(3)).unwrap();
|
||||
assert!(actual[12][0] > 0.799, "reflected edge {edge}: {:?}", actual[12]);
|
||||
assert_eq!(task.read_linear_buffer(&raw).unwrap(), input);
|
||||
}
|
||||
}
|
||||
fn optical_plane(z: f64, albedo: Vec3) -> ProductMesh {
|
||||
let mut body = plane().meshes.remove(0);
|
||||
for point in &mut Arc::make_mut(&mut body.mesh).positions {
|
||||
point.x *= 3.0;
|
||||
point.y *= 3.0;
|
||||
point.z = z;
|
||||
}
|
||||
body.surface = Some(OpaqueSurface::new(albedo, 0.0, 0.7, 1.0).unwrap());
|
||||
body
|
||||
}
|
||||
fn optical_box() -> Mesh {
|
||||
let faces = [
|
||||
([[-3., -3., 0.5], [3., -3., 0.5], [3., 3., 0.5], [-3., 3., 0.5]], Vec3::Z),
|
||||
([[-3., 3., -0.5], [3., 3., -0.5], [3., -3., -0.5], [-3., -3., -0.5]], -Vec3::Z),
|
||||
([[3., -3., -0.5], [3., 3., -0.5], [3., 3., 0.5], [3., -3., 0.5]], Vec3::X),
|
||||
([[-3., 3., -0.5], [-3., -3., -0.5], [-3., -3., 0.5], [-3., 3., 0.5]], -Vec3::X),
|
||||
([[3., 3., -0.5], [-3., 3., -0.5], [-3., 3., 0.5], [3., 3., 0.5]], Vec3::Y),
|
||||
([[-3., -3., -0.5], [3., -3., -0.5], [3., -3., 0.5], [-3., -3., 0.5]], -Vec3::Y),
|
||||
];
|
||||
let mut mesh = Mesh::default();
|
||||
for (points, normal) in faces {
|
||||
let start = mesh.positions.len() as u32;
|
||||
mesh.positions.extend(points.map(|[x, y, z]| Vec3::new(x, y, z)));
|
||||
mesh.normals.extend([normal; 4]);
|
||||
mesh.triangles.extend([[start, start + 1, start + 2], [start, start + 2, start + 3]]);
|
||||
mesh.tri_face.extend([0; 2]);
|
||||
}
|
||||
mesh
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_denoise_optical_guides_track_branches_and_certified_snell_endpoints() {
|
||||
use solidcraft_render::{
|
||||
glass_model::{GlassModel, ThinFilm},
|
||||
job::{AreaLight, AreaLights, StudioLighting, ThinLens},
|
||||
transmission::Transmission,
|
||||
};
|
||||
let Some(context) = context() else { return };
|
||||
let mut image = request(1);
|
||||
let incident = 0.4_f64;
|
||||
image.camera.pitch = std::f64::consts::FRAC_PI_2 - incident;
|
||||
image.camera.yaw = 0.0;
|
||||
image.camera.fov = 0.4;
|
||||
let reflected = optical_plane(6.0, Vec3::new(0.2, 0.4, 0.6));
|
||||
let transmitted = optical_plane(-3.0, Vec3::new(0.7, 0.3, 0.2));
|
||||
let base = optical_plane(0.0, Vec3::ZERO);
|
||||
let options = JobOptions { samples: 1, max_depth: 4, denoise: true, ..Default::default() };
|
||||
let probe = |meshes: Vec<ProductMesh>, options| {
|
||||
let bounds = meshes.iter().fold(solidcraft_geom::Aabb3::EMPTY, |bounds, body| bounds.union(&body.mesh.bounds()));
|
||||
let task = Task::new(context.clone(), ProductScene { meshes, bounds }, image.clone(), options, CancelToken::default()).unwrap();
|
||||
let filter = task.denoiser.as_ref().unwrap();
|
||||
filter.generate_guides(&task).unwrap();
|
||||
assert!(read_guides(&context, &task.failures).iter().all(|word| *word == [0.0; 4]), "guide tracing cannot write production failures");
|
||||
read_guides(&context, &filter._guides)
|
||||
};
|
||||
let tag = |reflection: u32, transmission: u32| 0xa0000000_u32 | (1_u32 << 18) | (reflection << 9) | transmission;
|
||||
let bypass = |reason: DenoiseGuideClass| {
|
||||
let mut guides = vec![[0.0; 4]; 6];
|
||||
guides[0][0] = reason.code() as f32;
|
||||
guides
|
||||
};
|
||||
let mut mirror = base.clone();
|
||||
mirror.surface = Some(OpaqueSurface::new(Vec3::new(1.0, 1.0, 1.0), 1.0, 0.045, 1.5).unwrap());
|
||||
assert_eq!(probe(vec![mirror.clone()], options), bypass(DenoiseGuideClass::Unavailable));
|
||||
assert_eq!(probe(Vec::new(), options), bypass(DenoiseGuideClass::Background));
|
||||
let direct_emitter = AreaLight { center: [0.0, 0.0, 2.0], u: [2.0, 0.0, 0.0], v: [0.0, 2.0, 0.0], radiance: [1.0; 3] };
|
||||
let direct_lighting = StudioLighting { area_lights: AreaLights::new(&[direct_emitter]).unwrap(), ..Default::default() };
|
||||
assert_eq!(probe(Vec::new(), JobOptions { lighting: Some(direct_lighting), ..options }), bypass(DenoiseGuideClass::Emitter));
|
||||
let guide = probe(vec![mirror.clone(), reflected.clone()], options);
|
||||
assert_eq!(guide[0], [0.2, 0.4, 0.6, f32::from_bits(tag(2, 0))]);
|
||||
assert_eq!(&guide[1][..3], &[0.0, 0.0, -1.0]);
|
||||
assert!(guide[1][3] < 0.0, "the reflected endpoint is behind the camera");
|
||||
assert!((guide[2][1] - (6.0 * incident.tan()) as f32).abs() < 2e-5);
|
||||
assert!(guide[2][3] >= 1.6, "the endpoint behind the camera retains the primary footprint");
|
||||
let lens = ThinLens { aperture_radius_mm: 0.1, focus_distance_mm: 4.0 };
|
||||
assert_eq!(probe(vec![mirror, reflected.clone()], JobOptions { lens: Some(lens), ..options }), bypass(DenoiseGuideClass::DepthOfField));
|
||||
|
||||
let mut thin = base.clone();
|
||||
thin.surface = Some(OpaqueSurface::new(Vec3::ZERO, 0.0, 0.7, 1.5).unwrap());
|
||||
thin.transmission = Some(Transmission::new(Vec3::ZERO, Some(0.0)).unwrap());
|
||||
let thin_scene = vec![thin.clone(), reflected.clone(), transmitted.clone()];
|
||||
let guide = probe(thin_scene.clone(), options);
|
||||
assert_eq!(guide[0], [0.7, 0.3, 0.2, f32::from_bits(tag(2, 3))]);
|
||||
assert_eq!(guide[3], [0.2, 0.4, 0.6, 2.0]);
|
||||
assert_eq!(&guide[4][..3], &[0.0, 0.0, -1.0]);
|
||||
assert_eq!(&guide[1][..3], &[0.0, 0.0, 1.0]);
|
||||
assert!((guide[2][1] - (3.0 * incident.tan()) as f32).abs() < 2e-5);
|
||||
assert_eq!(
|
||||
probe(vec![thin.clone(), transmitted.clone()], options),
|
||||
bypass(DenoiseGuideClass::Unavailable),
|
||||
"both weighted branches need an opaque endpoint"
|
||||
);
|
||||
let emitter = AreaLight { center: [0.0, 2.0, 5.0], u: [2.0, 0.0, 0.0], v: [0.0, 2.0, 0.0], radiance: [1.0; 3] };
|
||||
let blocked = StudioLighting { area_lights: AreaLights::new(&[emitter]).unwrap(), ..Default::default() };
|
||||
assert_eq!(probe(thin_scene, JobOptions { lighting: Some(blocked), ..options }), bypass(DenoiseGuideClass::Unavailable));
|
||||
let film = GlassModel { roughness: 0.0, dispersion: None, thin_film: Some(ThinFilm { ior: 1.35, thickness_nm: 200.0 }) };
|
||||
let mut modeled = thin.clone();
|
||||
modeled.transmission = Some(modeled.transmission.unwrap().with_model(film).unwrap());
|
||||
assert_eq!(probe(vec![modeled, reflected.clone(), transmitted.clone()], options), bypass(DenoiseGuideClass::UnsupportedMaterial));
|
||||
let mut matched = thin.clone();
|
||||
matched.surface = base.surface;
|
||||
let guide = probe(vec![matched, reflected.clone(), transmitted.clone()], options);
|
||||
assert_eq!(guide[0][3].to_bits(), tag(0, 3));
|
||||
|
||||
let mut closed = thin;
|
||||
closed.mesh = Arc::new(optical_box());
|
||||
closed.transmission = Some(Transmission::new(Vec3::ZERO, None).unwrap());
|
||||
let closed_scene = vec![closed, reflected, transmitted];
|
||||
let guide = probe(closed_scene.clone(), JobOptions { nested_media: true, ..options });
|
||||
assert_eq!(guide[0], [0.7, 0.3, 0.2, f32::from_bits(tag(2, 3))]);
|
||||
assert_eq!(guide[3], [0.2, 0.4, 0.6, 2.0]);
|
||||
assert_eq!(&guide[4][..3], &[0.0, 0.0, -1.0]);
|
||||
let internal = (incident.sin() / 1.5).asin();
|
||||
let snell_y = 2.0 * incident.tan() + internal.tan();
|
||||
assert!((f64::from(guide[2][1]) - snell_y).abs() < 2e-5, "Snell displacement {guide:?} vs {snell_y}");
|
||||
assert_eq!(probe(closed_scene, JobOptions { max_depth: 2, ..options }), bypass(DenoiseGuideClass::PathDepth));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_denoise_default_false_is_identical_and_true_preserves_raw() {
|
||||
let Some(context) = context() else {
|
||||
@@ -300,13 +522,16 @@ fn gpu_denoise_default_false_is_identical_and_true_preserves_raw() {
|
||||
}
|
||||
let raw = task.read_linear().unwrap();
|
||||
if let Some(filter) = &task.denoiser {
|
||||
use std::sync::atomic::Ordering;
|
||||
assert!(!filter.guides_ready.load(Ordering::Relaxed));
|
||||
filter.run(&task).unwrap();
|
||||
assert!(filter.guides_ready.load(Ordering::Relaxed));
|
||||
assert_eq!(raw, task.read_linear().unwrap());
|
||||
let filtered = task.read_linear_buffer(filter.output()).unwrap();
|
||||
let filtered = task.read_linear_buffer(filter.output(3)).unwrap();
|
||||
assert!(filtered.iter().all(|p| p.iter().all(|v| v.is_finite())));
|
||||
let guides = read_guides(&context, &filter._guides);
|
||||
assert_eq!(guides[0], [0.0; 4]);
|
||||
let center = (4 * 8 + 4) * 3;
|
||||
assert_eq!(guides[0], [DenoiseGuideClass::Background.code() as f32, 0.0, 0.0, 0.0]);
|
||||
let center = (4 * 8 + 4) * 6;
|
||||
assert_eq!(guides[center], [0.5, 0.5, 0.5, 1.0]);
|
||||
assert_eq!(guides[center + 1], [0.0, 0.0, 1.0, 16.0]);
|
||||
assert!((guides[center + 2][0] - 0.2).abs() < 1e-6);
|
||||
@@ -348,3 +573,406 @@ fn gpu_denoise_preflight_reports_limits_without_changing_document() {
|
||||
let reply = session.execute("render.preflight", ¶ms).unwrap();
|
||||
assert!(reply.get("denoising").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_denoise_controls_match_f64_keep_alpha_and_leave_raw_unchanged() {
|
||||
use solidcraft_render::job::DenoiseSettings;
|
||||
let Some(context) = context() else { return };
|
||||
let (mut input, guides) = fixture(13);
|
||||
input[6 * 13 + 6] = [512.0, 128.0, 32.0, 1.0];
|
||||
input[0][3] = 0.5;
|
||||
let samples = 4;
|
||||
let bytes: Vec<_> = input.iter().flatten().flat_map(|value| (*value as f32 * samples as f32).to_le_bytes()).collect();
|
||||
let raw = context.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("denoise control radiance"),
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
});
|
||||
let dummy = context.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("unused control geometry"),
|
||||
contents: &[0; 64],
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
});
|
||||
let mut task = Task::new(context.clone(), plane(), request(13), JobOptions::default(), CancelToken::default()).unwrap();
|
||||
task.sample = samples;
|
||||
let filter = Resources::new(&context, &task.uniform, [&dummy, &dummy, &dummy, &dummy, &dummy], &raw, bytes.len() as u64, false).unwrap();
|
||||
let packed: Vec<_> = guides.iter().flatten().flatten().flat_map(|value| (*value as f32).to_le_bytes()).collect();
|
||||
context.queue.write_buffer(&filter._guides, 0, &packed);
|
||||
let raw_before = task.read_linear_buffer(&raw).unwrap();
|
||||
for (passes, strength, firefly_suppression) in [(1, 0.0, true), (1, 1.0, false), (1, 1.0, true), (2, 0.4, true), (3, 0.6, false)] {
|
||||
task.options.denoise_settings = DenoiseSettings { passes, strength, firefly_suppression };
|
||||
filter.run_filter(&task, false).unwrap();
|
||||
let actual = task.read_linear_buffer(filter.output(passes)).unwrap();
|
||||
let mut expected = input.clone();
|
||||
for pass in 0..passes {
|
||||
expected = reference_settings(13, &expected, &guides, 1 << pass, strength, firefly_suppression);
|
||||
}
|
||||
for (actual, expected) in actual.iter().zip(&expected) {
|
||||
assert_eq!(actual[3].to_bits(), (expected[3] as f32).to_bits());
|
||||
for (a, e) in actual[..3].iter().zip(&expected[..3]) {
|
||||
assert!((f64::from(*a) - *e).abs() < 3e-5 * e.abs().max(1.0), "passes={passes} strength={strength}: {actual:?} vs {expected:?}");
|
||||
}
|
||||
}
|
||||
if strength == 0.0 {
|
||||
assert_eq!(actual, raw_before);
|
||||
}
|
||||
assert_eq!(task.read_linear_buffer(&raw).unwrap(), raw_before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_supersampling_admission_charges_internal_pixels_and_final_mask() {
|
||||
let scene = plane();
|
||||
let output = request(8);
|
||||
let trace = request(16);
|
||||
let native = super::super::admission(&scene, &trace, JobOptions::default()).unwrap();
|
||||
let options = JobOptions { supersampling: 2, ..Default::default() };
|
||||
let sampled = super::super::admission(&scene, &output, options).unwrap();
|
||||
assert_eq!(sampled.pixel_scratch_bytes, native.pixel_scratch_bytes + 8 * 8 * 16);
|
||||
assert_eq!(sampled.encoded_limit, 8 * 8 * 8 + 65_536);
|
||||
let diagnostic = super::super::admission(&scene, &output, JobOptions { denoise_diagnostics: true, ..options }).unwrap();
|
||||
assert_eq!(diagnostic.pixel_scratch_bytes - sampled.pixel_scratch_bytes, 16 * 16 * 128 + 8 * 8 * 12 + 65_536);
|
||||
assert!(
|
||||
super::super::admission(
|
||||
&scene,
|
||||
&ImageRequest { width: 7680, height: 4320, ..output.clone() },
|
||||
JobOptions { samples: 128, ..Default::default() }
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
for supersampling in [0, 3, u32::MAX] {
|
||||
assert!(super::super::admission(&scene, &output, JobOptions { supersampling, ..Default::default() }).is_err());
|
||||
}
|
||||
assert!(super::super::admission(&scene, &request(8192), JobOptions::default()).is_err(), "8K output is also bounded by total pixels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_supersampling_raw_master_uses_linear_rgba_and_selected_output_size() {
|
||||
let Some(context) = context() else { return };
|
||||
let options = JobOptions { samples: 2, supersampling: 2, linear_exr: true, ..Default::default() };
|
||||
let mut task = Task::new(context.clone(), plane(), request(4), options, CancelToken::default()).unwrap();
|
||||
assert_eq!((task.request.width, task.request.height), (8, 8));
|
||||
while task.sample < options.samples {
|
||||
task.complete_sample().unwrap();
|
||||
}
|
||||
let input = task.read_linear().unwrap();
|
||||
let mut expected = Vec::new();
|
||||
for y in 0..4 {
|
||||
for x in 0..4 {
|
||||
let positions = [(2 * y) * 8 + 2 * x, (2 * y) * 8 + 2 * x + 1, (2 * y + 1) * 8 + 2 * x, (2 * y + 1) * 8 + 2 * x + 1];
|
||||
expected
|
||||
.push(std::array::from_fn(|channel| (positions.into_iter().map(|index| f64::from(input[index][channel])).sum::<f64>() / 4.0) as f32));
|
||||
}
|
||||
}
|
||||
let mut expected_exr = solidcraft_render::job::linear_exr::ExrTask::new(4, 4, expected, CancelToken::default()).unwrap();
|
||||
while !expected_exr.step(4).unwrap() {}
|
||||
let (expected_exr, _) = expected_exr.finish().unwrap();
|
||||
let mut png = None;
|
||||
for _ in 0..100 {
|
||||
if let TaskPoll::Complete(bytes) = task.step(4).unwrap() {
|
||||
png = Some(bytes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let png = image::load_from_memory(&png.unwrap()).unwrap().to_rgba8();
|
||||
assert_eq!(png.dimensions(), (4, 4));
|
||||
assert_eq!(task.take_linear_exr().unwrap(), expected_exr);
|
||||
assert_eq!(task.read_linear().unwrap(), input, "resolution never mutates the transport accumulator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_denoise_diagnostics_preserve_beauty_and_export_a_categorical_final_size_mask() {
|
||||
let Some(context) = context() else { return };
|
||||
let mut beauty = Vec::new();
|
||||
let mut masters = Vec::new();
|
||||
for enabled in [false, true] {
|
||||
let options = JobOptions { samples: 2, supersampling: 2, linear_exr: true, denoise_diagnostics: enabled, ..Default::default() };
|
||||
let mut task = Task::new(context.clone(), plane(), request(8), options, CancelToken::default()).unwrap();
|
||||
for _ in 0..200 {
|
||||
if let TaskPoll::Complete(png) = task.step(4).unwrap() {
|
||||
beauty.push(png);
|
||||
break;
|
||||
}
|
||||
}
|
||||
masters.push(task.take_linear_exr().unwrap());
|
||||
if enabled {
|
||||
let report = task.denoise_diagnostics().unwrap();
|
||||
assert_eq!((report.width, report.height), (16, 16));
|
||||
assert_eq!(report.counts.iter().sum::<u64>(), 256);
|
||||
assert!(report.pixels(DenoiseGuideClass::Surface) > 0);
|
||||
assert!(report.pixels(DenoiseGuideClass::Background) > 0);
|
||||
let mask = image::load_from_memory(&task.take_denoise_mask().unwrap()).unwrap().to_rgba8();
|
||||
assert_eq!(mask.dimensions(), (8, 8));
|
||||
assert!(mask.pixels().all(|pixel| DenoiseGuideClass::ALL.iter().any(|class| class.color() == pixel.0)));
|
||||
assert!(task.take_denoise_mask().is_none());
|
||||
} else {
|
||||
assert!(task.denoise_diagnostics().is_none());
|
||||
assert!(task.take_denoise_mask().is_none());
|
||||
}
|
||||
}
|
||||
assert_eq!(beauty.len(), 2);
|
||||
assert_eq!(beauty[0], beauty[1]);
|
||||
assert_eq!(masters[0], masters[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_filter_windows_keep_three_pass_halos_firefly_support_and_actual_frame_edges_exact() {
|
||||
let Some(context) = context() else { return };
|
||||
let (mut input, mut guides) = fixture(40);
|
||||
for row in [8, 16, 24, 32] {
|
||||
input[row * 40 + 9] = [512.0, 128.0, 32.0, 1.0];
|
||||
}
|
||||
for y in 0..40 {
|
||||
input[y * 40 + 27] = [256.0, 64.0, 16.0, 1.0];
|
||||
for x in 20..40 {
|
||||
guides[y * 40 + x][0][3] = 2.0;
|
||||
}
|
||||
}
|
||||
input[0] = [0.2, 0.3, 0.4, 0.5];
|
||||
guides[39][0][3] = 0.0;
|
||||
let bytes: Vec<u8> = input.iter().flatten().flat_map(|value| (*value as f32).to_le_bytes()).collect();
|
||||
let guide_bytes: Vec<u8> = guides.iter().flatten().flatten().flat_map(|value| (*value as f32).to_le_bytes()).collect();
|
||||
let make = |label, bytes: &[u8]| {
|
||||
context.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
})
|
||||
};
|
||||
let raw = make("full filter halo oracle", &bytes);
|
||||
let dummy = make("unused filter geometry", &[0; 64]);
|
||||
let options = JobOptions {
|
||||
denoise: true,
|
||||
denoise_settings: solidcraft_render::job::DenoiseSettings { passes: 3, strength: 1.0, firefly_suppression: true },
|
||||
..Default::default()
|
||||
};
|
||||
let mut task = Task::new(context.clone(), plane(), request(40), JobOptions { denoise: false, ..options }, CancelToken::default()).unwrap();
|
||||
task.sample = 1;
|
||||
let full = Resources::new(&context, &task.uniform, [&dummy; 5], &raw, bytes.len() as u64, false).unwrap();
|
||||
context.queue.write_buffer(&full._guides, 0, &guide_bytes);
|
||||
full.run_filter(&task, false).unwrap();
|
||||
let expected = task.read_linear_buffer(full.output(3)).unwrap();
|
||||
let layout = super::super::image_pages::Layout::new(40, 40, options, 0, &context.device.limits()).unwrap().with_rows(8, 1).unwrap();
|
||||
let size = u64::from(layout.max_window_pixels()) * 16;
|
||||
let gathered = make("gathered filter halo fixture", &vec![0; size as usize]);
|
||||
let paged = Resources::new(&context, &task.uniform, [&dummy; 5], &gathered, size, false).unwrap();
|
||||
let mut actual = Vec::new();
|
||||
for window in layout.windows() {
|
||||
context.queue.write_buffer(&gathered, 0, &bytes[window.start as usize * 16..(window.start + window.pixels) as usize * 16]);
|
||||
context.queue.write_buffer(&paged._guides, 0, &guide_bytes[window.start as usize * 96..(window.start + window.pixels) as usize * 96]);
|
||||
paged.run_filter_window(&task, false, window).unwrap();
|
||||
let count = u64::from(window.core_pixels) * 16;
|
||||
task.readback_data(
|
||||
count,
|
||||
|encoder| {
|
||||
encoder.copy_buffer_to_buffer(paged.output(3), u64::from(window.core_start - window.start) * 16, &task.readback, 0, count);
|
||||
Ok(())
|
||||
},
|
||||
|data| {
|
||||
actual.extend(
|
||||
data.as_chunks::<16>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|pixel| std::array::from_fn::<_, 4, _>(|i| f32::from_le_bytes(pixel[i * 4..i * 4 + 4].try_into().unwrap()))),
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(actual, expected, "full dependency halos must preserve every filtered float, including real frame edges");
|
||||
assert!(actual[8 * 40 + 9][0] < 1.0, "firefly support crosses the page boundary");
|
||||
assert!(actual[16 * 40 + 27][0] > 254.0, "supported thin highlights cross the boundary unchanged");
|
||||
assert_eq!(task.read_linear_buffer(&raw).unwrap(), input.iter().map(|pixel| pixel.map(|v| v as f32)).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// Deliberately isolate storage/normalization from stochastic pilot allocation.
|
||||
/// The existing adaptive tests cover the allocation oracle; this fixture gives
|
||||
/// every row and page boundary both stopped and unfinished production pixels.
|
||||
fn set_checkerboard_test_budgets(task: &Task) {
|
||||
assert_eq!(task.sample, task.options.adaptive.unwrap().pilot_samples);
|
||||
assert_eq!((task.offset, task.lane), (0, 0));
|
||||
let context = &task.context;
|
||||
let submission = super::super::checked_creation(&context.device, || {
|
||||
let source = r#"
|
||||
struct Params { page:vec4<u32> };
|
||||
@group(0) @binding(0) var<uniform> p:Params;
|
||||
@group(0) @binding(1) var<storage,read_write> film:array<vec4<f32>>;
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) id:vec3<u32>) {
|
||||
let local=id.x;
|
||||
if local>=p.page.y {return;}
|
||||
let pixel=p.page.x+local;
|
||||
let high=((pixel%p.page.z)+(pixel/p.page.z))%2u==1u;
|
||||
film[2u*p.page.y+local]=vec4<f32>(f32(select(16u,32u,high)),0.0,0.0,0.0);
|
||||
}
|
||||
"#;
|
||||
let module = context.device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("independent adaptive budget fixture"),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let pipeline = context.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("independent adaptive budget fixture"),
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let layout = pipeline.get_bind_group_layout(0);
|
||||
let mut encoder = context.device.create_command_encoder(&Default::default());
|
||||
for page in &task.pages {
|
||||
let bytes: Vec<u8> = [page.start, page.pixels, task.request.width, 0].into_iter().flat_map(u32::to_le_bytes).collect();
|
||||
let uniform = context.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("independent adaptive page fixture"),
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let entries = [
|
||||
wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: page.accumulation.as_entire_binding() },
|
||||
];
|
||||
let bind = context.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("independent adaptive page fixture"),
|
||||
layout: &layout,
|
||||
entries: &entries,
|
||||
});
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &bind, &[]);
|
||||
pass.dispatch_workgroups(page.pixels.div_ceil(64), 1, 1);
|
||||
}
|
||||
Ok(context.queue.submit([encoder.finish()]))
|
||||
})
|
||||
.unwrap();
|
||||
context.wait(submission, TIMEOUT).unwrap();
|
||||
}
|
||||
|
||||
fn read_test_production_budgets(task: &Task) -> Vec<u32> {
|
||||
let mut budgets = Vec::new();
|
||||
for page in &task.pages {
|
||||
let bytes = u64::from(page.pixels) * 16;
|
||||
task.readback_data(
|
||||
bytes,
|
||||
|encoder| {
|
||||
encoder.copy_buffer_to_buffer(&page.accumulation, bytes * 2, &task.readback, 0, bytes);
|
||||
Ok(())
|
||||
},
|
||||
|data| {
|
||||
budgets.extend(data.as_chunks::<16>().0.iter().map(|pixel| f32::from_le_bytes(pixel[..4].try_into().unwrap()) as u32));
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
budgets
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_paged_film_matches_raw_finished_png_diagnostic_masks_and_complete_adaptive_previews() {
|
||||
use solidcraft_render::job::{DenoiseSettings, adaptive::AdaptiveSampling, bloom::Bloom, finishing::Finishing};
|
||||
let Some(context) = context() else { return };
|
||||
let image = ImageRequest { width: 8, height: 21, ..request(8) };
|
||||
for adaptive in [false, true] {
|
||||
let options = JobOptions {
|
||||
samples: if adaptive { 32 } else { 4 },
|
||||
max_depth: 3,
|
||||
seed: 91,
|
||||
supersampling: 2,
|
||||
denoise: true,
|
||||
denoise_settings: DenoiseSettings { passes: 3, strength: 1.0, firefly_suppression: true },
|
||||
denoise_diagnostics: true,
|
||||
linear_exr: true,
|
||||
adaptive: adaptive.then_some(AdaptiveSampling { pilot_samples: 16, min_samples: 16, ..Default::default() }),
|
||||
finishing: Finishing { bloom: Some(Bloom { strength: 0.3, threshold: 0.1, radius: 2 }), ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let render = |rows| {
|
||||
let mut task = Task::new_with_page_rows(context.clone(), plane(), image.clone(), options, CancelToken::default(), rows).unwrap();
|
||||
if rows.is_some() {
|
||||
assert_eq!(task.pages.len(), 6);
|
||||
assert_eq!(task.pages.last().unwrap().pixels, 16 * 2, "the short final page must execute with its own plane stride");
|
||||
}
|
||||
task.preview_enabled = true;
|
||||
let mut previews = Vec::new();
|
||||
let mut budget_injected = false;
|
||||
for _ in 0..50_000 {
|
||||
let poll = task.step(4).unwrap();
|
||||
if let Some(preview) = task.take_preview() {
|
||||
assert_eq!(task.offset, 0, "previews own completed global passes");
|
||||
assert_eq!(task.lane, 0);
|
||||
if adaptive && preview.samples_per_pixel == 16 {
|
||||
assert!(!budget_injected);
|
||||
set_checkerboard_test_budgets(&task);
|
||||
budget_injected = true;
|
||||
}
|
||||
previews.push((preview.samples_per_pixel, preview.png));
|
||||
}
|
||||
if let TaskPoll::Complete(png) = poll {
|
||||
let raw = task.read_linear().unwrap();
|
||||
let report = task.denoise_diagnostics().unwrap();
|
||||
assert_eq!(report.counts.iter().sum::<u64>(), 16 * 42);
|
||||
let budgets = if adaptive {
|
||||
assert!(budget_injected);
|
||||
let budgets = read_test_production_budgets(&task);
|
||||
let expected: Vec<u32> = (0..16 * 42).map(|pixel| if ((pixel % 16) + (pixel / 16)) % 2 == 0 { 16 } else { 32 }).collect();
|
||||
assert_eq!(budgets, expected, "both budgets cross every storage boundary, including the short last page");
|
||||
let sampling = task.sampling_report().unwrap();
|
||||
assert_eq!((sampling.minimum_production_samples, sampling.maximum_production_samples), (16, 32));
|
||||
assert_eq!(sampling.pixels_at_cap, 16 * 42 / 2);
|
||||
assert_eq!(sampling.production_samples, 16 * 42 * 24);
|
||||
assert_eq!(sampling.primary_path_evaluations, 16 * 42 * 40);
|
||||
budgets
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
return (
|
||||
png,
|
||||
task.take_linear_exr().unwrap(),
|
||||
report,
|
||||
task.take_denoise_mask().unwrap(),
|
||||
task.sampling_report(),
|
||||
previews,
|
||||
raw,
|
||||
budgets,
|
||||
);
|
||||
}
|
||||
}
|
||||
panic!("bounded page-equivalence fixture did not complete");
|
||||
};
|
||||
let single = render(None);
|
||||
let paged = render(Some(8));
|
||||
assert_eq!(single.0, paged.0, "final PNG adaptive={adaptive}");
|
||||
assert_eq!(single.1, paged.1, "raw EXR adaptive={adaptive}");
|
||||
assert_eq!(single.2, paged.2, "exact category counts adaptive={adaptive}");
|
||||
assert_eq!(single.3, paged.3, "X2 categorical mask adaptive={adaptive}");
|
||||
assert_eq!(single.4, paged.4, "adaptive per-pixel budgets adaptive={adaptive}");
|
||||
assert_eq!(single.5, paged.5, "completed checkpoint PNGs adaptive={adaptive}");
|
||||
assert_eq!(single.6, paged.6, "raw float film adaptive={adaptive}");
|
||||
assert_eq!(single.7, paged.7, "per-pixel stopped/unfinished production budgets adaptive={adaptive}");
|
||||
assert_eq!(paged.5.iter().map(|preview| preview.0).collect::<Vec<_>>(), (1..options.preview_target().unwrap()).collect::<Vec<_>>());
|
||||
println!(
|
||||
"hardware page equivalence: adaptive={adaptive} pages=6 last_rows=2 halo=14 previews={} raw/png/mask/category/sample-report exact",
|
||||
paged.5.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_paged_partial_pass_cancellation_publishes_no_preview_or_final_artifact() {
|
||||
let Some(context) = context() else { return };
|
||||
let token = CancelToken::default();
|
||||
let image = ImageRequest { width: 8, height: 20, ..request(8) };
|
||||
let options = JobOptions { samples: 2, supersampling: 2, denoise: true, denoise_diagnostics: true, linear_exr: true, ..Default::default() };
|
||||
let mut task = Task::new_with_page_rows(context, plane(), image, options, token.clone(), Some(8)).unwrap();
|
||||
task.preview_enabled = true;
|
||||
assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
|
||||
assert!(task.offset > 0);
|
||||
assert_eq!(task.sample, 0);
|
||||
assert!(task.take_preview().is_none());
|
||||
token.cancel();
|
||||
assert_eq!(task.step(1).err().unwrap().code, solidcraft_render::job::ErrorCode::Cancelled);
|
||||
assert!(task.take_preview().is_none());
|
||||
assert!(task.take_linear_exr().is_none());
|
||||
assert!(task.take_denoise_mask().is_none());
|
||||
}
|
||||
|
||||
@@ -9,6 +9,29 @@ use solidcraft_render::job::{
|
||||
photographic::{Flare, Photographic},
|
||||
};
|
||||
|
||||
// Validate each live receipt before comparing deterministic accounting. RAM
|
||||
// snapshots and other retained leases can change between otherwise equal checks.
|
||||
fn preflight_without_memory_observations(mut reply: serde_json::Value) -> serde_json::Value {
|
||||
let total = reply["admission"]["estimated_total_bytes"].as_u64().unwrap();
|
||||
let memory = reply["admission"]["host_memory"].as_object_mut().expect("native preflight reports its RAM admission");
|
||||
let required = memory["required_bytes"].as_u64().unwrap();
|
||||
let admitted = memory["admitted_bytes"].as_u64().unwrap();
|
||||
let available = memory["available_bytes"].as_u64().unwrap();
|
||||
let capacity = memory["capacity_bytes"].as_u64().unwrap();
|
||||
assert_eq!(required, total, "native debit includes the complete public admission");
|
||||
assert!(required <= available, "accepted native debit fits its available RAM: {memory:?}");
|
||||
assert_eq!(admitted.checked_add(available), Some(capacity), "native ledger accounts for the entire capacity snapshot");
|
||||
assert!(
|
||||
matches!(memory["source"].as_str(), Some("system_available" | "system_and_cgroup" | "conservative_fallback")),
|
||||
"native receipt identifies its capacity source"
|
||||
);
|
||||
// Keep required_bytes and every non-observation field in the exact comparison.
|
||||
for field in ["admitted_bytes", "available_bytes", "capacity_bytes", "source"] {
|
||||
memory.remove(field).unwrap();
|
||||
}
|
||||
reply
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_targeted_aging_reaches_preflight_and_start_after_cold_reopen() {
|
||||
let Some(executor) = executor() else { return };
|
||||
@@ -48,7 +71,10 @@ fn saved_targeted_aging_reaches_preflight_and_start_after_cold_reopen() {
|
||||
assert_eq!(runtime_target["body_name"], "Target panel");
|
||||
assert_ne!(runtime_target["body_index"], original["options"]["aging"][0]["target"]["body_index"]);
|
||||
let saved = session.doc.render.setups["Aging"].clone();
|
||||
assert_eq!(checked, session.execute("render.preflight", &json!({"settings":saved})).unwrap());
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone()),
|
||||
preflight_without_memory_observations(session.execute("render.preflight", &json!({"settings":saved})).unwrap())
|
||||
);
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
|
||||
let started = session.execute("render.start", &json!({"setup":"Aging"})).unwrap();
|
||||
@@ -294,7 +320,10 @@ fn saved_aging_identity_reaches_real_gpu_after_cold_reopen() {
|
||||
assert_ne!(first["options"]["aging"][0]["target"]["body_index"], checked["options"]["aging"][0]["target"]["body_index"]);
|
||||
assert_eq!(checked["options"]["aging"][0]["target"]["body_name"], "Target panel");
|
||||
let saved = session.doc.render.setups["Aging"].clone();
|
||||
assert_eq!(checked, session.execute("render.preflight", &json!({"settings":saved})).unwrap());
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone()),
|
||||
preflight_without_memory_observations(session.execute("render.preflight", &json!({"settings":saved})).unwrap())
|
||||
);
|
||||
let physical = session.execute("component.properties", &json!({})).unwrap();
|
||||
let before = session.doc.clone();
|
||||
let history = (session.revision, session.undo.len(), session.redo.len());
|
||||
@@ -594,7 +623,12 @@ fn scratch_geometry_reference(@builtin(global_invocation_id) gid:vec3<u32>){
|
||||
let carry = f32::from_le_bytes(parameters[carry_offset..carry_offset + 4].try_into().unwrap());
|
||||
let expected = stack.gpu_layers([target.x, target.y, target.z]).unwrap()[0][11];
|
||||
assert_eq!(carry.to_bits(), expected.to_bits());
|
||||
assert_eq!(¶meters[parameters.len() - 4..], &[0; 4], "the determinant materialization lane remains positive zero");
|
||||
// studio_weights.w precedes the sixteen-byte page metadata.
|
||||
assert_eq!(
|
||||
¶meters[parameters.len() - 20..parameters.len() - 16],
|
||||
&[0; 4],
|
||||
"the determinant materialization lane remains positive zero"
|
||||
);
|
||||
context.queue.write_buffer(&task.uniform, 0, ¶meters);
|
||||
let mut encoder = context.device.create_command_encoder(&Default::default());
|
||||
{
|
||||
@@ -823,7 +857,10 @@ fn saved_coat_gpu_capture_reopen_and_actual_render_match_independent_normal_inci
|
||||
let mut plain = saved.clone();
|
||||
plain.coats = Default::default();
|
||||
let baseline_check = session.execute("render.preflight", &json!({"settings":plain})).unwrap();
|
||||
assert_eq!(checked["admission"], baseline_check["admission"]);
|
||||
assert_eq!(
|
||||
preflight_without_memory_observations(checked.clone())["admission"],
|
||||
preflight_without_memory_observations(baseline_check.clone())["admission"]
|
||||
);
|
||||
assert_eq!(checked["retained_options_bytes"], baseline_check["retained_options_bytes"]);
|
||||
let mut zero = saved;
|
||||
let row = zero.coats.iter().next().unwrap().clone();
|
||||
|
||||
@@ -85,7 +85,7 @@ fn glass_importance_normal(incident:vec3<f32>,outgoing:vec3<f32>,normal:vec3<f32
|
||||
return correction;
|
||||
}
|
||||
// One uniformly retained hard-light vertex keeps native optical visibility at
|
||||
// one call site with96 bytes of private payload. For K<=16 eligible camera
|
||||
// one call site with96 bytes of private payload. For K<=64 eligible camera
|
||||
// vertices, K*prefix[J]*direct[J] estimates their complete sum. Selection uses
|
||||
// its own RNG, leaving transport draws unchanged. Null/depth exits retain the
|
||||
// estimator; multi-vertex map-off paths acquire additional sampling variance.
|
||||
@@ -97,7 +97,7 @@ fn glass_offer_light(lights:ptr<function,array<vec4<u32>,6>>,count:ptr<function,
|
||||
let studio=u.environment.w>0.0;
|
||||
if select(3u,u32(u.light_count.x)+u32(u.light_count.y),studio)==0u {return;}
|
||||
let index=*count;
|
||||
if index>=16u || index>=u.sampling.y {atomicOr(&failures[7],8u);return;}
|
||||
if index>=64u || index>=u.sampling.y {atomicOr(&failures[7],8u);return;}
|
||||
*count=index+1u;
|
||||
if index>0u && random(selection_rng)>=1.0/f32(index+1u) {return;}
|
||||
(*lights)[0]=vec4<u32>(bitcast<vec3<u32>>(point.hi),source_triangle);
|
||||
@@ -112,7 +112,7 @@ fn glass_offer_light(lights:ptr<function,array<vec4<u32>,6>>,count:ptr<function,
|
||||
// Film planes precede this scratch and retain their historical offsets. Integer
|
||||
// metadata uses exact numeric f32 encoding rather than subnormal bit patterns.
|
||||
fn glass_tile_address(pixel:u32)->u32 {
|
||||
return u.image.x*u.image.y*select(1u,3u,u.adaptive_budget.x>0u)+(pixel-u.image.w)*8u;
|
||||
return film_pixels()*select(1u,3u,u.adaptive_budget.x>0u)+(pixel-u.image.w)*8u;
|
||||
}
|
||||
fn glass_capture_light(lights:ptr<function,array<vec4<u32>,6>>,count:u32,wavelength:f32,pixel:u32) {
|
||||
let address=glass_tile_address(pixel);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Row-aligned GPU storage pages and filter windows. Page boundaries change
|
||||
//! resource residency only; camera/sample identities remain full-image indices.
|
||||
use super::{FAILURE_BYTES, JobError, JobOptions, Result};
|
||||
use solidcraft_render::job::{ErrorCode, MAX_TRACE_DIMENSION, MAX_TRACE_PIXELS};
|
||||
|
||||
// A working-set target, not an image-size limit: larger images use more pages.
|
||||
// Actual adapter binding/allocation limits can make pages smaller.
|
||||
pub(super) const TARGET_BUFFER_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Layout {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rows: u32,
|
||||
pub halo: u32,
|
||||
pub planes: u32,
|
||||
pub optical_bytes: u64,
|
||||
filter: bool,
|
||||
adaptive: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Window {
|
||||
pub start: u32,
|
||||
pub pixels: u32,
|
||||
pub core_start: u32,
|
||||
pub core_pixels: u32,
|
||||
}
|
||||
|
||||
fn limit(message: &str) -> JobError {
|
||||
JobError::new(ErrorCode::LimitExceeded, message)
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
pub(super) fn new(width: u32, height: u32, options: JobOptions, optical_bytes: u64, limits: &wgpu::Limits) -> Result<Self> {
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| width > MAX_TRACE_DIMENSION
|
||||
|| height > MAX_TRACE_DIMENSION
|
||||
|| u64::from(width) * u64::from(height) > MAX_TRACE_PIXELS
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "GPU image pages require a bounded nonempty trace grid"));
|
||||
}
|
||||
options.denoise_settings.validate()?;
|
||||
let adaptive = options.adaptive.is_some();
|
||||
let planes = if adaptive { 3 } else { 1 };
|
||||
let filter = options.denoise || options.denoise_diagnostics;
|
||||
let halo = if options.denoise { 2 * ((1 << options.denoise_settings.passes) - 1) } else { 0 };
|
||||
let storage = limits.max_storage_buffer_binding_size.min(limits.max_buffer_size).min(TARGET_BUFFER_BYTES);
|
||||
let film_row = u64::from(width) * 16 * u64::from(planes);
|
||||
let storage_rows = storage.checked_sub(optical_bytes).ok_or_else(|| limit("GPU storage cannot fit optical dispatch scratch"))? / film_row;
|
||||
let readback_rows = limits.max_buffer_size.checked_sub(FAILURE_BYTES).ok_or_else(|| limit("GPU readback cannot fit diagnostics"))?
|
||||
/ (u64::from(width) * 16 * if adaptive { 2 } else { 1 });
|
||||
let mut rows = u64::from(height).min(storage_rows).min(readback_rows);
|
||||
if filter {
|
||||
let window_rows = storage / (u64::from(width) * super::denoise::GUIDE_BYTES_PER_PIXEL);
|
||||
if window_rows < u64::from(height) {
|
||||
let core_rows = window_rows
|
||||
.checked_sub(u64::from(halo) * 2)
|
||||
.ok_or_else(|| limit("GPU storage cannot fit one denoising row with its complete filter halo"))?;
|
||||
rows = rows.min(core_rows);
|
||||
}
|
||||
}
|
||||
// Each diagnostic core owns complete supersampling cells.
|
||||
if rows < u64::from(height) {
|
||||
let scale = u64::from(options.supersampling);
|
||||
if scale == 0 {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "GPU image page supersampling must be nonzero"));
|
||||
}
|
||||
rows -= rows % scale;
|
||||
}
|
||||
let rows = u32::try_from(rows).map_err(|_| limit("GPU image page row count exceeds its address space"))?;
|
||||
if rows == 0 {
|
||||
return Err(limit("GPU storage cannot fit one complete image row and its required scratch"));
|
||||
}
|
||||
let layout = Self { width, height, rows, halo, planes, optical_bytes, filter, adaptive };
|
||||
if layout.buffer_bytes(layout.max_core_pixels())? > storage || layout.readback_bytes()? > limits.max_buffer_size {
|
||||
return Err(limit("GPU image page allocation exceeds the selected adapter limits"));
|
||||
}
|
||||
Ok(layout)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn with_rows(self, rows: u32, scale: u32) -> Result<Self> {
|
||||
let rows = rows.min(self.rows);
|
||||
if rows == 0 || scale == 0 || (rows < self.height && !rows.is_multiple_of(scale)) {
|
||||
return Err(limit("test GPU page rows must contain complete supersampling cells"));
|
||||
}
|
||||
Ok(Self { rows, ..self })
|
||||
}
|
||||
|
||||
pub(super) fn is_paged(self) -> bool {
|
||||
self.rows < self.height
|
||||
}
|
||||
|
||||
pub(super) fn page_count(self) -> u32 {
|
||||
self.height.div_ceil(self.rows)
|
||||
}
|
||||
|
||||
pub(super) fn max_core_pixels(self) -> u32 {
|
||||
self.width * self.rows
|
||||
}
|
||||
|
||||
pub(super) fn max_window_pixels(self) -> u32 {
|
||||
self.width * self.height.min(self.rows + self.halo * 2)
|
||||
}
|
||||
|
||||
pub(super) fn buffer_bytes(self, pixels: u32) -> Result<u64> {
|
||||
u64::from(pixels)
|
||||
.checked_mul(16 * u64::from(self.planes))
|
||||
.and_then(|bytes| bytes.checked_add(self.optical_bytes))
|
||||
.ok_or_else(|| limit("GPU image page byte count overflow"))
|
||||
}
|
||||
|
||||
pub(super) fn optical_scratch_bytes(self) -> Result<u64> {
|
||||
self.optical_bytes.checked_mul(u64::from(self.page_count())).ok_or_else(|| limit("GPU optical page scratch overflow"))
|
||||
}
|
||||
|
||||
pub(super) fn readback_bytes(self) -> Result<u64> {
|
||||
let film = u64::from(self.max_core_pixels()) * 16 * if self.adaptive { 2 } else { 1 };
|
||||
let filter = if self.filter { u64::from(self.max_window_pixels()) * 16 } else { 0 };
|
||||
film.max(filter).checked_add(FAILURE_BYTES).ok_or_else(|| limit("GPU page readback byte count overflow"))
|
||||
}
|
||||
|
||||
pub(super) fn filter_scratch_bytes(self) -> Result<u64> {
|
||||
if !self.filter {
|
||||
return Ok(0);
|
||||
}
|
||||
let normalized = if self.adaptive { 16 } else { 0 };
|
||||
let gather = if self.is_paged() { 16 * u64::from(self.planes) } else { 0 };
|
||||
u64::from(self.max_window_pixels())
|
||||
.checked_mul(super::denoise::BYTES_PER_PIXEL + normalized + gather)
|
||||
.ok_or_else(|| limit("GPU filter window byte count overflow"))
|
||||
}
|
||||
|
||||
pub(super) fn windows(self) -> impl Iterator<Item = Window> {
|
||||
(0..self.height).step_by(self.rows as usize).map(move |row| {
|
||||
let end = self.height.min(row + self.rows);
|
||||
let first = row.saturating_sub(self.halo);
|
||||
let last = self.height.min(end + self.halo);
|
||||
Window {
|
||||
start: first * self.width,
|
||||
pixels: (last - first) * self.width,
|
||||
core_start: row * self.width,
|
||||
core_pixels: (end - row) * self.width,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct FilmPage {
|
||||
pub start: u32,
|
||||
pub pixels: u32,
|
||||
pub accumulation: wgpu::Buffer,
|
||||
pub bind: wgpu::BindGroup,
|
||||
}
|
||||
@@ -1,109 +1,157 @@
|
||||
//! Conservative, bounded convex-parent nesting certificate. AABB containment is
|
||||
//! only a candidate; parent convexity and child vertices are checked against
|
||||
//! every outward parent half-space. Intersections and touching boundaries refuse.
|
||||
//! Exact retained-f32 surface contact and oriented containment. Bounds accelerate
|
||||
//! candidate selection; disjoint overlapping bounds never imply nested media.
|
||||
use super::scene::{Triangle, reserved};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_render::job::{CancelToken, ErrorCode, JobError, Result};
|
||||
use solidcraft_render::{
|
||||
job::{CancelToken, ErrorCode, JobError, Result},
|
||||
optical_boundary::{ContactIndex, forbidden_contact, origin_crossing},
|
||||
};
|
||||
use std::ops::Range;
|
||||
|
||||
pub(super) struct Region {
|
||||
pub(super) struct Region<'a> {
|
||||
pub lo: Vec3,
|
||||
pub hi: Vec3,
|
||||
pub closed: bool,
|
||||
pub triangles: Range<usize>,
|
||||
pub medium: u32,
|
||||
pub name: &'a str,
|
||||
}
|
||||
fn invalid(message: &str) -> JobError {
|
||||
JobError::new(ErrorCode::InvalidScene, message)
|
||||
}
|
||||
fn disjoint(a: &Region, b: &Region) -> bool {
|
||||
fn disjoint(a: &Region<'_>, b: &Region<'_>) -> bool {
|
||||
a.hi.x < b.lo.x || b.hi.x < a.lo.x || a.hi.y < b.lo.y || b.hi.y < a.lo.y || a.hi.z < b.lo.z || b.hi.z < a.lo.z
|
||||
}
|
||||
fn box_contains(a: &Region, b: &Region) -> bool {
|
||||
a.lo.x <= b.lo.x && a.lo.y <= b.lo.y && a.lo.z <= b.lo.z && a.hi.x >= b.hi.x && a.hi.y >= b.hi.y && a.hi.z >= b.hi.z
|
||||
fn tick(work: &mut u64, cancel: &CancelToken) -> Result<()> {
|
||||
cancel.check()?;
|
||||
*work = work.checked_add(1).ok_or_else(|| invalid("optical certificate work overflow"))?;
|
||||
if *work > 1_000_000_000 {
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "optical surface certificate exceeds one billion bounded checks"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn check_halfspaces(parent: &[Triangle], child: &[Triangle], strict: bool, work: &mut u64, cancel: &CancelToken) -> Result<()> {
|
||||
for face in parent {
|
||||
let normal = (face.p[1] - face.p[0]).cross(face.p[2] - face.p[0]);
|
||||
let length = normal.len();
|
||||
if !length.is_finite() || length <= 1e-12 {
|
||||
return Err(invalid("invalid parent medium plane"));
|
||||
fn winding(region: &Region<'_>, triangles: &[Triangle], point: Vec3, work: &mut u64, cancel: &CancelToken) -> Result<bool> {
|
||||
let mut sums = [0_i32; 3];
|
||||
// The topology certificate allows numerical CAD seams, while these tests
|
||||
// use the uploaded coordinates. A ray can escape through such a seam.
|
||||
// Cyclic permutations retain orientation and check all three axes without
|
||||
// moving geometry or perturbing the containment sample.
|
||||
let rotate = |p: Vec3, axis| match axis {
|
||||
0 => p,
|
||||
1 => Vec3::new(p.y, p.z, p.x),
|
||||
_ => Vec3::new(p.z, p.x, p.y),
|
||||
};
|
||||
for triangle in triangles.get(region.triangles.clone()).ok_or_else(|| invalid("missing optical containment boundary"))? {
|
||||
tick(work, cancel)?;
|
||||
for (axis, sum) in sums.iter_mut().enumerate() {
|
||||
*sum += i32::from(origin_crossing(triangle.p.map(|p| rotate(p, axis)), rotate(point, axis))?);
|
||||
}
|
||||
let n = normal / length;
|
||||
for triangle in child {
|
||||
for point in triangle.p {
|
||||
*work += 1;
|
||||
if (*work).is_multiple_of(2048) {
|
||||
cancel.check()?;
|
||||
}
|
||||
if *work > 50_000_000 {
|
||||
return Err(JobError::new(
|
||||
ErrorCode::LimitExceeded,
|
||||
"nested-medium half-space validation exceeds 50 million bounded checks; simplify only the selected optical boundary explicitly",
|
||||
));
|
||||
}
|
||||
let scale = point.x.abs().max(point.y.abs()).max(point.z.abs()).max(1.0);
|
||||
let tolerance = (scale * f64::from(f32::EPSILON) * 4.0).max(2e-6);
|
||||
let distance = (point - face.p[0]).dot(n);
|
||||
if !distance.is_finite() || (strict && distance >= -tolerance) || (!strict && distance > 2e-6) {
|
||||
return Err(invalid(if strict {
|
||||
"nested dielectric is not strictly inside the certified convex parent; touching or intersecting surfaces are unsupported"
|
||||
} else {
|
||||
"nested-medium parent must be geometrically convex; bounding-box containment is not a certificate"
|
||||
}));
|
||||
}
|
||||
}
|
||||
if sums.iter().any(|sum| *sum != sums[0]) {
|
||||
return Err(invalid(&format!("inconsistent optical containment across uploaded seams for {}", region.name)));
|
||||
}
|
||||
match sums[0] {
|
||||
0 => Ok(false),
|
||||
1 => Ok(true),
|
||||
_ => Err(invalid(&format!("ambiguous optical containment winding for {}", region.name))),
|
||||
}
|
||||
}
|
||||
/// Certify all optical self/cross contacts with the actual uploaded coordinates.
|
||||
/// This covers nonconvex surfaces without a triangle-pair scan.
|
||||
fn contacts(regions: &[Region<'_>], triangles: &[Triangle], work: &mut u64, cancel: &CancelToken) -> Result<()> {
|
||||
let count = regions.iter().try_fold(0_usize, |n, r| n.checked_add(r.triangles.len()).ok_or_else(|| invalid("optical contact count overflow")))?;
|
||||
let mut ordinals = reserved(count)?;
|
||||
for region in regions {
|
||||
for i in region.triangles.clone() {
|
||||
if ordinals.len().is_multiple_of(1024) {
|
||||
cancel.check()?;
|
||||
}
|
||||
ordinals.push(i);
|
||||
}
|
||||
}
|
||||
let mut index = ContactIndex::new(count)?;
|
||||
while !index.step(|i| ordinals.get(i).and_then(|at| triangles.get(*at)).map(|t| t.p), cancel)? {
|
||||
tick(work, cancel)?;
|
||||
}
|
||||
for (i, at) in ordinals.iter().enumerate() {
|
||||
let a = triangles.get(*at).ok_or_else(|| invalid("missing optical contact face"))?;
|
||||
let mut query = index.query(a.p)?;
|
||||
loop {
|
||||
tick(work, cancel)?;
|
||||
let Some(candidate) = index.query_step(&mut query, cancel)? else { break };
|
||||
let Some(j) = candidate.filter(|j| *j > i) else { continue };
|
||||
let other = *ordinals.get(j).ok_or_else(|| invalid("missing optical contact ordinal"))?;
|
||||
let b = triangles.get(other).ok_or_else(|| invalid("missing optical contact candidate"))?;
|
||||
if forbidden_contact(a.p, b.p, a.medium == b.medium) {
|
||||
let identify = |id, triangle| {
|
||||
regions.iter().find(|r| r.medium == id).map_or_else(
|
||||
|| format!("unknown triangle {triangle}"),
|
||||
|region| format!("{} triangle {}", region.name, triangle - region.triangles.start),
|
||||
)
|
||||
};
|
||||
return Err(invalid(&format!(
|
||||
"glass boundaries intersect, touch, overlap or self-intersect: {} / {}",
|
||||
identify(a.medium, *at),
|
||||
identify(b.medium, other)
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
cancel.check()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return immediate parent medium identities, with zero for air, independent of
|
||||
/// body order. A maximum of 16 containing media matches the device stack exactly.
|
||||
pub(super) fn parents(regions: &[Region], triangles: &[Triangle], allow_nested: bool, cancel: &CancelToken) -> Result<Vec<(u32, u32)>> {
|
||||
if regions.len() > 32 {
|
||||
return Err(invalid("at most 32 dielectric bodies are supported"));
|
||||
/// Immediate parent identities are independent of body order. Externally
|
||||
/// disjoint interlocking boundaries remain children of air. Actual containment
|
||||
/// retains the explicit nesting and disabled-shadow-shortcut requirements.
|
||||
pub(super) fn parents(regions: &[Region<'_>], triangles: &[Triangle], allow_nested: bool, cancel: &CancelToken) -> Result<Vec<(u32, u32)>> {
|
||||
if regions.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut contains = [[false; 32]; 32];
|
||||
let mut convex = [false; 32];
|
||||
if regions.len() > solidcraft_render::job::MAX_GPU_DIELECTRICS {
|
||||
return Err(invalid("at most 128 dielectric bodies are supported"));
|
||||
}
|
||||
let mut contains = [[false; 128]; 128];
|
||||
let mut work = 0;
|
||||
contacts(regions, triangles, &mut work, cancel)?;
|
||||
for (i, a) in regions.iter().enumerate() {
|
||||
for (j, b) in regions.iter().enumerate().skip(i + 1) {
|
||||
cancel.check()?;
|
||||
tick(&mut work, cancel)?;
|
||||
if disjoint(a, b) || (!a.closed && !b.closed) {
|
||||
continue;
|
||||
}
|
||||
if !allow_nested {
|
||||
return Err(invalid("nested or overlapping dielectric bounds require explicit nested_media and a containment certificate"));
|
||||
}
|
||||
if !a.closed || !b.closed {
|
||||
return Err(invalid("a thin-sheet boundary may not overlap a closed nested medium"));
|
||||
}
|
||||
let (parent, child, pi, ci) = if box_contains(a, b) {
|
||||
(a, b, i, j)
|
||||
} else if box_contains(b, a) {
|
||||
(b, a, j, i)
|
||||
} else {
|
||||
return Err(invalid("dielectric overlap is not a strict containment hierarchy"));
|
||||
};
|
||||
let parent_triangles = triangles.get(parent.triangles.clone()).ok_or_else(|| invalid("missing parent triangles"))?;
|
||||
let child_triangles = triangles.get(child.triangles.clone()).ok_or_else(|| invalid("missing child triangles"))?;
|
||||
if !convex[pi] {
|
||||
check_halfspaces(parent_triangles, parent_triangles, false, &mut work, cancel)?;
|
||||
convex[pi] = true;
|
||||
let ap = triangles.get(a.triangles.start).ok_or_else(|| invalid("missing optical containment sample"))?.p[0];
|
||||
let bp = triangles.get(b.triangles.start).ok_or_else(|| invalid("missing optical containment sample"))?.p[0];
|
||||
let ab = winding(a, triangles, bp, &mut work, cancel)?;
|
||||
let ba = winding(b, triangles, ap, &mut work, cancel)?;
|
||||
if ab && ba {
|
||||
return Err(invalid("cyclic optical containment"));
|
||||
}
|
||||
check_halfspaces(parent_triangles, child_triangles, true, &mut work, cancel)?;
|
||||
if parent_triangles.iter().chain(child_triangles).any(|t| t.transmission.is_some_and(|m| m.shadow_transmission())) {
|
||||
if !ab && !ba {
|
||||
continue;
|
||||
}
|
||||
if !allow_nested {
|
||||
return Err(invalid("nested dielectric bounds require explicit nested_media and a containment certificate"));
|
||||
}
|
||||
if triangles[a.triangles.clone()]
|
||||
.iter()
|
||||
.chain(triangles[b.triangles.clone()].iter())
|
||||
.any(|t| t.transmission.is_some_and(|m| m.shadow_transmission()))
|
||||
{
|
||||
return Err(invalid("nested media require disabled straight shadow transmission; true refracted paths remain active"));
|
||||
}
|
||||
contains[pi][ci] = true;
|
||||
if ab {
|
||||
contains[i][j] = true;
|
||||
} else {
|
||||
contains[j][i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut result = reserved(regions.len())?;
|
||||
for (child, region) in regions.iter().enumerate() {
|
||||
let depth = contains.iter().filter(|parents| parents[child]).count();
|
||||
if depth >= 16 {
|
||||
if depth >= solidcraft_render::job::MAX_NESTED_MEDIA {
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "at most 16 nested dielectric layers are supported"));
|
||||
}
|
||||
let mut parent = 0;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Original directional-loss fit for SolidCraft's separable Smith GGX. Keep the
|
||||
// coefficients and cosine moments in sync with render/src/surface.rs.
|
||||
struct MetalPolynomial { low: vec4<f32>, high: vec4<f32> };
|
||||
fn metal_chebyshev(x: f32) -> MetalPolynomial {
|
||||
let x2 = 2.0*x*x-1.0;
|
||||
let x3 = 2.0*x*x2-x;
|
||||
let x4 = 2.0*x*x3-x2;
|
||||
let x5 = 2.0*x*x4-x3;
|
||||
let x6 = 2.0*x*x5-x4;
|
||||
let x7 = 2.0*x*x6-x5;
|
||||
return MetalPolynomial(vec4<f32>(1.0,x,x2,x3), vec4<f32>(x4,x5,x6,x7));
|
||||
}
|
||||
fn metal_loss_curve(roughness: f32) -> MetalPolynomial {
|
||||
let rough = metal_chebyshev(2.0*(roughness-0.35)/0.65-1.0);
|
||||
return MetalPolynomial(
|
||||
vec4<f32>(
|
||||
dot(rough.low, vec4<f32>(0.266099230744, 0.234143736162, 0.030578608252, -0.006555488201))
|
||||
+ dot(rough.high.xy, vec2<f32>(-0.001408821529, 0.000338338127)) - 0.003,
|
||||
dot(rough.low, vec4<f32>(0.062815590935, 0.109326249066, -0.000168741912, -0.008651318409))
|
||||
+ dot(rough.high.xy, vec2<f32>(-0.000234806591, 0.000537478808)),
|
||||
dot(rough.low, vec4<f32>(-0.012483235801, 0.028318374577, 0.005800323841, -0.006920257497))
|
||||
+ dot(rough.high.xy, vec2<f32>(0.000309127847, 0.000486378250)),
|
||||
dot(rough.low, vec4<f32>(-0.004382182237, -0.011917917997, 0.008966558426, -0.002826443547))
|
||||
+ dot(rough.high.xy, vec2<f32>(-0.000881066054, 0.000652337664))
|
||||
),
|
||||
vec4<f32>(
|
||||
dot(rough.low, vec4<f32>(0.004792378381, -0.005261311499, 0.000874914479, 0.001861663053))
|
||||
+ dot(rough.high.xy, vec2<f32>(-0.001449625147, 0.000284442998)),
|
||||
dot(rough.low, vec4<f32>(-0.001334206261, 0.003343183987, -0.002951722839, 0.001555479216))
|
||||
+ dot(rough.high.xy, vec2<f32>(-0.000159594280, -0.000303749560)),
|
||||
dot(rough.low, vec4<f32>(-0.000540498387, 0.000522348781, 0.000261748019, -0.000812746047))
|
||||
+ dot(rough.high.xy, vec2<f32>(0.000729308912, -0.000316627573)),
|
||||
dot(rough.low, vec4<f32>(0.000576930968, -0.001089750952, 0.000855587383, -0.000437065280))
|
||||
+ dot(rough.high.xy, vec2<f32>(0.000037708235, 0.000134962401))
|
||||
)
|
||||
);
|
||||
}
|
||||
fn metal_directional_loss(curve: MetalPolynomial, mu: f32) -> f32 {
|
||||
let direction = metal_chebyshev(2.0*sqrt(mu)-1.0);
|
||||
return dot(curve.low,direction.low)+dot(curve.high,direction.high);
|
||||
}
|
||||
fn metal_multiple_scattering(color: vec3<f32>, metallic: f32, roughness: f32, nv: f32, nl: f32) -> vec3<f32> {
|
||||
let curve = metal_loss_curve(roughness);
|
||||
let mean_loss = dot(curve.low, vec4<f32>(1.000000000000, 0.600000000000, -0.066666666667, -0.314285714286))
|
||||
+ dot(curve.high, vec4<f32>(-0.219047619048, -0.111111111111, -0.066666666667, -0.046464646465));
|
||||
let strength = smoothstep(0.35,0.45,roughness);
|
||||
let lobe = metallic*strength*metal_directional_loss(curve,nv)*metal_directional_loss(curve,nl)/(PI*mean_loss);
|
||||
let mean_fresnel = color+(vec3<f32>(1.0)-color)/21.0;
|
||||
return lobe*mean_fresnel*mean_fresnel*(1.0-mean_loss)/(vec3<f32>(1.0)-mean_fresnel*mean_loss);
|
||||
}
|
||||
+570
-228
File diff suppressed because it is too large
Load Diff
@@ -469,6 +469,171 @@ fn origin_vacuum(o:vec3<f32>,d:vec3<f32>)->bool {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_photon_origin_uses_optical_bounds_with_low_parts_and_all_proposal_layouts() {
|
||||
use solidcraft_render::job::CausticOptions;
|
||||
let source = [
|
||||
photons::shader_source().unwrap(),
|
||||
r#"
|
||||
@compute @workgroup_size(1) fn photon_origin_reference() {
|
||||
let boundary=photon_origin_boundary(u.eye.xyz,u.right.xyz,u.up.xyz);
|
||||
var media:array<Medium,16>;var count=0u;var valid=boundary.valid;
|
||||
if valid && boundary.hit.triangle!=0xffffffffu {valid=initialize_media(boundary.hit.triangle,u.up.xyz,&media,&count);}
|
||||
let legacy=origin_boundary(u.eye.xyz+u.right.xyz,u.up.xyz);
|
||||
let root=nodes[0];let point=u.eye.xyz+u.right.xyz;
|
||||
accumulation[0]=vec4<f32>(select(0.0,1.0,valid),f32(count),select(0.0,1.0,boundary.hit.triangle!=0xffffffffu),
|
||||
select(0.0,1.0,photon_origin_outside_optics(u.eye.xyz,u.right.xyz)));
|
||||
accumulation[1]=vec4<f32>(select(0.0,1.0,legacy.valid),select(0.0,1.0,all(point>=root.lo) && all(point<=root.hi)),0.0,1.0);
|
||||
}
|
||||
"#
|
||||
.to_owned(),
|
||||
]
|
||||
.concat();
|
||||
let parsed = naga::front::wgsl::parse_str(&source).unwrap_or_else(|error| panic!("{}", error.emit_to_string(&source)));
|
||||
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty())
|
||||
.validate(&parsed)
|
||||
.unwrap_or_else(|error| panic!("{}", error.emit_to_string(&source)));
|
||||
let Some(executor) = executor() else { return };
|
||||
let context = &executor.context;
|
||||
let pipelines = photons::Pipelines::shared(context).unwrap();
|
||||
let pipeline = checked_creation(&context.device, || {
|
||||
let shader = context.device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("photon physical origin certificate"),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let layout = context.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &[Some(&pipelines.layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
Ok(context.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
module: &shader,
|
||||
entry_point: Some("photon_origin_reference"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
}))
|
||||
})
|
||||
.unwrap();
|
||||
let mut nested_scene = nested();
|
||||
let mut opaque = directional_receiver().meshes.remove(0);
|
||||
for p in &mut Arc::make_mut(&mut opaque.mesh).positions {
|
||||
p.x *= 2.0;
|
||||
p.y *= 2.0;
|
||||
p.z = 30.0;
|
||||
}
|
||||
nested_scene.bounds = nested_scene.bounds.union(&opaque.mesh.bounds());
|
||||
nested_scene.meshes.push(opaque);
|
||||
// OPUS map 0 / seed 81 / photon 465196: these four retained-f32 vertices
|
||||
// preserve both actual lens-rim faces, closed here as a tetrahedron. Their
|
||||
// nearest entry/exit depths tie along the captured ray while its physical
|
||||
// origin is independently outside every optical surface.
|
||||
let mut rim = glass_slab(false).meshes.remove(0);
|
||||
rim.mesh = Arc::new(Mesh {
|
||||
positions: vec![
|
||||
Vec3::new(-228.50514221191406, 20.0, 97.48429107666016),
|
||||
Vec3::new(-228.50514221191406, 29.0, 97.48429107666016),
|
||||
Vec3::new(-228.50514221191406, 29.0, 99.4035415649414),
|
||||
Vec3::new(-230.0750732421875, 28.104591369628906, 97.5528335571289),
|
||||
],
|
||||
normals: vec![Vec3::Z; 4],
|
||||
triangles: vec![[0, 1, 2], [2, 1, 3], [1, 0, 3], [0, 2, 3]],
|
||||
tri_face: vec![0, 1, 2, 3],
|
||||
..Default::default()
|
||||
});
|
||||
let mut opaque = directional_receiver().meshes.remove(0);
|
||||
for p in &mut Arc::make_mut(&mut opaque.mesh).positions {
|
||||
p.x *= 100.0;
|
||||
p.y *= 100.0;
|
||||
p.z = 0.0;
|
||||
}
|
||||
let rim_scene = ProductScene { bounds: rim.mesh.bounds().union(&opaque.mesh.bounds()), meshes: vec![rim, opaque] };
|
||||
for guided_emission in [false, true] {
|
||||
for power_weighted_sources in [false, true] {
|
||||
for (captured, mut scene) in [(false, nested_scene.clone()), (true, rim_scene.clone())] {
|
||||
for body in &mut scene.meshes {
|
||||
body.transmission = body.transmission.map(|optics| optics.with_shadow_transmission(false));
|
||||
}
|
||||
let mut task = Task::new(
|
||||
context.clone(),
|
||||
scene,
|
||||
request(2),
|
||||
JobOptions {
|
||||
nested_media: true,
|
||||
samples: 1,
|
||||
caustics: Some(CausticOptions { photons: 1, guided_emission, power_weighted_sources, ..Default::default() }),
|
||||
..Default::default()
|
||||
},
|
||||
CancelToken::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let epsilon = f64::from(task.epsilon);
|
||||
let cases = if captured {
|
||||
vec![(
|
||||
Vec3::new(317.0488586425781, 162.61300659179688, 62.66033935546875),
|
||||
Vec3::new(1.3214378668635618e-5, 6.820903763582464e-6, 1.460740236325364e-6),
|
||||
Vec3::new(-0.9693225026130676, -0.23739922046661377, 0.06368253380060196),
|
||||
true,
|
||||
0.0,
|
||||
true,
|
||||
true,
|
||||
)]
|
||||
} else {
|
||||
vec![
|
||||
(Vec3::new(10.0, 0.0, 20.0), Vec3::ZERO, -Vec3::Z, true, 0.0, true, true),
|
||||
(Vec3::new(10.0, 10.0, 20.0), Vec3::ZERO, -Vec3::Z, true, 0.0, true, true),
|
||||
(Vec3::new(0.25, 0.125, 0.0), Vec3::new(0.0, 0.0, 20.0), -Vec3::Z, true, 0.0, true, false),
|
||||
(Vec3::new(0.25, 0.125, 4.0), Vec3::new(0.0, 0.0, -2.5), -Vec3::Z, true, 1.0, false, false),
|
||||
(Vec3::new(0.25, 0.125, 0.0), Vec3::ZERO, -Vec3::Z, true, 2.0, false, false),
|
||||
(Vec3::new(0.25, 0.125, 4.0), Vec3::new(0.0, 0.0, -2.0), -Vec3::Z, false, 0.0, false, true),
|
||||
(Vec3::new(0.25, 0.125, -4.0), Vec3::new(0.0, 0.0, 2.0), Vec3::Z, false, 0.0, false, true),
|
||||
(Vec3::new(10.0, 0.0, 2.0), Vec3::ZERO, -Vec3::Z, false, 0.0, false, true),
|
||||
(Vec3::new(0.25, 0.125, 2.0), Vec3::new(0.0, 0.0, 2.0_f64.powi(-25)), -Vec3::Z, false, 0.0, false, true),
|
||||
(Vec3::new(0.25, 0.125, 2.0 + epsilon * 0.5), Vec3::ZERO, -Vec3::Z, true, 0.0, true, true),
|
||||
]
|
||||
};
|
||||
for (origin, low, direction, valid, count, outside, legacy_refusal) in cases {
|
||||
let mut params = task.parameters(1).unwrap();
|
||||
let mut values = Vec::new();
|
||||
for vector in [origin, low, direction] {
|
||||
scene::vector(&mut values, vector, 0.0);
|
||||
}
|
||||
params[32..80].copy_from_slice(&values);
|
||||
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 pixels = task.read_linear().unwrap();
|
||||
let detail = format!(
|
||||
"captured={captured} guided={guided_emission} weighted={power_weighted_sources} origin={origin:?} low={low:?}: {pixels:?}"
|
||||
);
|
||||
assert_eq!(pixels[0][0] != 0.0, valid, "{detail}");
|
||||
assert_eq!(pixels[0][3] != 0.0, outside, "{detail}");
|
||||
if valid {
|
||||
assert_eq!(pixels[0][1], count, "{detail}");
|
||||
}
|
||||
if outside {
|
||||
assert_eq!(pixels[0][2], 0.0, "a proven exterior bypasses the future query: {detail}");
|
||||
}
|
||||
if legacy_refusal {
|
||||
assert_eq!(pixels[1][0], 0.0, "regression must exercise the previous refusal: {detail}");
|
||||
}
|
||||
if outside && (captured || origin.z == 20.0) {
|
||||
assert_eq!(pixels[1][1], 1.0, "the unrelated opaque geometry defeats the whole-scene root shortcut: {detail}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn origin_emitter_fixture() -> ProductScene {
|
||||
let mut glass = slab_body("Pupil medium", Vec3::new(0.5, 1.0, 4.0), Vec3::new(-5.0, 0.0, 0.0), 1.0, Vec3::new(2.0_f64.ln(), 4.0_f64.ln(), 0.0));
|
||||
// The exact uploaded box is x[-10,0], y[-10,10], z[-2,2].
|
||||
@@ -636,3 +801,125 @@ fn gpu_boundary_origins_refuse_through_existing_diagnostics_and_non_nested_outsi
|
||||
assert!(legacy.iter().all(|p| p[0] > 0.0 && p[3] == 1.0));
|
||||
assert_eq!(legacy, render(true), "the extra opt-in query consumes no RNG and leaves an already supported outside path byte-identical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_128_disjoint_optics_are_independent_of_the_16_layer_medium_stack() {
|
||||
let mut source = ProductScene { meshes: Vec::new(), bounds: Aabb3::EMPTY };
|
||||
for index in 0..solidcraft_render::job::MAX_OPTICAL_TARGETS {
|
||||
source.meshes.push(slab_body(&format!("OPUS {index}"), Vec3::new(1.0, 1.0, 0.25), Vec3::new(index as f64 * 30.0, 0.0, 0.0), 1.0, Vec3::ZERO));
|
||||
}
|
||||
source.bounds = source.meshes.iter().fold(Aabb3::EMPTY, |bounds, body| bounds.union(&body.mesh.bounds()));
|
||||
assert!(scene::flatten(&source, Vec3::ZERO, &CancelToken::default()).is_ok());
|
||||
source.meshes.push(slab_body("one target too many", Vec3::new(1.0, 1.0, 0.25), Vec3::new(128.0 * 30.0, 0.0, 0.0), 1.0, Vec3::ZERO));
|
||||
assert!(scene::flatten(&source, Vec3::ZERO, &CancelToken::default()).err().unwrap().message.contains("128"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_thirty_gpu_optics_cross_60_interfaces_with_the_new_depth_budget() {
|
||||
let Some(executor) = executor() else { return };
|
||||
let mut source = ProductScene { meshes: Vec::new(), bounds: Aabb3::EMPTY };
|
||||
for index in 0..30 {
|
||||
source.meshes.push(slab_body(
|
||||
&format!("OPUS {index}"),
|
||||
Vec3::new(1.0, 1.0, 0.25),
|
||||
Vec3::new(0.0, 0.0, f64::from(index)),
|
||||
1.0,
|
||||
Vec3::new(0.1, 0.2, 0.0),
|
||||
));
|
||||
}
|
||||
source.bounds = source.meshes.iter().fold(Aabb3::EMPTY, |bounds, body| bounds.union(&body.mesh.bounds()));
|
||||
let mut req = request(1);
|
||||
req.camera.distance = 50.0;
|
||||
req.camera.fov = 0.00000001;
|
||||
let options = JobOptions { samples: 1, max_depth: u32::from(solidcraft_render::job::MAX_PATH_DEPTH), seed: 9, ..Default::default() };
|
||||
let mut task = Task::new(executor.context, source, req, options, CancelToken::default()).unwrap();
|
||||
task.environment = 1.0;
|
||||
task.environment_bottom_only = true;
|
||||
task.studio = 0.0;
|
||||
task.dispatch().unwrap();
|
||||
let actual = task.read_linear().unwrap()[0];
|
||||
for (actual, expected) in actual.into_iter().zip([(-0.75_f64).exp(), (-1.5_f64).exp(), 1.0, 1.0]) {
|
||||
assert!((f64::from(actual) - expected).abs() < 2e-5, "60-interface Beer {actual} vs {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
fn opus_concave_boundary() -> Mesh {
|
||||
let polygon = [(0.0, 0.0), (3.0, 0.0), (3.0, 1.0), (1.0, 1.0), (1.0, 3.0), (0.0, 3.0)];
|
||||
let mut mesh = Mesh::default();
|
||||
for z in [0.0, 1.0] {
|
||||
for (x, y) in polygon {
|
||||
mesh.positions.push(Vec3::new(x, y, z));
|
||||
mesh.normals.push(Vec3::Z);
|
||||
}
|
||||
}
|
||||
for [a, b, c] in [[0, 1, 2], [0, 2, 3], [0, 3, 5], [3, 4, 5]] {
|
||||
mesh.triangles.extend([[a, c, b], [a + 6, b + 6, c + 6]]);
|
||||
}
|
||||
for i in 0..6 {
|
||||
let j = (i + 1) % 6;
|
||||
mesh.triangles.extend([[i, j, j + 6], [i, j + 6, i + 6]]);
|
||||
}
|
||||
mesh.tri_face.resize(mesh.triangles.len(), 0);
|
||||
mesh
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonconvex_optical_surfaces_admit_disjoint_overlapping_bounds_and_certify_true_containment() {
|
||||
let cancel = CancelToken::default();
|
||||
for contained in [false, true] {
|
||||
let mut outer = slab_body("concave parent", Vec3::new(1.0, 1.0, 1.0), Vec3::ZERO, 1.5, Vec3::ZERO);
|
||||
outer.mesh = Arc::new(opus_concave_boundary());
|
||||
let mut inner = slab_body("notch or arm", Vec3::new(1.0, 1.0, 1.0), Vec3::ZERO, 1.5, Vec3::ZERO);
|
||||
let bounds = inner.mesh.bounds();
|
||||
let span = bounds.max - bounds.min;
|
||||
for p in &mut Arc::make_mut(&mut inner.mesh).positions {
|
||||
p.x = (p.x - bounds.min.x) / span.x * 0.6 + if contained { 0.2 } else { 1.5 };
|
||||
p.y = (p.y - bounds.min.y) / span.y * 0.6 + if contained { 0.2 } else { 1.5 };
|
||||
p.z = (p.z - bounds.min.z) / span.z * 0.6 + 0.2;
|
||||
}
|
||||
let mut source = ProductScene { bounds: outer.mesh.bounds(), meshes: vec![outer, inner] };
|
||||
for reverse in [false, true] {
|
||||
if reverse {
|
||||
source.meshes.reverse();
|
||||
}
|
||||
assert!(scene::flatten_with_media(&source, Vec3::ZERO, &cancel, true).is_ok(), "contained={contained} reverse={reverse}");
|
||||
assert_eq!(scene::flatten_with_media(&source, Vec3::ZERO, &cancel, false).is_ok(), !contained);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_final_gpu_profile_completes_on_the_same_physical_executor() {
|
||||
let Some(executor) = executor() else { return };
|
||||
let host = ImageHost::with_executor(Arc::new(executor)).unwrap();
|
||||
let options =
|
||||
JobOptions { samples: 2, max_depth: u32::from(solidcraft_render::job::MAX_PATH_DEPTH), linear_exr: true, seed: 81, ..Default::default() };
|
||||
let mut submission = host.prepare_submission(sphere(white()), request(1), options, None, CancelToken::default()).unwrap();
|
||||
submission.select_image_mode(RenderMode::Final).unwrap();
|
||||
let job = host.submit_prepared(solidcraft_render::job::JobId { session: 982, serial: 1, generation: 1 }, 1, submission).unwrap();
|
||||
let status = job.wait_timeout(Duration::from_secs(30)).unwrap();
|
||||
assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
|
||||
assert_eq!(status.resolved.profile.mode, RenderMode::Final);
|
||||
assert_eq!(status.resolved.executor.as_str(), "gpu-path");
|
||||
assert_eq!((status.options.samples, status.options.max_depth), (2, 64));
|
||||
assert!(job.frame().unwrap().unwrap().linear_master_info().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_gpu_preparation_rejects_actual_optical_crossings_before_a_job_exists_and_releases_its_lease() {
|
||||
let Some(executor) = executor() else { return };
|
||||
let host = ImageHost::with_executor(Arc::new(executor)).unwrap();
|
||||
let mut source = nested();
|
||||
for point in &mut Arc::make_mut(&mut source.meshes[1].mesh).positions {
|
||||
point.x += 5.0;
|
||||
}
|
||||
let options = JobOptions { nested_media: true, samples: 1, ..Default::default() };
|
||||
let failure = host.prepare_submission(source, request(1), options, None, CancelToken::default()).err().unwrap();
|
||||
assert_eq!(failure.code, ErrorCode::InvalidScene);
|
||||
assert!(failure.message.contains("Outer") && failure.message.contains("Inner"), "{}", failure.message);
|
||||
assert_eq!(host.admitted_bytes(), 0, "failed Check releases all captured work without creating a job");
|
||||
let cancel = CancelToken::default();
|
||||
cancel.cancel();
|
||||
assert_eq!(host.prepare_submission(nested(), request(1), options, None, cancel).err().unwrap().code, ErrorCode::Cancelled);
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ struct Params {
|
||||
// ray epsilon, test environment radiance, studio scale, reserved
|
||||
lighting: vec4<f32>,
|
||||
environment: vec4<f32>, // RGB radiance, enabled flag
|
||||
light_count: vec4<f32>, // x: hard directional count, y: point/spot count, w: rectangle count
|
||||
light_count: vec4<f32>, // directional count, point/spot count, metal energy flag, rectangle count
|
||||
area_lights: array<AreaLight,4>,
|
||||
environment_map: vec4<f32>, // cosine rotation, sine rotation, intensity, enabled
|
||||
sun_direction: vec4<f32>, // normalized toward sun, cosine angular radius
|
||||
@@ -37,6 +37,8 @@ struct Params {
|
||||
directional_lights: array<DirectionalLight,8>,
|
||||
punctual_lights: array<PunctualLight,8>,
|
||||
studio_weights: vec4<f32>,
|
||||
// Global first pixel and local film-plane length; zero length is the full-image test layout.
|
||||
page: vec4<u32>,
|
||||
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> u: Params;
|
||||
@@ -49,6 +51,9 @@ struct EnvironmentData { header: vec4<u32>, texels: array<EnvironmentTexel> };
|
||||
// Count low, first pixel/bounce/sample/code, count high, first-writer flag, spare.
|
||||
// Two atomic words count jobs with more than 2^32 paths without wrapping to zero.
|
||||
@group(0) @binding(6) var<storage, read_write> failures: array<atomic<u32>>;
|
||||
fn film_pixels()->u32 {return select(u.image.x*u.image.y,u.page.y,u.page.y!=0u);}
|
||||
fn film_index(pixel:u32)->u32 {return pixel-u.page.x;}
|
||||
|
||||
// Codes: 1 escaped inside a medium, 2 exit with no medium, 3 exit from another
|
||||
// medium, 4 ambiguous origin/certificate, invalid entry or beyond16 nested media.
|
||||
fn medium_failure(radiance: vec3<f32>, coverage: f32, pixel: u32, bounce: u32, code: u32) -> vec4<f32> {
|
||||
@@ -185,8 +190,11 @@ fn brdf(t: Triangle, n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> vec3<f32> {
|
||||
let ratio = (t.parameters.z - 1.0) / (t.parameters.z + 1.0);
|
||||
let f0 = mix(vec3<f32>(ratio*ratio), t.base.xyz, t.parameters.x);
|
||||
let diffuse = t.base.xyz * ((1.0-t.parameters.x)/PI);
|
||||
let base = base_fresnel(f0,dot(v,h),t.parameters.x,t.parameters.z) * (d*0.25*gv*gl)
|
||||
var base = base_fresnel(f0,dot(v,h),t.parameters.x,t.parameters.z) * (d*0.25*gv*gl)
|
||||
+ (vec3<f32>(1.0)-base_fresnel(f0,nv,t.parameters.x,t.parameters.z)) * (vec3<f32>(1.0)-base_fresnel(f0,nl,t.parameters.x,t.parameters.z)) * diffuse;
|
||||
if u.light_count.z > 0.0 && t.parameters.x > 0.0 && t.parameters.y > 0.35 {
|
||||
base += metal_multiple_scattering(t.base.xyz,t.parameters.x,t.parameters.y,nv,nl);
|
||||
}
|
||||
if t.coat.x <= 0.0 || t.coat.z == 1.0 { return base; }
|
||||
let coat_a = t.coat.y*t.coat.y;
|
||||
let coat_a2 = coat_a*coat_a;
|
||||
@@ -871,17 +879,21 @@ fn sample_surface(t: Triangle, n: vec3<f32>, v: vec3<f32>, rng: ptr<function,u32
|
||||
let component = random(rng); let x = random(rng); let y = random(rng);
|
||||
return sample_surface_values(t,n,v,vec3<f32>(component,x,y));
|
||||
}
|
||||
fn visible_ggx_reflection_pdf(nh: f32, nv: f32, roughness: f32) -> f32 {
|
||||
let alpha = roughness*roughness;
|
||||
let a2 = alpha*alpha;
|
||||
let root = sqrt(nv*nv+a2*(1.0-nv*nv));
|
||||
return distribution(nh,a2)/(2.0*(nv+root));
|
||||
}
|
||||
// Conditional opaque mixture density; branch selection is accounted for by callers.
|
||||
fn surface_pdf(t: Triangle, n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> f32 {
|
||||
let nl = dot(n,l);
|
||||
if nl<=0.0 || dot(n,v)<=0.0 { return 0.0; }
|
||||
let h = unit(v+l); let vh = max(dot(v,h),1e-20);
|
||||
let nh = max(dot(n,h),0.0);
|
||||
let alpha = t.parameters.y*t.parameters.y;
|
||||
let base = 0.5*nl/PI+0.5*distribution(nh,alpha*alpha)*nh/(4.0*vh);
|
||||
let nv = min(dot(n,v),1.0);
|
||||
if nl<=0.0 || nv<=0.0 { return 0.0; }
|
||||
let nh = clamp(dot(n,unit(v+l)),0.0,1.0);
|
||||
let base = 0.5*nl/PI+0.5*visible_ggx_reflection_pdf(nh,nv,t.parameters.y);
|
||||
if t.coat.x<=0.0 { return base; }
|
||||
let coat_alpha = t.coat.y*t.coat.y;
|
||||
let coat_pdf = distribution(nh,coat_alpha*coat_alpha)*nh/(4.0*vh);
|
||||
let coat_pdf = visible_ggx_reflection_pdf(nh,nv,t.coat.y);
|
||||
return (1.0-t.coat.x*0.5)*base+t.coat.x*0.5*coat_pdf;
|
||||
}
|
||||
fn power_weight(a: f32, b: f32) -> f32 {
|
||||
@@ -891,9 +903,10 @@ fn power_weight(a: f32, b: f32) -> f32 {
|
||||
return 1.0/(1.0+ratio*ratio);
|
||||
}
|
||||
struct EmitterHit { distance: f32, index: u32 };
|
||||
fn emitter_hit_precise(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, minimum: f32) -> EmitterHit {
|
||||
fn emitter_hit_precise_except(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, minimum: f32, excluded: u32) -> EmitterHit {
|
||||
var hit = EmitterHit(FAR,0xffffffffu);
|
||||
for (var i=0u; i<u32(u.light_count.w); i+=1u) {
|
||||
if i==excluded {continue;}
|
||||
let light = u.area_lights[i];
|
||||
let normal = unit(cross(light.u.xyz,light.v.xyz));
|
||||
let cosine = dot(normal,d);
|
||||
@@ -909,6 +922,9 @@ fn emitter_hit_precise(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, minimum: f3
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
fn emitter_hit_precise(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, minimum: f32) -> EmitterHit {
|
||||
return emitter_hit_precise_except(o,o_low,d,minimum,0xffffffffu);
|
||||
}
|
||||
fn emitter_hit_bounded(o: vec3<f32>, d: vec3<f32>, minimum: f32) -> EmitterHit {
|
||||
return emitter_hit_precise(o,vec3<f32>(0.0),d,minimum);
|
||||
}
|
||||
@@ -1142,6 +1158,9 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
var previous_pdf = 0.0;
|
||||
var previous_delta = true;
|
||||
var continuous_prefix=false;
|
||||
// Receiver plus its uninterrupted smooth closed-glass continuation.
|
||||
// Zero means there is no opaque receiver eligible for area or environment photons.
|
||||
var smooth_tail=0u;
|
||||
var optical_lights:array<vec4<u32>,6>;var optical_light_count=0u;
|
||||
var optical_light_rng=pixel*0x68bc21ebu ^ ordinal*0x02e5be93u ^ u.sampling.x ^ salt ^ lane*0x967a889bu ^ 0x5bd1e995u;
|
||||
var media: array<Medium,MEDIUM_CAPACITY>;
|
||||
@@ -1201,6 +1220,7 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
// refracting-boundary suffixes use photons when enabled. Finite
|
||||
// source endpoints retain the random walk's complete weight.
|
||||
d=sample_hg(d,current.scatter.y,&rng);
|
||||
smooth_tail=0u;
|
||||
previous_delta=true;previous_pdf=0.0;suppress_direct=false;
|
||||
previous_position=position;previous_position_low=vec3<f32>(0.0);
|
||||
previous_scatter_position=position;previous_scatter_position_low=vec3<f32>(0.0);
|
||||
@@ -1239,6 +1259,7 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
if remaining==0u { break; }
|
||||
let next=volume_sample(d,&rng);
|
||||
previous_pdf=volume_phase(dot(d,next));
|
||||
smooth_tail=0u;
|
||||
previous_delta=false;
|
||||
previous_scatter_position=position;
|
||||
previous_scatter_position_low=vec3<f32>(0.0);
|
||||
@@ -1266,7 +1287,7 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
}
|
||||
let scatter_segment = ray_segment_precise(o,o_low,previous_scatter_position,previous_scatter_position_low,d,emitter.distance);
|
||||
let pdf = area_pdf(light,scatter_segment);
|
||||
if pdf>0.0 && !suppress_direct {
|
||||
if pdf>0.0 && !suppress_direct && !photon_owns_smooth_tail(smooth_tail,false) {
|
||||
let weight = select(power_weight(previous_pdf,pdf),1.0,previous_delta);
|
||||
radiance += throughput*light.radiance.xyz*weight;
|
||||
}
|
||||
@@ -1281,7 +1302,9 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
// The backplate remains request.background; environment lights covered paths.
|
||||
if bounce>0u && !suppress_direct {
|
||||
let weight = select(power_weight(previous_pdf,env_pdf(d)),1.0,previous_delta);
|
||||
radiance += throughput*env_eval(d)*weight;
|
||||
if !photon_owns_smooth_tail(smooth_tail,true) {
|
||||
radiance += throughput*env_eval(d)*weight;
|
||||
}
|
||||
let sun_weight=select(power_weight(previous_pdf,sun_pdf(d)),1.0,previous_delta);
|
||||
radiance += throughput*sun_eval(d)*sun_weight;
|
||||
}
|
||||
@@ -1322,6 +1345,8 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
// making the texture sampling temporaries live.
|
||||
if !OPAQUE_SCENE && captured.base.w > 0.0 && random(&rng) < captured.base.w {
|
||||
let t=captured;
|
||||
if smooth_tail>0u && t.parameters.w<0.0 && t.glass.x==0.0 {smooth_tail+=1u;}
|
||||
else {smooth_tail=0u;}
|
||||
let interpolated=unit(t.n0.xyz*hit.bary.x+t.n1.xyz*hit.bary.y+t.n2.xyz*hit.bary.z);
|
||||
let winding_normal=select(interpolated,-interpolated,dot(interpolated,geometric)<0.0);
|
||||
var n=select(geometric,winding_normal,dot(winding_normal,v)>0.0);
|
||||
@@ -1476,7 +1501,7 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
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,shading_position,physical,o,n,geometric,v,u.sampling.y-bounce-1u,&rng);
|
||||
radiance += throughput*photon_gather(t,shading_position,n,geometric,v);
|
||||
radiance += throughput*photon_gather(t,shading_position,n,geometric,v,u.sampling.y-bounce-1u);
|
||||
d = sample_surface(t,n,v,&rng);
|
||||
let nl = dot(n,d);
|
||||
if nl<=0.0 || dot(geometric,d)<=0.0 { break; }
|
||||
@@ -1486,6 +1511,7 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
previous_scatter_position=position; previous_scatter_position_low=position_low;
|
||||
previous_delta = false;
|
||||
suppress_direct=false;
|
||||
smooth_tail=select(0u,1u,t.base.w<=0.0);
|
||||
throughput *= brdf(t,n,v,d) * (nl/pdf);
|
||||
if medium_count>0u {
|
||||
o=position;o_low=position_low;ray_excluded=hit.triangle;ray_minimum=0.0;
|
||||
@@ -1529,42 +1555,42 @@ fn adaptive_target(mean: vec4<f32>, m2: vec4<f32>) -> u32 {
|
||||
// publish one bounded request per tile; a separate native program resolves its
|
||||
// visibility before entering this update, including final adaptive normalization.
|
||||
fn accumulate_path(pixel:u32,value:vec4<f32>) {
|
||||
let pixels=u.image.x*u.image.y;let pilots=u.adaptive_budget.x;
|
||||
let pixels=film_pixels();let local=film_index(pixel);let pilots=u.adaptive_budget.x;
|
||||
let strata=max(1u,u.adaptive_budget.z);let lane=u32(u.adaptive_error.w);
|
||||
var budget_count=0u;
|
||||
if pilots>0u && u.image.z>=pilots {budget_count=u32(accumulation[2u*pixels+pixel].x);}
|
||||
if pilots>0u && u.image.z>=pilots {budget_count=u32(accumulation[2u*pixels+local].x);}
|
||||
if pilots == 0u {
|
||||
let sample = value/f32(strata);
|
||||
if u.image.z == 0u && lane == 0u { accumulation[pixel] = sample; }
|
||||
else { accumulation[pixel] += sample; }
|
||||
if u.image.z == 0u && lane == 0u { accumulation[local] = sample; }
|
||||
else { accumulation[local] += sample; }
|
||||
return;
|
||||
}
|
||||
if u.image.z < pilots {
|
||||
var mean = accumulation[pixels+pixel];
|
||||
var m2 = accumulation[2u*pixels+pixel];
|
||||
var mean = accumulation[pixels+local];
|
||||
var m2 = accumulation[2u*pixels+local];
|
||||
if u.image.z == 0u { mean=vec4<f32>(0.0); m2=vec4<f32>(0.0); }
|
||||
let delta=value-mean;
|
||||
mean += delta/f32(u.image.z+1u);
|
||||
m2 += delta*(value-mean);
|
||||
accumulation[pixels+pixel]=mean;
|
||||
accumulation[2u*pixels+pixel]=m2;
|
||||
accumulation[pixels+local]=mean;
|
||||
accumulation[2u*pixels+local]=m2;
|
||||
if u.image.z+1u == pilots {
|
||||
accumulation[2u*pixels+pixel]=vec4<f32>(f32(adaptive_target(mean,m2)),0.0,0.0,0.0);
|
||||
accumulation[pixel]=vec4<f32>(0.0);
|
||||
accumulation[2u*pixels+local]=vec4<f32>(f32(adaptive_target(mean,m2)),0.0,0.0,0.0);
|
||||
accumulation[local]=vec4<f32>(0.0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let ordinal=u.image.z-pilots;
|
||||
accumulation[pixel] += value/f32(strata);
|
||||
accumulation[local] += value/f32(strata);
|
||||
// Per-pixel production means enter both raw EXR and denoising. Pilots do not.
|
||||
if ordinal+1u == budget_count && lane+1u == strata { accumulation[pixel] /= f32(budget_count); }
|
||||
if ordinal+1u == budget_count && lane+1u == strata { accumulation[local] /= f32(budget_count); }
|
||||
}
|
||||
@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;
|
||||
if gid.x>=u.sampling.z || pixel>=pixels {return;}
|
||||
let pilots=u.adaptive_budget.x;
|
||||
if pilots>0u && u.image.z>=pilots && u.image.z-pilots>=u32(accumulation[2u*pixels+pixel].x) {return;}
|
||||
if pilots>0u && u.image.z>=pilots && u.image.z-pilots>=u32(accumulation[2u*film_pixels()+film_index(pixel)].x) {return;}
|
||||
if GLASS_DELTA_LIGHTS {accumulation[glass_tile_address(pixel)+6u]=vec4<f32>(0.0);}
|
||||
let value=sample_path(pixel);
|
||||
if GLASS_DELTA_LIGHTS {accumulation[glass_tile_address(pixel)+7u]=value;return;}
|
||||
@@ -1577,7 +1603,7 @@ fn optical_resolve(@builtin(global_invocation_id) gid:vec3<u32>) {
|
||||
let pixel=u.image.w+gid.x;let pixels=u.image.x*u.image.y;
|
||||
if gid.x>=u.sampling.z || pixel>=pixels {return;}
|
||||
let pilots=u.adaptive_budget.x;
|
||||
if pilots>0u && u.image.z>=pilots && u.image.z-pilots>=u32(accumulation[2u*pixels+pixel].x) {return;}
|
||||
if pilots>0u && u.image.z>=pilots && u.image.z-pilots>=u32(accumulation[2u*film_pixels()+film_index(pixel)].x) {return;}
|
||||
let address=glass_tile_address(pixel);let header=accumulation[address+6u];
|
||||
var packet:array<vec4<u32>,6>;
|
||||
if header.x>0.0 {
|
||||
@@ -1655,9 +1681,7 @@ fn sample_surface_values(t: Triangle, n: vec3<f32>, v: vec3<f32>, variates: vec3
|
||||
}
|
||||
let roughness = select(t.parameters.y,t.coat.y,component >= 1.0-coat_probability);
|
||||
let alpha = roughness*roughness;
|
||||
let cosine = sqrt((1.0-x)/(1.0+(alpha*alpha-1.0)*x));
|
||||
let sine = sqrt(max(0.0,1.0-cosine*cosine));
|
||||
let h = from_local(vec3<f32>(sine*cos(phi),sine*sin(phi),cosine),n);
|
||||
let h = glass_visible_normal(n,v,alpha,variates.yz);
|
||||
return reflect(-v,h);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
//! Opt-in, prepaid GPU caustic photon storage and cancellable measured prepasses.
|
||||
//! Legacy jobs retain the original six-storage-buffer shader/layout unchanged.
|
||||
use super::*;
|
||||
use solidcraft_render::job::CausticOptions;
|
||||
use solidcraft_render::job::{
|
||||
CausticOptions,
|
||||
photon_sources::{self as power, MAX_PHOTON_SOURCES, PhotonSourceDistribution},
|
||||
};
|
||||
|
||||
const HEADER_WORDS: u32 = 16;
|
||||
const RECORD_WORDS: u32 = 16;
|
||||
const GUIDE_WORDS: u32 = 4;
|
||||
const BOUNDS_WORDS: u32 = 6;
|
||||
const SOURCE_WORDS: u32 = 1 + MAX_PHOTON_SOURCES as u32;
|
||||
fn extra_words(options: CausticOptions) -> u32 {
|
||||
(if options.guided_emission || options.power_weighted_sources { GUIDE_WORDS } else { 0 })
|
||||
+ if options.power_weighted_sources { SOURCE_WORDS } else { 0 }
|
||||
}
|
||||
const DISABLED: &str = include_str!("photons_disabled.wgsl");
|
||||
const PHOTONS: &str = include_str!("photons.wgsl");
|
||||
|
||||
@@ -13,15 +23,73 @@ pub(super) fn heads(options: CausticOptions) -> Result<u32> {
|
||||
options.photons.checked_mul(2).and_then(u32::checked_next_power_of_two).ok_or_else(|| gpu("caustic hash capacity overflow"))
|
||||
}
|
||||
pub(super) fn buffer_bytes(options: CausticOptions) -> Result<u64> {
|
||||
let words = u64::from(HEADER_WORDS) + u64::from(heads(options)?) + u64::from(options.photons) * u64::from(RECORD_WORDS);
|
||||
let words = u64::from(HEADER_WORDS + extra_words(options) + BOUNDS_WORDS)
|
||||
+ u64::from(heads(options)?)
|
||||
+ u64::from(options.photons) * u64::from(RECORD_WORDS);
|
||||
words.checked_mul(4).ok_or_else(|| gpu("caustic buffer size overflow"))
|
||||
}
|
||||
pub(super) fn admission_bytes(options: Option<CausticOptions>) -> Result<u64> {
|
||||
options.map_or(Ok(0), |options| {
|
||||
buffer_bytes(options)?.checked_add(u64::from(HEADER_WORDS) * 4).ok_or_else(|| gpu("caustic header estimate overflow"))
|
||||
let upload_words = HEADER_WORDS + extra_words(options) + BOUNDS_WORDS;
|
||||
buffer_bytes(options)?.checked_add(u64::from(upload_words) * 4).ok_or_else(|| gpu("caustic header estimate overflow"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Host-side source order and enable predicates mirror photon_trace exactly.
|
||||
/// The bounded CDF is uploaded once and retained across map refreshes.
|
||||
pub(super) fn source_distribution(
|
||||
options: JobOptions,
|
||||
request: &ImageRequest,
|
||||
nodes: &[u8],
|
||||
guide: Option<[f32; 4]>,
|
||||
hdr_integral: f64,
|
||||
) -> Result<Option<PhotonSourceDistribution>> {
|
||||
let Some(caustics) = options.caustics.filter(|c| c.power_weighted_sources) else { return Ok(None) };
|
||||
let mut bounds = [0.0; 6];
|
||||
for (value, index) in bounds.iter_mut().zip([0usize, 1, 2, 4, 5, 6]) {
|
||||
let bytes = nodes.get(index * 4..index * 4 + 4).and_then(|v| v.try_into().ok()).ok_or_else(|| gpu("photon power bounds missing"))?;
|
||||
*value = f64::from(f32::from_le_bytes(bytes));
|
||||
}
|
||||
let radius = ((bounds[3] - bounds[0]).powi(2) + (bounds[4] - bounds[1]).powi(2) + (bounds[5] - bounds[2]).powi(2)).sqrt() * 0.5;
|
||||
let radius = guide.filter(|g| caustics.guided_emission && g[3] > 0.0 && f64::from(g[3]) < radius).map_or(radius, |g| f64::from(g[3]));
|
||||
let studio = options.lighting.is_some() || request.environment.is_some();
|
||||
let lighting = options.lighting.unwrap_or_default();
|
||||
let mut powers = [0.0; MAX_PHOTON_SOURCES];
|
||||
let mut count = 0;
|
||||
let mut push = |value| -> Result<()> {
|
||||
*powers.get_mut(count).ok_or_else(|| gpu("photon source proposal capacity exceeded"))? = value;
|
||||
count += 1;
|
||||
Ok(())
|
||||
};
|
||||
if studio {
|
||||
for light in lighting.directional_lights.iter() {
|
||||
push(power::disk_power(radius, power::luminance(light.color.map(|c| c * light.intensity))?)?)?;
|
||||
}
|
||||
for light in lighting.punctual_lights.iter() {
|
||||
push(power::punctual_power(light)?)?;
|
||||
}
|
||||
} else {
|
||||
for (intensity, weight) in [3.0, 1.0, 1.5].into_iter().zip(request.studio_weights.unwrap_or([1.0; 3])) {
|
||||
push(power::disk_power(radius, intensity * weight)?)?;
|
||||
}
|
||||
}
|
||||
if caustics.area_lights && caustics.max_depth > 1 {
|
||||
for light in lighting.area_lights.iter() {
|
||||
push(power::rectangle_power(light.area(), light.radiance)?)?;
|
||||
}
|
||||
}
|
||||
let lit_environment =
|
||||
request.environment.as_ref().map_or(lighting.environment.into_iter().any(|c| (c as f32).to_bits() & 0x7fff_ffff != 0), |e| {
|
||||
(e.info().intensity as f32).to_bits() & 0x7fff_ffff != 0
|
||||
});
|
||||
if studio && lit_environment && caustics.environment && caustics.max_depth > 1 {
|
||||
let integral =
|
||||
if request.environment.is_some() { hdr_integral } else { 4.0 * std::f64::consts::PI * power::luminance(lighting.environment)? };
|
||||
push(power::disk_power(radius, integral)?)?;
|
||||
}
|
||||
if count == 0 { Ok(None) } else { PhotonSourceDistribution::new(&powers[..count]).map(Some) }
|
||||
}
|
||||
|
||||
pub(super) fn validate_scene(scene: &ProductScene, options: JobOptions) -> Result<()> {
|
||||
let Some(caustics) = options.caustics else { return Ok(()) };
|
||||
caustics.validate()?;
|
||||
@@ -186,12 +254,20 @@ pub(super) struct Resources {
|
||||
emit: wgpu::ComputePipeline,
|
||||
photons: u32,
|
||||
head_count: u32,
|
||||
pass: u32,
|
||||
cleared: u32,
|
||||
emitted: u32,
|
||||
budget: dispatch::Budget,
|
||||
}
|
||||
impl Resources {
|
||||
pub(super) fn new(context: &Context, options: CausticOptions, nodes: &[u8], source_count: u32) -> Result<Self> {
|
||||
pub(super) fn new(
|
||||
context: &Context,
|
||||
options: CausticOptions,
|
||||
nodes: &[u8],
|
||||
guide: Option<[f32; 4]>,
|
||||
bounds: Option<[f32; 6]>,
|
||||
sources: Option<&PhotonSourceDistribution>,
|
||||
) -> Result<Self> {
|
||||
options.validate()?;
|
||||
let head_count = heads(options)?;
|
||||
let word = |index: usize| -> Result<u32> {
|
||||
@@ -223,20 +299,59 @@ impl Resources {
|
||||
));
|
||||
}
|
||||
}
|
||||
header[12] = source_count;
|
||||
header[12] = u32::from(options.area_lights);
|
||||
header[13] = u32::from(options.environment);
|
||||
let guide = guide.filter(|_| options.guided_emission);
|
||||
header[14] = u32::from(guide.is_some()) | (u32::from(sources.is_some()) << 1) | (u32::from(bounds.is_some()) << 2);
|
||||
if let Some([_, _, _, radius]) = guide {
|
||||
let diameter_squared = [(6, 9), (7, 10), (8, 11)]
|
||||
.into_iter()
|
||||
.map(|(lo, hi)| (f64::from(f32::from_bits(header[hi])) - f64::from(f32::from_bits(header[lo]))).powi(2))
|
||||
.sum::<f64>();
|
||||
log::info!("gpu-path guided photon emission optical radius {:.3} mm, scene radius {:.3} mm", radius, diameter_squared.sqrt() * 0.5);
|
||||
}
|
||||
let mut bytes = [0u8; HEADER_WORDS as usize * 4];
|
||||
for (word, output) in header.into_iter().zip(bytes.as_chunks_mut::<4>().0) {
|
||||
output.copy_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
let pipelines = Pipelines::shared(context)?;
|
||||
let size = buffer_bytes(options)?;
|
||||
let buffer = checked_creation(&context.device, || {
|
||||
let buffer = context.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("caustic photons and spatial hash"),
|
||||
size: buffer_bytes(options)?,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
size,
|
||||
usage: wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_DST
|
||||
| if cfg!(test) { wgpu::BufferUsages::COPY_SRC } else { wgpu::BufferUsages::empty() },
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
context.queue.write_buffer(&buffer, 0, &bytes);
|
||||
if let Some(guide) = guide {
|
||||
let mut bytes = [0u8; GUIDE_WORDS as usize * 4];
|
||||
for (value, output) in guide.into_iter().zip(bytes.as_chunks_mut::<4>().0) {
|
||||
output.copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
context.queue.write_buffer(&buffer, size - u64::from(extra_words(options) + BOUNDS_WORDS) * 4, &bytes);
|
||||
}
|
||||
if let Some(sources) = sources {
|
||||
let mut words = [0u32; SOURCE_WORDS as usize];
|
||||
words[0] = sources.cumulative().len() as u32;
|
||||
for (target, value) in words[1..].iter_mut().zip(sources.cumulative()) {
|
||||
*target = *value;
|
||||
}
|
||||
let mut packed = [0u8; SOURCE_WORDS as usize * 4];
|
||||
for (value, output) in words.into_iter().zip(packed.as_chunks_mut::<4>().0) {
|
||||
output.copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
context.queue.write_buffer(&buffer, size - u64::from(SOURCE_WORDS + BOUNDS_WORDS) * 4, &packed);
|
||||
}
|
||||
if let Some(bounds) = bounds {
|
||||
let mut bytes = [0u8; BOUNDS_WORDS as usize * 4];
|
||||
for (value, output) in bounds.into_iter().zip(bytes.as_chunks_mut::<4>().0) {
|
||||
output.copy_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
context.queue.write_buffer(&buffer, size - u64::from(BOUNDS_WORDS) * 4, &bytes);
|
||||
}
|
||||
Ok(buffer)
|
||||
})?;
|
||||
Ok(Self {
|
||||
@@ -247,6 +362,7 @@ impl Resources {
|
||||
emit: pipelines.emit.clone(),
|
||||
photons: options.photons,
|
||||
head_count,
|
||||
pass: 0,
|
||||
cleared: 0,
|
||||
emitted: 0,
|
||||
budget: Default::default(),
|
||||
@@ -274,6 +390,23 @@ impl Resources {
|
||||
pub(super) fn ready(&self) -> bool {
|
||||
self.emitted == self.photons
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(super) fn test_epoch(&self) -> u32 {
|
||||
self.pass
|
||||
}
|
||||
pub(super) fn ensure_pass(&mut self, pass: u32) -> Result<()> {
|
||||
if self.pass == pass {
|
||||
return Ok(());
|
||||
}
|
||||
if !self.ready() || self.pass.checked_add(1) != Some(pass) {
|
||||
return Err(gpu("caustic refresh requires the next complete camera pass"));
|
||||
}
|
||||
self.pass = pass;
|
||||
self.cleared = 0;
|
||||
self.emitted = 0;
|
||||
self.budget = Default::default();
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn count(&self) -> u32 {
|
||||
let remaining =
|
||||
if self.cleared < self.head_count + self.photons { self.head_count + self.photons - self.cleared } else { self.photons - self.emitted };
|
||||
@@ -300,6 +433,9 @@ impl Resources {
|
||||
let count = self.count();
|
||||
let started = Instant::now();
|
||||
let scopes = scopes(&context.device);
|
||||
if clearing && offset == 0 && self.pass != 0 {
|
||||
context.queue.write_buffer(&self.buffer, 15 * 4, &self.pass.to_le_bytes());
|
||||
}
|
||||
context.queue.write_buffer(&self.buffer, 5 * 4, &offset.to_le_bytes());
|
||||
context.queue.write_buffer(uniform, 0, parameters);
|
||||
let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("caustic photon quantum") });
|
||||
@@ -329,6 +465,45 @@ impl Resources {
|
||||
pub(super) fn maximum_quanta(&self) -> u32 {
|
||||
(self.head_count + 2 * self.photons).div_ceil(dispatch::MIN_PATHS) + 2
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(super) fn test_records(&self, context: &Context) -> Vec<[u32; RECORD_WORDS as usize]> {
|
||||
assert!(self.ready());
|
||||
let offset = u64::from(HEADER_WORDS + self.head_count) * 4;
|
||||
let bytes = u64::from(self.photons) * u64::from(RECORD_WORDS) * 4;
|
||||
let staging = context.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("test photon records"),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = context.device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(&self.buffer, offset, &staging, 0, bytes);
|
||||
let submission = context.queue.submit([encoder.finish()]);
|
||||
let slice = staging.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |result| tx.send(result).unwrap());
|
||||
context.wait(submission, TIMEOUT).unwrap();
|
||||
rx.try_recv().unwrap().unwrap();
|
||||
let mapped = slice.get_mapped_range().unwrap();
|
||||
let records = mapped
|
||||
.as_chunks::<64>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|record| {
|
||||
let mut words = std::array::from_fn(|i| u32::from_le_bytes(record[i * 4..i * 4 + 4].try_into().unwrap()));
|
||||
if words[11] == 0 {
|
||||
return [0; RECORD_WORDS as usize];
|
||||
}
|
||||
// Atomic linked-list insertion order is independent of the
|
||||
// indexed emitted paths and may vary on the same driver.
|
||||
words[3] = 0;
|
||||
words
|
||||
})
|
||||
.collect();
|
||||
drop(mapped);
|
||||
staging.unmap();
|
||||
records
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -341,8 +516,11 @@ mod tests {
|
||||
let options = CausticOptions { photons, ..Default::default() };
|
||||
let head_count = heads(options).unwrap();
|
||||
assert!(head_count.is_power_of_two() && head_count >= 2 * photons);
|
||||
assert_eq!(buffer_bytes(options).unwrap(), 64 + u64::from(head_count) * 4 + u64::from(photons) * 64);
|
||||
assert_eq!(admission_bytes(Some(options)).unwrap(), buffer_bytes(options).unwrap() + 64);
|
||||
assert_eq!(buffer_bytes(options).unwrap(), 88 + u64::from(head_count) * 4 + u64::from(photons) * 64);
|
||||
assert_eq!(admission_bytes(Some(options)).unwrap(), buffer_bytes(options).unwrap() + 88);
|
||||
let guided = CausticOptions { guided_emission: true, ..options };
|
||||
assert_eq!(buffer_bytes(guided).unwrap(), buffer_bytes(options).unwrap() + 16);
|
||||
assert_eq!(admission_bytes(Some(guided)).unwrap(), admission_bytes(Some(options)).unwrap() + 32);
|
||||
}
|
||||
assert!(buffer_bytes(CausticOptions { photons: u32::MAX, ..Default::default() }).is_err());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// Bounded light tracing for delta-source caustics. Finite sources already have
|
||||
// camera-path estimators and are deliberately absent from this source mixture.
|
||||
// Header16 words, hash heads, then16-word immutable photon records.
|
||||
// Bounded light tracing for delta sources and opt-in area and environment
|
||||
// sources. The latter two own only paths that encounter one or more smooth
|
||||
// closed-glass events before the first opaque receiver.
|
||||
// Header 16 words, hash heads, 16-word immutable photon records, then an
|
||||
// optional four-word optical guide slot, then an optional source count/CDF.
|
||||
// The guide slot is also reserved when only source weighting is enabled.
|
||||
// A mandatory six-word retained optical AABB follows the optional proposals.
|
||||
// Gathering uses the original layout.
|
||||
@group(0) @binding(7) var<storage,read_write> photon_data:array<atomic<u32>>;
|
||||
fn photon_word(index:u32)->u32 {return atomicLoad(&photon_data[index]);}
|
||||
fn photon_float(index:u32)->f32 {return bitcast<f32>(photon_word(index));}
|
||||
@@ -10,6 +15,39 @@ fn photon_store_vector(index:u32,value:vec3<f32>) {
|
||||
atomicStore(&photon_data[index+1u],bitcast<u32>(value.y));
|
||||
atomicStore(&photon_data[index+2u],bitcast<u32>(value.z));
|
||||
}
|
||||
// Monotone numeric order of finite f32 bit patterns; both zeros are equal.
|
||||
fn photon_float_order(bits:u32)->u32 {
|
||||
let value=select(bits,0u,(bits&0x7fffffffu)==0u);
|
||||
return select(value^0x80000000u,~value,(value&0x80000000u)!=0u);
|
||||
}
|
||||
fn photon_origin_outside_optics(origin:vec3<f32>,origin_low:vec3<f32>)->bool {
|
||||
if (photon_word(14u)&4u)==0u {return false;}
|
||||
let bounds=arrayLength(&photon_data)-6u;
|
||||
var outside=false;
|
||||
for(var axis=0u;axis<3u;axis+=1u) {
|
||||
let high=bitcast<u32>(origin[axis]);let low=bitcast<u32>(origin_low[axis]);
|
||||
let lo=photon_word(bounds+axis);let hi=photon_word(bounds+3u+axis);
|
||||
if (high&0x7f800000u)==0x7f800000u || (low&0x7f800000u)==0x7f800000u ||
|
||||
(lo&0x7f800000u)==0x7f800000u || (hi&0x7f800000u)==0x7f800000u {return false;}
|
||||
// RTE is monotone and fixes each uploaded-f32 bound. A strictly exterior
|
||||
// rounded sum therefore proves the exact high+low point exterior too.
|
||||
// Integer arithmetic keeps subnormals and prevents reassociation; an
|
||||
// equality never proves vacuum, including a low part on the boundary.
|
||||
let sum=fp_add_bits(high,low);
|
||||
if (sum&0x7f800000u)==0x7f800000u {return false;}
|
||||
let minimum=photon_float_order(lo);let maximum=photon_float_order(hi);
|
||||
if minimum>maximum {return false;}
|
||||
let value=photon_float_order(sum);
|
||||
outside=outside || value<minimum || value>maximum;
|
||||
}
|
||||
return outside;
|
||||
}
|
||||
fn photon_origin_boundary(origin:vec3<f32>,origin_low:vec3<f32>,direction:vec3<f32>)->OriginBoundary {
|
||||
// Initial medium is a property of the launch point. A distant edge, vertex
|
||||
// or tied crossing along its sampled direction cannot invalidate proven air.
|
||||
if photon_origin_outside_optics(origin,origin_low) {return OriginBoundary(Hit(FAR,0xffffffffu,vec3<f32>(0.0)),true);}
|
||||
return origin_boundary(origin+origin_low,direction);
|
||||
}
|
||||
fn photon_cell(position:vec3<f32>)->vec3<i32> {
|
||||
return vec3<i32>(floor((position-photon_vector(6u))/photon_float(2u)));
|
||||
}
|
||||
@@ -19,13 +57,14 @@ fn photon_hash(cell:vec3<i32>)->u32 {
|
||||
}
|
||||
fn photon_record(index:u32)->u32 {return 16u+photon_word(1u)+index*16u;}
|
||||
fn photon_bad_medium() {atomicOr(&failures[7],4u);}
|
||||
fn photon_deposit(index:u32,position:vec3<f32>,incoming:vec3<f32>,flux:vec3<f32>,normal:vec3<f32>,receiver:u32,kind:u32,side:i32,source:u32) {
|
||||
fn photon_deposit(index:u32,position:vec3<f32>,incoming:vec3<f32>,flux:vec3<f32>,normal:vec3<f32>,receiver:u32,kind:u32,side:i32,source:u32,smooth_events:u32) {
|
||||
let record=photon_record(index);
|
||||
photon_store_vector(record,position);photon_store_vector(record+4u,incoming);
|
||||
photon_store_vector(record+8u,flux);photon_store_vector(record+12u,normal);
|
||||
atomicStore(&photon_data[record+7u],receiver);atomicStore(&photon_data[record+11u],kind);
|
||||
let side_code=select(select(0u,1u,side>0),2u,side<0);
|
||||
atomicStore(&photon_data[record+15u],side_code|(source<<2u));
|
||||
// Two side bits, four hard source bits, then up to 63 smooth closed-glass events.
|
||||
atomicStore(&photon_data[record+15u],side_code|(source<<2u)|(smooth_events<<6u));
|
||||
let previous=atomicExchange(&photon_data[16u+photon_hash(photon_cell(position))],index);
|
||||
atomicStore(&photon_data[record+3u],previous);
|
||||
}
|
||||
@@ -34,7 +73,7 @@ fn photon_deposit(index:u32,position:vec3<f32>,incoming:vec3<f32>,flux:vec3<f32>
|
||||
// allocating photons*depth records. The separate RNG never changes transport.
|
||||
struct PhotonReservoir {
|
||||
visits:u32,rng:u32,position:vec3<f32>,incoming:vec3<f32>,flux:vec3<f32>,normal:vec3<f32>,receiver:u32,kind:u32,side:i32,
|
||||
source:vec3<f32>,source_low:vec3<f32>,minimum:f32,source_index:u32,
|
||||
source:vec3<f32>,source_low:vec3<f32>,minimum:f32,source_index:u32,smooth_events:u32,
|
||||
};
|
||||
fn photon_offer(reservoir:ptr<function,PhotonReservoir>,point:PrecisePoint,incoming:vec3<f32>,flux:vec3<f32>,normal:vec3<f32>,receiver:u32,kind:u32,side:i32) {
|
||||
if (*reservoir).minimum>0.0 && length(precise_point_delta(point.hi,point.lo,(*reservoir).source,(*reservoir).source_low))<(*reservoir).minimum {punctual_count(0u);return;}
|
||||
@@ -66,30 +105,134 @@ fn photon_unit_sphere(rng:ptr<function,u32>)->vec3<f32> {
|
||||
let z=1.0-2.0*random(rng);let phi=2.0*PI*random(rng);let r=sqrt(max(0.0,1.0-z*z));
|
||||
return vec3<f32>(r*cos(phi),r*sin(phi),z);
|
||||
}
|
||||
// A guided disk still starts upstream of the full scene, so opaque blockers
|
||||
// outside the optical bounds remain visible. w is the proposal's area.
|
||||
fn photon_project(direction:vec3<f32>,guide:vec4<f32>,rng:ptr<function,u32>)->vec4<f32> {
|
||||
var center=(photon_vector(6u)+photon_vector(9u))*0.5;
|
||||
var radius=length(photon_vector(9u)-photon_vector(6u))*0.5;
|
||||
if !(radius>0.0) {return vec4<f32>(0.0);}
|
||||
var launch=radius+max(u.lighting.x*2.0,radius*0.000001);
|
||||
if guide.w>0.0 && guide.w<radius {
|
||||
// Allow for f32 rounding when a small captured assembly is far from
|
||||
// the camera target used as the upload origin.
|
||||
launch+=dot(guide.xyz-center,direction)+0.000004*(length(center)+radius);
|
||||
center=guide.xyz;
|
||||
radius=guide.w;
|
||||
}
|
||||
let axis=select(vec3<f32>(0.0,0.0,1.0),vec3<f32>(0.0,1.0,0.0),abs(direction.z)>0.9);
|
||||
let x=unit(cross(axis,direction));let y=cross(direction,x);
|
||||
let r=radius*sqrt(random(rng));let phi=2.0*PI*random(rng);
|
||||
let origin=center-direction*launch+r*(cos(phi)*x+sin(phi)*y);
|
||||
return vec4<f32>(origin,PI*radius*radius);
|
||||
}
|
||||
const PHOTON_GUIDE_MIX:f32=0.75;
|
||||
struct PhotonCone {axis:vec3<f32>,width:f32};
|
||||
fn photon_guide_cone(guide:vec4<f32>,origin:vec3<f32>,origin_low:vec3<f32>,maximum_width:f32)->PhotonCone {
|
||||
let fallback=PhotonCone(vec3<f32>(0.0),0.0);
|
||||
if guide.w<=0.0 {return fallback;}
|
||||
let offset=precise_point_delta(guide.xyz,vec3<f32>(0.0),origin,origin_low);
|
||||
let distance=length(offset);
|
||||
if !(distance>guide.w) {return fallback;}
|
||||
let sine=guide.w/distance;
|
||||
let sine_squared=sine*sine;
|
||||
let width=sine_squared/(1.0+sqrt(max(0.0,1.0-sine_squared)));
|
||||
if !(width>=0.00001 && width<min(maximum_width,0.5)) {return fallback;}
|
||||
return PhotonCone(offset/distance,width);
|
||||
}
|
||||
fn photon_sample_cone(cone:PhotonCone,rng:ptr<function,u32>)->vec3<f32> {
|
||||
let height=cone.width*random(rng);
|
||||
let sine=sqrt(height*(2.0-height));
|
||||
let phi=2.0*PI*random(rng);
|
||||
return from_local(vec3<f32>(sine*cos(phi),sine*sin(phi),1.0-height),cone.axis);
|
||||
}
|
||||
fn photon_mixture_pdf(cone:PhotonCone,direction:vec3<f32>,baseline_pdf:f32,sampled_cone:bool)->f32 {
|
||||
let transverse=cross(cone.axis,direction);
|
||||
let inside=sampled_cone || (dot(cone.axis,direction)>0.0 && dot(transverse,transverse)<=cone.width*(2.0-cone.width));
|
||||
let guide_pdf=select(0.0,1.0/(2.0*PI*cone.width),inside);
|
||||
return (1.0-PHOTON_GUIDE_MIX)*baseline_pdf+PHOTON_GUIDE_MIX*guide_pdf;
|
||||
}
|
||||
fn photon_spot_feather(cosine:f32,outer:f32,inner:f32)->f32 {
|
||||
let blend=clamp((cosine-outer)/(inner-outer),0.0,1.0);
|
||||
return blend*blend*(3.0-2.0*blend);
|
||||
}
|
||||
fn photon_mix_key(key:u32)->u32 {
|
||||
var value=key;
|
||||
value=(value^(value>>16u))*0x7feb352du;
|
||||
value=(value^(value>>15u))*0x846ca68bu;
|
||||
return value^(value>>16u);
|
||||
}
|
||||
// Mix the ordinal before combining the epoch key. XORing the epoch salt
|
||||
// directly with an ordinal can permute the same seed set at power-of-two N.
|
||||
fn photon_pass_key(index:u32,seed:u32,epoch:u32,domain:u32)->u32 {
|
||||
let ordinal_key=photon_mix_key(index^domain);
|
||||
let epoch_key=photon_mix_key(seed^photon_mix_key(epoch+domain));
|
||||
return photon_mix_key(ordinal_key^epoch_key);
|
||||
}
|
||||
@compute @workgroup_size(32)
|
||||
fn photon_emit(@builtin(global_invocation_id) invocation:vec3<u32>) {
|
||||
if invocation.x>=u.sampling.z {return;}
|
||||
let index=photon_word(5u)+invocation.x;let count=photon_word(0u);
|
||||
if index>=count {return;}
|
||||
let seed=photon_word(4u);let epoch=photon_word(15u);
|
||||
var reservoir:PhotonReservoir;
|
||||
reservoir.rng=index^(photon_word(4u)*0x85ebca6bu)^0x6b8b4567u;
|
||||
photon_trace(index,&reservoir);
|
||||
reservoir.rng=index^(seed*0x85ebca6bu)^0x6b8b4567u;
|
||||
if epoch>0u {reservoir.rng=photon_pass_key(index,seed,epoch,0x6b8b4567u);}
|
||||
photon_trace(index,seed,epoch,&reservoir);
|
||||
if reservoir.visits>0u {
|
||||
let flux=reservoir.flux*f32(reservoir.visits);
|
||||
if !all(flux>=vec3<f32>(0.0)) || !all(flux<=vec3<f32>(3.4e38)) {atomicOr(&failures[7],1u);return;}
|
||||
photon_deposit(index,reservoir.position,reservoir.incoming,flux,reservoir.normal,reservoir.receiver,reservoir.kind,reservoir.side,reservoir.source_index);
|
||||
photon_deposit(index,reservoir.position,reservoir.incoming,flux,reservoir.normal,reservoir.receiver,reservoir.kind,reservoir.side,reservoir.source_index,reservoir.smooth_events);
|
||||
}
|
||||
}
|
||||
fn photon_trace(index:u32,reservoir:ptr<function,PhotonReservoir>) {
|
||||
fn photon_trace(index:u32,seed:u32,epoch:u32,reservoir:ptr<function,PhotonReservoir>) {
|
||||
let count=photon_word(0u);
|
||||
var rng=index^(photon_word(4u)*0x9e3779b9u)^0x8da6b343u;
|
||||
var rng=index^(seed*0x9e3779b9u)^0x8da6b343u;
|
||||
if epoch>0u {rng=photon_pass_key(index,seed,epoch,0x8da6b343u);}
|
||||
let studio=u.environment.w>0.0;
|
||||
let directions=select(3u,u32(u.light_count.x),studio);
|
||||
let punctual=select(0u,u32(u.light_count.y),studio);
|
||||
let sources=directions+punctual;
|
||||
let hard_sources=directions+punctual;
|
||||
let maximum=photon_word(3u);
|
||||
let area_count=select(0u,u32(u.light_count.w),photon_word(12u)>0u && maximum>1u);
|
||||
var lit_environment=select(any(u.environment.xyz>vec3<f32>(0.0)),u.environment_map.z>0.0,u.environment_map.w>0.0);
|
||||
if (photon_word(14u)&2u)!=0u {
|
||||
// Source inclusion matches the uploaded bits even on drivers that flush
|
||||
// subnormal floating comparisons; ordinary transport still uses its values.
|
||||
let positive_constant=any((bitcast<vec3<u32>>(u.environment.xyz)&vec3<u32>(0x7fffffffu))>vec3<u32>(0u));
|
||||
let positive_hdr=(bitcast<u32>(u.environment_map.z)&0x7fffffffu)>0u;
|
||||
lit_environment=select(positive_constant,positive_hdr,u.environment_map.w>0.0);
|
||||
}
|
||||
let environment_count=select(0u,1u,studio && lit_environment && photon_word(13u)>0u && maximum>1u);
|
||||
let sources=hard_sources+area_count+environment_count;
|
||||
if sources==0u {return;}
|
||||
let selected=min(sources-1u,u32(random(&rng)*f32(sources)));
|
||||
(*reservoir).source_index=select(0u,selected-directions+1u,selected>=directions);
|
||||
var guide=vec4<f32>(0.0);
|
||||
if (photon_word(14u)&1u)!=0u {
|
||||
let record=photon_record(count);
|
||||
guide=vec4<f32>(photon_vector(record),photon_float(record+3u));
|
||||
}
|
||||
let choice=random(&rng);
|
||||
var selected=min(sources-1u,u32(choice*f32(sources)));
|
||||
var inverse_source_probability=f32(sources);
|
||||
if (photon_word(14u)&2u)!=0u {
|
||||
let table=photon_record(count)+4u;
|
||||
if photon_word(table)!=sources {atomicOr(&failures[7],1u);return;}
|
||||
let bin=u32(choice*16777216.0);
|
||||
var previous=0u;
|
||||
for(var source=0u;source<sources;source+=1u) {
|
||||
let end=photon_word(table+1u+source);
|
||||
if bin<end {
|
||||
if end<=previous {atomicOr(&failures[7],1u);return;}
|
||||
selected=source;
|
||||
inverse_source_probability=16777216.0/f32(end-previous);
|
||||
break;
|
||||
}
|
||||
previous=end;
|
||||
}
|
||||
}
|
||||
let area=selected>=hard_sources && selected<hard_sources+area_count;
|
||||
let continuous=selected>=hard_sources;
|
||||
let area_index=select(0xffffffffu,selected-hard_sources,area);
|
||||
(*reservoir).source_index=select(0u,selected-directions+1u,selected>=directions && !continuous);
|
||||
var origin=vec3<f32>(0.0);var origin_low=vec3<f32>(0.0);
|
||||
var direction=vec3<f32>(0.0,0.0,-1.0);var flux=vec3<f32>(0.0);
|
||||
var source_minimum=0.0;
|
||||
@@ -98,27 +241,64 @@ fn photon_trace(index:u32,reservoir:ptr<function,PhotonReservoir>) {
|
||||
var irradiance=vec3<f32>(FIXED_LIGHTS[min(selected,2u)].w*u.lighting.z*u.studio_weights[min(selected,2u)]);
|
||||
if studio {let light=u.directional_lights[min(selected,7u)];toward=light.direction.xyz;irradiance=light.color.xyz*light.direction.w;}
|
||||
direction=-toward;
|
||||
let center=(photon_vector(6u)+photon_vector(9u))*0.5;
|
||||
let radius=length(photon_vector(9u)-photon_vector(6u))*0.5;
|
||||
if !(radius>0.0) {return;}
|
||||
let axis=select(vec3<f32>(0.0,0.0,1.0),vec3<f32>(0.0,1.0,0.0),abs(direction.z)>0.9);
|
||||
let x=unit(cross(axis,direction));let y=cross(direction,x);
|
||||
let r=radius*sqrt(random(&rng));let phi=2.0*PI*random(&rng);
|
||||
origin=center-direction*(radius+max(u.lighting.x*2.0,radius*0.000001))+r*(cos(phi)*x+sin(phi)*y);
|
||||
flux=irradiance*(PI*radius*radius*f32(sources)/f32(count));
|
||||
} else {
|
||||
let projected=photon_project(direction,guide,&rng);
|
||||
if projected.w<=0.0 {return;}
|
||||
origin=projected.xyz;
|
||||
flux=irradiance*(projected.w*inverse_source_probability/f32(count));
|
||||
} else if !continuous {
|
||||
let light=u.punctual_lights[min(selected-min(selected,directions),7u)];
|
||||
origin=light.position.xyz;origin_low=light.position_low.xyz;source_minimum=light.position_low.w;
|
||||
var density=1.0/(4.0*PI);var cone=1.0;
|
||||
var density=1.0/(4.0*PI);var feather=1.0;
|
||||
if light.cone.y>0.0 {
|
||||
let cosine=1.0-random(&rng)*(1.0-light.cone.x);let phi=2.0*PI*random(&rng);
|
||||
let sine=sqrt(max(0.0,1.0-cosine*cosine));
|
||||
direction=from_local(vec3<f32>(sine*cos(phi),sine*sin(phi),cosine),light.axis.xyz);
|
||||
density=1.0/(2.0*PI*(1.0-light.cone.x));
|
||||
let blend=clamp((cosine-light.cone.x)/(light.axis.w-light.cone.x),0.0,1.0);
|
||||
cone=blend*blend*(3.0-2.0*blend);
|
||||
feather=photon_spot_feather(cosine,light.cone.x,light.axis.w);
|
||||
} else {direction=photon_unit_sphere(&rng);}
|
||||
flux=light.color.xyz*(light.color.w*light.position.w*light.position.w*cone*f32(sources)/(density*f32(count)));
|
||||
let cone=photon_guide_cone(guide,origin,origin_low,select(2.0,1.0-light.cone.x,light.cone.y>0.0));
|
||||
if cone.width>0.0 {
|
||||
let sampled_cone=random(&rng)<PHOTON_GUIDE_MIX;
|
||||
if sampled_cone {
|
||||
direction=photon_sample_cone(cone,&rng);
|
||||
if light.cone.y>0.0 {feather=photon_spot_feather(dot(light.axis.xyz,direction),light.cone.x,light.axis.w);}
|
||||
}
|
||||
if feather<=0.0 {return;}
|
||||
// A direction with positive spot emission is in the baseline cone.
|
||||
density=photon_mixture_pdf(cone,direction,density,sampled_cone);
|
||||
}
|
||||
flux=light.color.xyz*(light.color.w*light.position.w*light.position.w*feather*inverse_source_probability/(density*f32(count)));
|
||||
} else if area {
|
||||
let light=u.area_lights[min(area_index,3u)];
|
||||
origin=light.center.xyz;
|
||||
origin_low=(2.0*random(&rng)-1.0)*light.u.xyz+(2.0*random(&rng)-1.0)*light.v.xyz;
|
||||
let normal=unit(cross(light.u.xyz,light.v.xyz));
|
||||
let radius_squared=random(&rng);let phi=2.0*PI*random(&rng);
|
||||
let radius=sqrt(radius_squared);
|
||||
direction=from_local(vec3<f32>(radius*cos(phi),radius*sin(phi),sqrt(1.0-radius_squared)),normal);
|
||||
// Uniform area and cosine solid-angle sampling cancel the emitted
|
||||
// cosine only in the baseline. The mixture retains that factor.
|
||||
var angular_weight=PI;
|
||||
let cone=photon_guide_cone(guide,origin,origin_low,0.5);
|
||||
if cone.width>0.0 {
|
||||
let sampled_cone=random(&rng)<PHOTON_GUIDE_MIX;
|
||||
if sampled_cone {direction=photon_sample_cone(cone,&rng);}
|
||||
let cosine=dot(normal,direction);
|
||||
if cosine<=0.0 {return;}
|
||||
angular_weight=cosine/photon_mixture_pdf(cone,direction,cosine/PI,sampled_cone);
|
||||
}
|
||||
flux=light.radiance.xyz*(angular_weight*light.center.w*inverse_source_probability/f32(count));
|
||||
} else {
|
||||
let sample=env_sample(&rng);
|
||||
if !(sample.pdf>0.0) || !any(sample.radiance>vec3<f32>(0.0)) {return;}
|
||||
direction=-sample.direction;
|
||||
let projected=photon_project(direction,guide,&rng);
|
||||
if projected.w<=0.0 {return;}
|
||||
origin=projected.xyz;
|
||||
// The disk encloses scene geometry. Analytic rectangles can lie farther
|
||||
// toward the sampled sky, so test that part of the same line separately.
|
||||
if emitter_hit_precise(origin,origin_low,-direction,0.0).index!=0xffffffffu {return;}
|
||||
flux=sample.radiance*(projected.w*inverse_source_probability/(sample.pdf*f32(count)));
|
||||
}
|
||||
if !any(flux>vec3<f32>(0.0)) {return;}
|
||||
var wavelength=0.0;
|
||||
@@ -130,17 +310,17 @@ fn photon_trace(index:u32,reservoir:ptr<function,PhotonReservoir>) {
|
||||
let source_position=origin;let source_position_low=origin_low;
|
||||
(*reservoir).source=source_position;(*reservoir).source_low=source_position_low;(*reservoir).minimum=source_minimum;
|
||||
var media:array<Medium,MEDIUM_CAPACITY>;var medium_count=0u;
|
||||
let first=origin_boundary(origin+origin_low,direction);
|
||||
let first=photon_origin_boundary(origin,origin_low,direction);
|
||||
if !first.valid {photon_bad_medium();return;}
|
||||
if first.hit.triangle!=0xffffffffu && !initialize_media_spectral(first.hit.triangle,direction,&media,&medium_count,wavelength) {photon_bad_medium();return;}
|
||||
var previous=origin;var previous_low=origin_low;var excluded=0xffffffffu;var minimum=0.0;var optical=false;
|
||||
let maximum=photon_word(3u);
|
||||
for(var depth=0u;depth<maximum;depth+=1u) {
|
||||
let hit=trace_precise(origin,origin_low,direction,false,excluded,minimum,medium_count>0u);
|
||||
let emitter=emitter_hit_precise(origin,origin_low,direction,minimum);
|
||||
let emitter=emitter_hit_precise_except(origin,origin_low,direction,minimum,select(0xffffffffu,area_index,depth==0u));
|
||||
if medium_count>0u && media[medium_count-1u].scatter.x>0.0 {
|
||||
let current=media[medium_count-1u];let flight=-log(1.0-random(&rng))/current.scatter.x;
|
||||
if flight<min(hit.distance,emitter.distance) {
|
||||
if continuous {return;}
|
||||
let point=origin+origin_low+direction*flight;
|
||||
flux*=exp(-current.sigma*length(precise_point_delta(point,vec3<f32>(0.0),previous,previous_low)));
|
||||
if optical {photon_offer(reservoir,PrecisePoint(point,vec3<f32>(0.0)),-direction,flux,vec3<f32>(0.0),bitcast<u32>(current.id),3u,0);}
|
||||
@@ -165,9 +345,13 @@ fn photon_trace(index:u32,reservoir:ptr<function,PhotonReservoir>) {
|
||||
if !all(flux>=vec3<f32>(0.0)) || !all(flux<=vec3<f32>(3.4e38)) {atomicOr(&failures[7],1u);return;}
|
||||
if !any(flux>vec3<f32>(0.0)) {return;}
|
||||
if t.base.w<=0.0 {
|
||||
if optical {photon_offer(reservoir,point,-direction,flux,geometric,hit.triangle,1u,0);}
|
||||
if optical {
|
||||
if continuous {(*reservoir).smooth_events=depth;}
|
||||
photon_offer(reservoir,point,-direction,flux,geometric,hit.triangle,1u,0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if continuous && (t.parameters.w>=0.0 || t.glass.x>0.0) {return;}
|
||||
var normal=unit(t.n0.xyz*hit.bary.x+t.n1.xyz*hit.bary.y+t.n2.xyz*hit.bary.z);
|
||||
if dot(normal,geometric)<0.0 {normal=-normal;}
|
||||
if dot(normal,-direction)<=0.0 {normal=geometric;}
|
||||
@@ -236,7 +420,7 @@ fn photon_trace(index:u32,reservoir:ptr<function,PhotonReservoir>) {
|
||||
previous=point.hi;previous_low=point.lo;
|
||||
}
|
||||
}
|
||||
fn photon_gather(t:Triangle,position:vec3<f32>,normal:vec3<f32>,geometric:vec3<f32>,view:vec3<f32>) -> vec3<f32> {
|
||||
fn photon_gather(t:Triangle,position:vec3<f32>,normal:vec3<f32>,geometric:vec3<f32>,view:vec3<f32>,remaining:u32) -> vec3<f32> {
|
||||
let count=photon_word(0u);if count==0u || t.base.w>0.0 {return vec3<f32>(0.0);}
|
||||
let radius=photon_float(2u);let center=photon_cell(position);var result=vec3<f32>(0.0);
|
||||
for(var z=-1;z<=1;z+=1) {for(var y=-1;y<=1;y+=1) {for(var x=-1;x<=1;x+=1) {
|
||||
@@ -244,7 +428,9 @@ fn photon_gather(t:Triangle,position:vec3<f32>,normal:vec3<f32>,geometric:vec3<f
|
||||
loop {
|
||||
if index==0xffffffffu || index>=count || visited>=count {break;}visited+=1u;
|
||||
let record=photon_record(index);let point=photon_vector(record);let delta=point-position;
|
||||
if photon_word(record+11u)==1u && photon_receiver_allowed(record,position) && all(photon_cell(point)==cell) && dot(delta,delta)<=radius*radius && abs(dot(delta,geometric))<=radius*0.1 && dot(photon_vector(record+12u),geometric)>0.0 && triangle(min(photon_word(record+7u),max(geometry[0],1u)-1u)).optics.w==t.optics.w {
|
||||
let smooth_events=photon_word(record+15u)>>6u;
|
||||
// The camera needs one loop slot beyond the glass chain to see the emitter.
|
||||
if photon_word(record+11u)==1u && (smooth_events==0u || smooth_events<remaining) && photon_receiver_allowed(record,position) && all(photon_cell(point)==cell) && dot(delta,delta)<=radius*radius && abs(dot(delta,geometric))<=radius*0.1 && dot(photon_vector(record+12u),geometric)>0.0 && triangle(min(photon_word(record+7u),max(geometry[0],1u)-1u)).optics.w==t.optics.w {
|
||||
let incoming=photon_vector(record+4u);
|
||||
if dot(incoming,normal)>0.0 && dot(incoming,geometric)>0.0 {result+=brdf(t,normal,view,incoming)*photon_vector(record+8u)*(dot(incoming,normal)/dot(incoming,geometric));}
|
||||
}
|
||||
@@ -254,8 +440,11 @@ fn photon_gather(t:Triangle,position:vec3<f32>,normal:vec3<f32>,geometric:vec3<f
|
||||
return result/(PI*radius*radius);
|
||||
}
|
||||
fn photon_enabled()->bool {return photon_word(0u)>0u;}
|
||||
fn photon_owns_smooth_tail(events:u32,environment:bool)->bool {
|
||||
return events>1u && photon_word(select(12u,13u,environment))>0u && events<=photon_word(3u);
|
||||
}
|
||||
fn photon_receiver_allowed(record:u32,position:vec3<f32>)->bool {
|
||||
let source=photon_word(record+15u)>>2u;
|
||||
let source=(photon_word(record+15u)>>2u)&15u;
|
||||
if source==0u {return true;}
|
||||
if source>u32(u.light_count.y) {return false;}
|
||||
let light=u.punctual_lights[min(max(source,1u)-1u,7u)];
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Legacy jobs retain their six-storage-buffer pipeline and own no photon map.
|
||||
fn photon_gather(t:Triangle,position:vec3<f32>,normal:vec3<f32>,geometric:vec3<f32>,view:vec3<f32>) -> vec3<f32> {
|
||||
fn photon_gather(t:Triangle,position:vec3<f32>,normal:vec3<f32>,geometric:vec3<f32>,view:vec3<f32>,remaining:u32) -> vec3<f32> {
|
||||
return vec3<f32>(0.0);
|
||||
}
|
||||
fn photon_owns_smooth_tail(events:u32,environment:bool)->bool {return false;}
|
||||
fn photon_enabled()->bool {return false;}
|
||||
fn photon_gather_glass(t:Triangle,position:vec3<f32>,normal:vec3<f32>,geometric:vec3<f32>,incident:vec3<f32>,ni:f32,nt:f32,wavelength:f32,side:i32)->vec3<f32> {return vec3<f32>(0.0);}
|
||||
fn photon_gather_volume(position:vec3<f32>,incident:vec3<f32>,medium:Medium)->vec3<f32> {return vec3<f32>(0.0);}
|
||||
|
||||
@@ -71,6 +71,8 @@ pub(super) struct Upload {
|
||||
pub nodes: Vec<u8>,
|
||||
pub epsilon: f32,
|
||||
pub active_glass_model: bool,
|
||||
pub photon_guide: Option<[f32; 4]>,
|
||||
pub photon_bounds: Option<[f32; 6]>,
|
||||
}
|
||||
|
||||
fn invalid(message: &str) -> JobError {
|
||||
@@ -106,6 +108,8 @@ pub(super) fn flatten_with_media(scene: &ProductScene, origin: Vec3, cancel: &Ca
|
||||
let mut vertices = reserved(vertex_count * 32)?;
|
||||
let mut vertex_offset = 0u32;
|
||||
let mut media_bounds = reserved::<super::media_hierarchy::Region>(solidcraft_render::job::MAX_GPU_DIELECTRICS)?;
|
||||
let mut optical_lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
|
||||
let mut optical_hi = -optical_lo;
|
||||
for (body_index, body) in scene.meshes.iter().enumerate() {
|
||||
cancel.check()?;
|
||||
let body_start = triangles.len();
|
||||
@@ -225,7 +229,7 @@ pub(super) fn flatten_with_media(scene: &ProductScene, origin: Vec3, cancel: &Ca
|
||||
let body_triangles = &triangles[body_start..];
|
||||
if body_triangles.iter().any(|t| t.transmission.is_some()) {
|
||||
if media_bounds.len() >= solidcraft_render::job::MAX_GPU_DIELECTRICS {
|
||||
return Err(invalid("at most 32 disjoint dielectric bodies are supported"));
|
||||
return Err(invalid("at most 128 disjoint dielectric bodies are supported"));
|
||||
}
|
||||
let closed = body_triangles.iter().any(|t| t.transmission.is_some_and(|v| v.thin_wall_thickness_mm().is_none()));
|
||||
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
|
||||
@@ -234,6 +238,10 @@ pub(super) fn flatten_with_media(scene: &ProductScene, origin: Vec3, cancel: &Ca
|
||||
for p in t.p {
|
||||
lo = lo.min(p);
|
||||
hi = hi.max(p);
|
||||
if t.transmission.is_some() {
|
||||
optical_lo = optical_lo.min(p);
|
||||
optical_hi = optical_hi.max(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
media_bounds.push(super::media_hierarchy::Region {
|
||||
@@ -242,6 +250,7 @@ pub(super) fn flatten_with_media(scene: &ProductScene, origin: Vec3, cancel: &Ca
|
||||
closed,
|
||||
triangles: body_start..triangles.len(),
|
||||
medium: body_index as u32 + 1,
|
||||
name: &body.name,
|
||||
});
|
||||
}
|
||||
vertex_offset = vertex_offset.checked_add(mesh.positions.len() as u32).ok_or_else(|| invalid("vertex offset overflow"))?;
|
||||
@@ -349,7 +358,28 @@ pub(super) fn flatten_with_media(scene: &ProductScene, origin: Vec3, cancel: &Ca
|
||||
}
|
||||
node_bytes.extend_from_slice(&node.count.to_le_bytes());
|
||||
}
|
||||
Ok(Upload { geometry, nodes: node_bytes, epsilon: (extent * 1e-7).max(1e-5), active_glass_model })
|
||||
// Exact extrema of retained f32 optical vertices, including sheets. Unlike
|
||||
// an emission proposal, this conservative origin certificate is always used.
|
||||
let photon_bounds = (optical_lo.is_finite() && optical_hi.is_finite()).then_some([
|
||||
optical_lo.x as f32,
|
||||
optical_lo.y as f32,
|
||||
optical_lo.z as f32,
|
||||
optical_hi.x as f32,
|
||||
optical_hi.y as f32,
|
||||
optical_hi.z as f32,
|
||||
]);
|
||||
let center = optical_lo * 0.5 + optical_hi * 0.5;
|
||||
let radius = (optical_hi - optical_lo).len() * 0.5;
|
||||
let photon_guide = if center.is_finite() && radius.is_finite() && radius > 0.0 {
|
||||
// The union already contains retained f32 optical vertices. Cover the
|
||||
// center's conversion and the shader's projected-basis arithmetic too.
|
||||
let margin = 32.0 * f64::from(f32::EPSILON) * (center.len() + radius);
|
||||
let radius = ((radius + margin) as f32).next_up();
|
||||
radius.is_finite().then_some([center.x as f32, center.y as f32, center.z as f32, radius])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Upload { geometry, nodes: node_bytes, epsilon: (extent * 1e-7).max(1e-5), active_glass_model, photon_guide, photon_bounds })
|
||||
}
|
||||
|
||||
fn uploaded_optical_positions(source: [Vec3; 3]) -> Result<[Vec3; 3]> {
|
||||
|
||||
+1001
-42
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,12 @@
|
||||
//! One read-only storage buffer: body lookup, planar records and linear RGBA mip chains.
|
||||
//! Body lookup, planar records and linear mip chains. The quality policy packs
|
||||
//! original RGBA8 source texels and linear RGBA16_UNORM mip tails.
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_render::job::{
|
||||
CancelToken, ErrorCode, JobError, Result,
|
||||
textures::{BaseColorMode, RuntimeTextures, TextureMap},
|
||||
textures::{BaseColorMode, RuntimeTextures, TextureMap, normal_direction_variance, unit_normal_texel},
|
||||
};
|
||||
use solidcraft_render::texture_input::MapRole;
|
||||
use solidcraft_render::texture_sampling::{
|
||||
TextureView,
|
||||
mips::{MAX_MIP_TAIL_BYTES, MipChain},
|
||||
@@ -24,6 +26,7 @@ fn pixels(
|
||||
bytes: &mut Vec<u8>,
|
||||
map: Option<&TextureMap>,
|
||||
cancel: &CancelToken,
|
||||
quality: bool,
|
||||
shared: &mut std::collections::BTreeMap<([u8; 32], u8), (u32, [u32; 2])>,
|
||||
) -> Result<(u32, [u32; 2])> {
|
||||
let Some(map) = map else {
|
||||
@@ -33,13 +36,23 @@ fn pixels(
|
||||
if let Some(record) = shared.get(&map.identity()) {
|
||||
return Ok(*record);
|
||||
}
|
||||
let rgba = map.decode(cancel)?;
|
||||
let (encoded, mut rgba) = map.decode_with_source(cancel)?;
|
||||
let normal_variance = quality && map.role() == MapRole::NormalOpenGl;
|
||||
if normal_variance {
|
||||
for (index, pixel) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
|
||||
if index % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
let normal = unit_normal_texel([pixel[0], pixel[1], pixel[2]]);
|
||||
pixel[..3].copy_from_slice(&normal);
|
||||
}
|
||||
}
|
||||
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() {
|
||||
// Portable inputs already use mips. The explicit quality policy also
|
||||
// filters legacy inputs, without altering their scalar-face precedence.
|
||||
let mips = if quality || 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())
|
||||
})?)
|
||||
@@ -66,11 +79,37 @@ fn pixels(
|
||||
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()?;
|
||||
if quality {
|
||||
if level == 0 {
|
||||
let encoding = match map.role() {
|
||||
MapRole::BaseColor => 1,
|
||||
MapRole::NormalOpenGl => 4,
|
||||
_ => 2,
|
||||
};
|
||||
write_word(bytes, descriptor + 3, encoding)?;
|
||||
bytes.extend_from_slice(encoded.rgba());
|
||||
} else {
|
||||
write_word(bytes, descriptor + 3, if normal_variance { 5 } else { 3 })?;
|
||||
for (index, pixel) in pixels.rgba().as_chunks::<4>().0.iter().enumerate() {
|
||||
if index % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
let [r, g, b, alpha] = pixel.map(f64::from);
|
||||
// Recover the mean length independently of direction quantization.
|
||||
// This alpha lane has no material-opacity meaning.
|
||||
let alpha = if normal_variance { normal_direction_variance([r, g, b]) } else { alpha };
|
||||
for value in [r, g, b, alpha] {
|
||||
bytes.extend_from_slice(&((value.clamp(0.0, 1.0) * 65535.0).round() as u16).to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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());
|
||||
@@ -88,6 +127,7 @@ pub(super) fn flatten(resources: Option<&RuntimeTextures>, body_count: usize, or
|
||||
let mut bytes = super::scene::reserved(capacity)?;
|
||||
bytes.resize((header + record_words) * 4, 0);
|
||||
write_word(&mut bytes, 0, (body_count + 1) as u32)?;
|
||||
write_word(&mut bytes, 1, u32::from(resources.filtering()))?;
|
||||
let mut shared = std::collections::BTreeMap::new();
|
||||
for (index, binding) in resources.bindings().iter().enumerate() {
|
||||
cancel.check()?;
|
||||
@@ -104,10 +144,10 @@ pub(super) fn flatten(resources: Option<&RuntimeTextures>, body_count: usize, or
|
||||
write_word(&mut bytes, record + offset + k, (c as f32).to_bits())?;
|
||||
}
|
||||
}
|
||||
let (base, base_size) = pixels(&mut bytes, binding.base_color.as_ref(), cancel, &mut shared)?;
|
||||
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 (base, base_size) = pixels(&mut bytes, binding.base_color.as_ref(), cancel, resources.filtering(), &mut shared)?;
|
||||
let (normal, normal_size) = pixels(&mut bytes, binding.normal.as_ref(), cancel, resources.filtering(), &mut shared)?;
|
||||
let (roughness, roughness_size) = pixels(&mut bytes, binding.roughness.as_ref(), cancel, resources.filtering(), &mut shared)?;
|
||||
let (metallic, metallic_size) = pixels(&mut bytes, binding.metallic.as_ref(), cancel, resources.filtering(), &mut shared)?;
|
||||
let roles = if metallic != 0 {
|
||||
4
|
||||
} else if roughness != 0 {
|
||||
@@ -139,6 +179,9 @@ pub(super) fn flatten(resources: Option<&RuntimeTextures>, body_count: usize, or
|
||||
}
|
||||
}
|
||||
cancel.check()?;
|
||||
if bytes.len() != capacity {
|
||||
return Err(JobError::new(ErrorCode::Internal, "GPU texture packing disagrees with its admitted size"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
@@ -146,6 +189,7 @@ pub(super) fn flatten(resources: Option<&RuntimeTextures>, body_count: usize, or
|
||||
/// 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 quality = resources.filtering();
|
||||
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() {
|
||||
@@ -155,16 +199,16 @@ pub(super) fn packed_bytes(resources: &RuntimeTextures, body_count: usize) -> u6
|
||||
}
|
||||
let [mut width, mut height] = map.dimensions().map(u64::from);
|
||||
let mut levels = 1;
|
||||
let mut pixels = width * height;
|
||||
if map.gpu_mip_filtering() {
|
||||
let mut pixel_bytes = width * height * if quality { 4 } else { 16 };
|
||||
if quality || map.gpu_mip_filtering() {
|
||||
while width > 1 || height > 1 {
|
||||
width = (width / 2).max(1);
|
||||
height = (height / 2).max(1);
|
||||
pixels += width * height;
|
||||
pixel_bytes += width * height * if quality { 8 } else { 16 };
|
||||
levels += 1;
|
||||
}
|
||||
}
|
||||
bytes += (4 + levels * 4) * 4 + pixels * 16;
|
||||
bytes += (4 + levels * 4) * 4 + pixel_bytes;
|
||||
}
|
||||
}
|
||||
bytes
|
||||
|
||||
@@ -8,10 +8,28 @@ fn texture_record(body_id:f32)->u32 {
|
||||
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_mean_normal(rgb:vec3<f32>,variance:f32)->vec3<f32> {
|
||||
let direction=2.0*rgb-vec3<f32>(1.0);
|
||||
let length_squared=dot(direction,direction);
|
||||
if direction.z<=0.0 || length_squared<1e-12 {return vec3<f32>(0.5,0.5,1.0);}
|
||||
return vec3<f32>(0.5)+0.5*direction*sqrt(max(0.0,1.0-variance)/length_squared);
|
||||
}
|
||||
fn texture_bilinear(offset:u32,size:vec2<u32>,uv:vec2<f32>)->vec3<f32> {
|
||||
fn texture_pixel(offset:u32,width:u32,x:u32,y:u32,encoding:u32)->vec3<f32> {
|
||||
let pixel=y*width+x;
|
||||
if encoding==0u {return texture_vector(offset+4u*pixel);}
|
||||
if encoding==3u || encoding==5u {
|
||||
let word=offset+2u*pixel;
|
||||
let ba=unpack2x16unorm(texture_words[word+1u]);
|
||||
let rgb=vec3<f32>(unpack2x16unorm(texture_words[word]),ba.x);
|
||||
if encoding==5u {return texture_mean_normal(rgb,ba.y);}
|
||||
return rgb;
|
||||
}
|
||||
let encoded=unpack4x8unorm(texture_words[offset+pixel]).xyz;
|
||||
if encoding==2u {return encoded;}
|
||||
if encoding==4u {return texture_mean_normal(encoded,0.0);}
|
||||
return select(encoded/12.92,pow((encoded+vec3<f32>(0.055))/1.055,vec3<f32>(2.4)),encoded>vec3<f32>(0.04045));
|
||||
}
|
||||
fn texture_bilinear(offset:u32,size:vec2<u32>,uv:vec2<f32>,encoding:u32)->vec3<f32> {
|
||||
let position=fract(uv)*vec2<f32>(size)-vec2<f32>(0.5);
|
||||
let lower=vec2<i32>(floor(position));
|
||||
// fract restricts lower to[-1,size-1]. Avoid integer modulo after a BVH
|
||||
@@ -21,10 +39,10 @@ fn texture_bilinear(offset:u32,size:vec2<u32>,uv:vec2<f32>)->vec3<f32> {
|
||||
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);
|
||||
let c=texture_pixel(offset,size.x,first.x,second.y);
|
||||
let d=texture_pixel(offset,size.x,second.x,second.y);
|
||||
let a=texture_pixel(offset,size.x,first.x,first.y,encoding);
|
||||
let b=texture_pixel(offset,size.x,second.x,first.y,encoding);
|
||||
let c=texture_pixel(offset,size.x,first.x,second.y,encoding);
|
||||
let d=texture_pixel(offset,size.x,second.x,second.y,encoding);
|
||||
return mix(mix(a,b,fraction.x),mix(c,d,fraction.x),fraction.y);
|
||||
}
|
||||
struct TextureGradients { dx:vec2<f32>, dy:vec2<f32> };
|
||||
@@ -40,24 +58,60 @@ fn texture_lod(size:vec2<u32>,gradients:TextureGradients,last:u32)->f32 {
|
||||
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));
|
||||
}
|
||||
struct TextureFilter { lod:f32, span:vec2<f32>, taps:u32 };
|
||||
fn texture_filter(size:vec2<u32>,gradients:TextureGradients,last:u32)->TextureFilter {
|
||||
if last==0u {return TextureFilter(0.0,vec2<f32>(0.0),1u);}
|
||||
if texture_words[1]==0u {return TextureFilter(texture_lod(size,gradients,last),vec2<f32>(0.0),1u);}
|
||||
// The two rows of the UV Jacobian form the covariance of the projected
|
||||
// pixel footprint. Normalize before squaring to bound grazing arithmetic.
|
||||
let u=vec2<f32>(gradients.dx.x,gradients.dy.x)*f32(size.x);
|
||||
let v=vec2<f32>(gradients.dx.y,gradients.dy.y)*f32(size.y);
|
||||
let magnitude=max(abs(u),abs(v));let scale=max(magnitude.x,magnitude.y);
|
||||
if !(scale<=1e30) {return TextureFilter(f32(last),vec2<f32>(0.0),1u);}
|
||||
if scale<=0.0 {return TextureFilter(0.0,vec2<f32>(0.0),1u);}
|
||||
let su=u/scale;let sv=v/scale;
|
||||
let a=dot(su,su);let b=dot(su,sv);let c=dot(sv,sv);
|
||||
let separation=length(vec2<f32>(a-c,2.0*b));
|
||||
let eigenvalue=0.5*(a+c+separation);
|
||||
let major=scale*sqrt(max(0.0,eigenvalue));
|
||||
let minor=scale*sqrt(max(0.0,0.5*(a+c-separation)));
|
||||
let width=max(1.0,max(minor,major/8.0));
|
||||
let lod=clamp(log2(width),0.0,f32(last));
|
||||
if lod>=f32(last) || major<=1.0 {return TextureFilter(lod,vec2<f32>(0.0),1u);}
|
||||
let eigen=vec2<f32>(b,eigenvalue-a);
|
||||
var axis=select(vec2<f32>(0.0,1.0),vec2<f32>(1.0,0.0),a>=c);
|
||||
if dot(eigen,eigen)>1e-24 {axis=normalize(eigen);}
|
||||
return TextureFilter(lod,axis*major/vec2<f32>(size),u32(clamp(ceil(major/width),1.0,8.0)));
|
||||
}
|
||||
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);
|
||||
return texture_bilinear(texture_words[record],vec2<u32>(texture_words[record+1u],texture_words[record+2u]),uv,texture_words[record+3u]);
|
||||
}
|
||||
fn texture_sample(descriptor:u32,size:vec2<u32>,uv:vec2<f32>,gradients:TextureGradients)->vec3<f32> {
|
||||
fn texture_sample(descriptor:u32,size:vec2<u32>,uv:vec2<f32>,gradients:TextureGradients)->vec4<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));}
|
||||
let footprint=texture_filter(size,gradients,last);
|
||||
let low=u32(floor(footprint.lod));let high=min(low+1u,last);
|
||||
let count=min(texture_words[descriptor+1u],select(2u,1u,low==high || fract(footprint.lod)==0.0));
|
||||
var total=vec3<f32>(0.0);
|
||||
// Keep one bilinear call site while the glass traversal state is live.
|
||||
for(var tap=0u;tap<footprint.taps;tap+=1u) {
|
||||
let position=uv+footprint.span*((f32(tap)+0.5)/f32(footprint.taps)-0.5);
|
||||
var color=vec3<f32>(0.0);
|
||||
for(var sample=0u;sample<count;sample+=1u) {
|
||||
let rgb=texture_level(descriptor,select(low,high,sample>0u),position);
|
||||
if sample==0u {color=rgb;} else {color=mix(color,rgb,fract(footprint.lod));}
|
||||
}
|
||||
total+=color;
|
||||
}
|
||||
return color;
|
||||
let rgb=total/f32(footprint.taps);
|
||||
var variance=0.0;
|
||||
if texture_words[descriptor+7u]==4u {
|
||||
let width=length(footprint.span*vec2<f32>(size));
|
||||
let minification=clamp(max(footprint.lod,width-1.0),0.0,1.0);
|
||||
let mean=2.0*rgb-vec3<f32>(1.0);
|
||||
variance=clamp(1.0-dot(mean,mean),0.0,1.0)*minification;
|
||||
}
|
||||
return vec4<f32>(rgb,variance);
|
||||
}
|
||||
fn texture_uv(record:u32,position:vec3<f32>)->vec2<f32> {
|
||||
let delta=position-texture_vector(record);
|
||||
@@ -98,6 +152,14 @@ fn texture_gradients(record:u32,normal:vec3<f32>,direction:vec3<f32>,distance:f3
|
||||
// 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);
|
||||
if texture_words[1]!=0u && abs(dot(normal,direction))>1e-6 {
|
||||
let axis=select(vec3<f32>(1.0,0.0,0.0),vec3<f32>(0.0,0.0,1.0),abs(direction.z)<0.9);
|
||||
let tangent=unit(cross(direction,axis));
|
||||
let diameter=min(pixel_span*distance_scale,1e20);
|
||||
let dx=texture_plane_differential(normal,direction,tangent*diameter);
|
||||
let dy=texture_plane_differential(normal,direction,cross(direction,tangent)*diameter);
|
||||
return texture_world_gradients(record,dx,dy);
|
||||
}
|
||||
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));
|
||||
@@ -112,6 +174,7 @@ fn texture_surface(record:u32,color:vec3<f32>,metallic:f32,roughness:f32,positio
|
||||
var mapped_metallic=metallic;
|
||||
var mapped_roughness=roughness;
|
||||
var mapped_normal=vec4<f32>(0.5,0.5,1.0,0.0);
|
||||
var normal_variance=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.
|
||||
@@ -127,15 +190,28 @@ fn texture_surface(record:u32,color:vec3<f32>,metallic:f32,roughness:f32,positio
|
||||
// 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);
|
||||
let sample=texture_sample(descriptor,size,uv,gradients);
|
||||
let rgb=sample.xyz;
|
||||
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);}
|
||||
} else if role==1u {
|
||||
mapped_normal=vec4<f32>(rgb,1.0);
|
||||
normal_variance=sample.w;
|
||||
}
|
||||
// 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);}
|
||||
}
|
||||
// Normal minification keeps unresolved direction variance in the base lobe.
|
||||
// Coat properties and the normal-strength zero case retain authored values.
|
||||
if normal_variance>1e-6 {
|
||||
let strength=bitcast<f32>(texture_words[record+15u]);
|
||||
if strength>0.0 {
|
||||
let alpha=mapped_roughness*mapped_roughness;
|
||||
mapped_roughness=sqrt(sqrt(min(1.0,alpha*alpha+normal_variance*strength*strength)));
|
||||
}
|
||||
}
|
||||
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> {
|
||||
@@ -178,7 +254,6 @@ fn texture_mip_reference() {
|
||||
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);
|
||||
let footprint=texture_filter(vec2<u32>(texture_words[record+7u],texture_words[record+11u]),gradients,last);
|
||||
accumulation[1]=vec4<f32>(footprint.lod,0.0,0.0,1.0);
|
||||
}
|
||||
|
||||
+93
-11
@@ -48,6 +48,7 @@ fn max_expanded() -> usize {
|
||||
max_step_part().saturating_add(max_design())
|
||||
}
|
||||
const MESH_MAGIC: &[u8; 8] = b"SCMESH01";
|
||||
const SHADED_MESH_MAGIC: &[u8; 8] = b"SCMESH02";
|
||||
const POINTS_MAGIC: &[u8; 8] = b"SCPNTS01";
|
||||
const MAX_PREVIEW_BYTES: usize = 8 << 20;
|
||||
const MAX_PREVIEW_TRIANGLES: usize = 100_000;
|
||||
@@ -236,7 +237,8 @@ fn write_container(
|
||||
// or it could never be saved (#6600). Compact deflates every mesh.
|
||||
let compress = compression == DesignCompression::Compact || bytes.len() > max_part();
|
||||
let blob = writer.blob(&bytes, "mesh", compress)?;
|
||||
let mut value = replace(mesh, fields([("positions", json!([])), ("triangles", json!([]))])).map_err(invalid)?;
|
||||
let mut value = replace(mesh, fields([("positions", json!([])), ("triangles", json!([])), ("corner_normals", json!([]))]))
|
||||
.map_err(invalid)?;
|
||||
value.as_object_mut().ok_or_else(|| invalid("mesh metadata"))?.insert("mesh_blob".into(), blob);
|
||||
metadata.push(value);
|
||||
}
|
||||
@@ -345,6 +347,11 @@ fn validate_mesh(mesh: &MeshData) -> Result<()> {
|
||||
|| !mesh.triangles.len().is_multiple_of(3)
|
||||
|| mesh.positions.iter().any(|v| !v.is_finite() || v.abs() > 1e9)
|
||||
|| mesh.triangles.iter().any(|&v| v as usize >= mesh.positions.len() / 3)
|
||||
|| (!mesh.corner_normals.is_empty() && mesh.triangles.len().checked_mul(3) != Some(mesh.corner_normals.len()))
|
||||
|| mesh.corner_normals.as_chunks::<3>().0.iter().any(|n| {
|
||||
let length = n[0].hypot(n[1]).hypot(n[2]);
|
||||
!length.is_finite() || length == 0.0
|
||||
})
|
||||
{
|
||||
return Err(invalid("invalid mesh coordinates or indices"));
|
||||
}
|
||||
@@ -361,24 +368,42 @@ fn mesh_encoded_len(positions: usize, triangles: usize) -> Option<usize> {
|
||||
}
|
||||
|
||||
fn encode_mesh(mesh: &MeshData) -> Result<Vec<u8>> {
|
||||
let size = mesh_encoded_len(mesh.positions.len(), mesh.triangles.len()).ok_or_else(|| invalid("mesh too large"))?;
|
||||
let legacy_size = mesh_encoded_len(mesh.positions.len(), mesh.triangles.len()).ok_or_else(|| invalid("mesh too large"))?;
|
||||
let size = if mesh.corner_normals.is_empty() {
|
||||
legacy_size
|
||||
} else {
|
||||
mesh.corner_normals
|
||||
.len()
|
||||
.checked_mul(8)
|
||||
.and_then(|n| n.checked_add(legacy_size))
|
||||
.and_then(|n| n.checked_add(8))
|
||||
.filter(|&n| n <= max_mesh_part())
|
||||
.ok_or_else(|| invalid("mesh shading normals too large"))?
|
||||
};
|
||||
validate_mesh(mesh)?;
|
||||
let mut bytes = Vec::new();
|
||||
bytes.try_reserve_exact(size).map_err(|_| invalid("could not reserve mesh blob"))?;
|
||||
bytes.extend_from_slice(MESH_MAGIC);
|
||||
bytes.extend_from_slice(if mesh.corner_normals.is_empty() { MESH_MAGIC } else { SHADED_MESH_MAGIC });
|
||||
bytes.extend_from_slice(&(mesh.positions.len() as u64).to_le_bytes());
|
||||
bytes.extend_from_slice(&(mesh.triangles.len() as u64).to_le_bytes());
|
||||
if !mesh.corner_normals.is_empty() {
|
||||
bytes.extend_from_slice(&(mesh.corner_normals.len() as u64).to_le_bytes());
|
||||
}
|
||||
for value in mesh.positions.iter() {
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
for value in mesh.triangles.iter() {
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
for value in mesh.corner_normals.iter() {
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn decode_mesh(bytes: &[u8], mesh: &mut MeshData) -> Result<()> {
|
||||
if bytes.get(..8) != Some(MESH_MAGIC.as_slice()) {
|
||||
let shaded = bytes.get(..8) == Some(SHADED_MESH_MAGIC.as_slice());
|
||||
if !shaded && bytes.get(..8) != Some(MESH_MAGIC.as_slice()) {
|
||||
return Err(invalid("unsupported mesh blob version"));
|
||||
}
|
||||
let count = |range: std::ops::Range<usize>| -> Result<usize> {
|
||||
@@ -387,15 +412,30 @@ fn decode_mesh(bytes: &[u8], mesh: &mut MeshData) -> Result<()> {
|
||||
};
|
||||
let positions = count(8..16)?;
|
||||
let triangles = count(16..24)?;
|
||||
let middle = positions.checked_mul(8).and_then(|n| n.checked_add(24)).ok_or_else(|| invalid("mesh size overflow"))?;
|
||||
let total = triangles.checked_mul(4).and_then(|n| n.checked_add(middle)).ok_or_else(|| invalid("mesh size overflow"))?;
|
||||
if total != bytes.len() || !positions.is_multiple_of(3) || !triangles.is_multiple_of(3) {
|
||||
let normals = if shaded { count(24..32)? } else { 0 };
|
||||
let header = if shaded { 32 } else { 24 };
|
||||
let middle = positions.checked_mul(8).and_then(|n| n.checked_add(header)).ok_or_else(|| invalid("mesh size overflow"))?;
|
||||
let normal_start = triangles.checked_mul(4).and_then(|n| n.checked_add(middle)).ok_or_else(|| invalid("mesh size overflow"))?;
|
||||
let total = normals.checked_mul(8).and_then(|n| n.checked_add(normal_start)).ok_or_else(|| invalid("mesh size overflow"))?;
|
||||
if total != bytes.len()
|
||||
|| !positions.is_multiple_of(3)
|
||||
|| !triangles.is_multiple_of(3)
|
||||
|| (normals != 0 && triangles.checked_mul(3) != Some(normals))
|
||||
{
|
||||
return Err(invalid("invalid mesh blob lengths"));
|
||||
}
|
||||
mesh.positions =
|
||||
bytes.get(24..middle).ok_or_else(|| invalid("truncated mesh"))?.as_chunks::<8>().0.iter().map(|&v| f64::from_le_bytes(v)).collect();
|
||||
bytes.get(header..middle).ok_or_else(|| invalid("truncated mesh"))?.as_chunks::<8>().0.iter().map(|&v| f64::from_le_bytes(v)).collect();
|
||||
mesh.triangles =
|
||||
bytes.get(middle..).ok_or_else(|| invalid("truncated mesh"))?.as_chunks::<4>().0.iter().map(|&v| u32::from_le_bytes(v)).collect();
|
||||
bytes.get(middle..normal_start).ok_or_else(|| invalid("truncated mesh"))?.as_chunks::<4>().0.iter().map(|&v| u32::from_le_bytes(v)).collect();
|
||||
mesh.corner_normals = bytes
|
||||
.get(normal_start..)
|
||||
.ok_or_else(|| invalid("truncated mesh normals"))?
|
||||
.as_chunks::<8>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|&v| f64::from_le_bytes(v))
|
||||
.collect();
|
||||
validate_mesh(mesh)
|
||||
}
|
||||
|
||||
@@ -575,7 +615,13 @@ fn encode_preview(preview: &DesignPreview, document: &[u8]) -> Result<(Vec<u8>,
|
||||
let mut triangles = 0usize;
|
||||
for mesh in &preview.meshes {
|
||||
triangles = triangles.saturating_add(mesh.triangles.len() / 3);
|
||||
let size = mesh.positions.len().saturating_mul(8).saturating_add(mesh.triangles.len().saturating_mul(4)).saturating_add(24);
|
||||
let size = mesh
|
||||
.positions
|
||||
.len()
|
||||
.saturating_mul(8)
|
||||
.saturating_add(mesh.triangles.len().saturating_mul(4))
|
||||
.saturating_add(mesh.corner_normals.len().saturating_mul(8))
|
||||
.saturating_add(32);
|
||||
if data.len().saturating_add(size) > MAX_PREVIEW_BYTES || triangles > MAX_PREVIEW_TRIANGLES || mesh.name.len() > 1024 {
|
||||
return Err(invalid("preview budget"));
|
||||
}
|
||||
@@ -633,7 +679,13 @@ fn read_preview<R: Read + Seek>(reader: &mut PackageReader<R>) -> Result<DesignP
|
||||
return Err(invalid("overlapping preview bodies"));
|
||||
}
|
||||
end = end.checked_add(length).ok_or_else(|| invalid("preview overflow"))?;
|
||||
let mut mesh = MeshData { name: String::new(), positions: Vec::new().into(), triangles: Vec::new().into(), color: None };
|
||||
let mut mesh = MeshData {
|
||||
name: String::new(),
|
||||
positions: Vec::new().into(),
|
||||
triangles: Vec::new().into(),
|
||||
color: None,
|
||||
corner_normals: Default::default(),
|
||||
};
|
||||
decode_mesh(data.get(offset..end).ok_or_else(|| invalid("truncated preview"))?, &mut mesh)?;
|
||||
triangles = triangles.saturating_add(mesh.triangles.len() / 3);
|
||||
if triangles > MAX_PREVIEW_TRIANGLES {
|
||||
@@ -1832,12 +1884,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaded_mesh_blob_and_json_roundtrip_keep_corner_seams() {
|
||||
let mut m = mesh();
|
||||
m.corner_normals = [1., 0., 1., 0., 1., 1., 0., 0., 1.].into();
|
||||
let bytes = encode_mesh(&m).unwrap();
|
||||
assert_eq!(bytes.get(..8), Some(SHADED_MESH_MAGIC.as_slice()));
|
||||
let mut restored = mesh();
|
||||
decode_mesh(&bytes, &mut restored).unwrap();
|
||||
assert_eq!(restored, m);
|
||||
let mut doc = Document::new("shaded source");
|
||||
doc.add_feature(FeatureKind::MeshImport { file: "smooth.obj".into(), meshes: vec![m.clone()] }, None).unwrap();
|
||||
for bytes in [crate::write_design(&doc), write_design_container(&doc).unwrap()] {
|
||||
let back = crate::read_design(&bytes).unwrap();
|
||||
assert_eq!(back, doc);
|
||||
let mut model = solidcraft_doc::Model::new();
|
||||
model.evaluate(&back);
|
||||
let mesh = model.state().bodies[0].mesh();
|
||||
assert!((mesh.normals[0].x - 2f64.sqrt().recip()).abs() < 1e-12);
|
||||
assert_ne!(mesh.normals[0], mesh.normals[1]);
|
||||
}
|
||||
restored.corner_normals = [0.; 9].into();
|
||||
assert!(encode_mesh(&restored).is_err());
|
||||
let legacy = encode_mesh(&mesh()).unwrap();
|
||||
decode_mesh(&legacy, &mut restored).unwrap();
|
||||
assert!(restored.corner_normals.is_empty());
|
||||
assert!(decode_mesh(&bytes[..bytes.len() - 1], &mut restored).is_err());
|
||||
}
|
||||
|
||||
fn mesh() -> MeshData {
|
||||
MeshData {
|
||||
name: "Precise mesh".into(),
|
||||
positions: vec![0.123456789012345, -0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0].into(),
|
||||
triangles: vec![0, 1, 2].into(),
|
||||
color: Some([0.25, 0.5, 0.75]),
|
||||
corner_normals: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2245,6 +2326,7 @@ mod tests {
|
||||
positions: Arc::clone(&positions),
|
||||
triangles: (0..indices).map(|i| (i % 3) as u32).collect(),
|
||||
color: None,
|
||||
corner_normals: Default::default(),
|
||||
};
|
||||
let mut doc = Document::new("Large mesh");
|
||||
doc.add_feature(FeatureKind::MeshImport { file: "large.stl".into(), meshes: vec![mesh] }, None).unwrap();
|
||||
|
||||
@@ -131,13 +131,19 @@ const MAX_MESH_HEADER: usize = 16 << 20;
|
||||
/// colour and paint), then each vertex as three f64 and each triangle as three u32, little-endian.
|
||||
fn encode_mesh_body(body: &solidcraft_kernel::Body) -> Option<Vec<u8>> {
|
||||
let (positions, triangles) = body.triangle_mesh()?;
|
||||
let normals = body.triangle_corner_normals()?;
|
||||
let header =
|
||||
serde_json::to_vec(&json!({"positions": positions.len(), "triangles": triangles.len(), "color": body.color(), "paint": body.paint()}))
|
||||
serde_json::to_vec(&json!({"positions": positions.len(), "triangles": triangles.len(), "corner_normals": normals.len(), "color": body.color(), "paint": body.paint()}))
|
||||
.ok()?;
|
||||
if header.len() > MAX_MESH_HEADER {
|
||||
return None;
|
||||
}
|
||||
let size = positions.len().checked_mul(24)?.checked_add(triangles.len().checked_mul(12)?)?.checked_add(12 + header.len())?;
|
||||
let size = positions
|
||||
.len()
|
||||
.checked_mul(24)?
|
||||
.checked_add(triangles.len().checked_mul(12)?)?
|
||||
.checked_add(normals.len().checked_mul(72)?)?
|
||||
.checked_add(12 + header.len())?;
|
||||
let mut out = Vec::with_capacity(size);
|
||||
out.extend_from_slice(MESH_TAG);
|
||||
out.extend_from_slice(&u32::try_from(header.len()).ok()?.to_le_bytes());
|
||||
@@ -152,6 +158,11 @@ fn encode_mesh_body(body: &solidcraft_kernel::Body) -> Option<Vec<u8>> {
|
||||
out.extend_from_slice(&i.to_le_bytes());
|
||||
}
|
||||
}
|
||||
for n in normals.iter().flatten() {
|
||||
for x in [n.x, n.y, n.z] {
|
||||
out.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -164,15 +175,21 @@ fn decode_mesh_body(bytes: &[u8]) -> Option<solidcraft_kernel::Body> {
|
||||
let header: Value = serde_json::from_slice(header).ok()?;
|
||||
let count = |key: &str| header.get(key).and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok());
|
||||
let (vertices, triangles) = (count("positions")?, count("triangles")?);
|
||||
let normals = count("corner_normals").unwrap_or(0);
|
||||
if normals != 0 && normals != triangles {
|
||||
return None;
|
||||
}
|
||||
if triangles > solidcraft_kernel::MAX_TRIANGLES {
|
||||
return None;
|
||||
}
|
||||
// The counts must account for every byte, so nothing is allocated for data that is not there.
|
||||
let split = vertices.checked_mul(24)?;
|
||||
if split.checked_add(triangles.checked_mul(12)?)? != data.len() {
|
||||
let normal_start = split.checked_add(triangles.checked_mul(12)?)?;
|
||||
if normal_start.checked_add(normals.checked_mul(72)?)? != data.len() {
|
||||
return None;
|
||||
}
|
||||
let (coordinates, corners) = data.split_at_checked(split)?;
|
||||
let (coordinates, rest) = data.split_at_checked(split)?;
|
||||
let (corners, shading) = rest.split_at_checked(triangles.checked_mul(12)?)?;
|
||||
let f64_at = |c: &[u8], at: usize| c.get(at..at + 8)?.try_into().ok().map(f64::from_le_bytes);
|
||||
let u32_at = |c: &[u8], at: usize| c.get(at..at + 4)?.try_into().ok().map(u32::from_le_bytes);
|
||||
let positions: Vec<solidcore_record::Vec3> = coordinates
|
||||
@@ -183,12 +200,21 @@ fn decode_mesh_body(bytes: &[u8]) -> Option<solidcraft_kernel::Body> {
|
||||
.collect::<Option<_>>()?;
|
||||
let triangles: Vec<[u32; 3]> =
|
||||
corners.as_chunks::<12>().0.iter().map(|c| Some([u32_at(c, 0)?, u32_at(c, 4)?, u32_at(c, 8)?])).collect::<Option<_>>()?;
|
||||
let normals: Vec<[solidcore_record::Vec3; 3]> = shading
|
||||
.as_chunks::<72>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let n = |offset| Some(solidcore_record::Vec3::new(f64_at(c, offset)?, f64_at(c, offset + 8)?, f64_at(c, offset + 16)?));
|
||||
Some([n(0)?, n(24)?, n(48)?])
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
let color: Option<[f32; 3]> = serde_json::from_value(header.get("color").cloned().unwrap_or(Value::Null)).ok()?;
|
||||
let paint: Option<solidcraft_kernel::Paint> = serde_json::from_value(header.get("paint").cloned().unwrap_or(Value::Null)).ok()?;
|
||||
if color.is_some_and(|c| c.iter().any(|x| !x.is_finite())) {
|
||||
return None;
|
||||
}
|
||||
Some(solidcraft_kernel::mesh_body(&positions, &triangles).ok()?.with_color(color).with_paint(paint))
|
||||
Some(solidcraft_kernel::mesh_body_with_normals(&positions, &triangles, &normals).ok()?.with_color(color).with_paint(paint))
|
||||
}
|
||||
|
||||
/// A mesh body decoded is the one encoded: the same vertices, bit for bit, the same triangles,
|
||||
@@ -196,7 +222,10 @@ fn decode_mesh_body(bytes: &[u8]) -> Option<solidcraft_kernel::Body> {
|
||||
fn same_mesh(a: &solidcraft_kernel::Body, b: &solidcraft_kernel::Body) -> bool {
|
||||
let (Some((pa, ta)), Some((pb, tb))) = (a.triangle_mesh(), b.triangle_mesh()) else { return false };
|
||||
let bits = |p: &[solidcore_record::Vec3]| p.iter().map(|v| [v.x.to_bits(), v.y.to_bits(), v.z.to_bits()]).collect::<Vec<_>>();
|
||||
ta == tb && bits(pa) == bits(pb) && a.color() == b.color() && a.paint() == b.paint()
|
||||
let normals = |body: &solidcraft_kernel::Body| {
|
||||
body.triangle_corner_normals().unwrap_or_default().iter().flatten().map(|n| [n.x.to_bits(), n.y.to_bits(), n.z.to_bits()]).collect::<Vec<_>>()
|
||||
};
|
||||
ta == tb && bits(pa) == bits(pb) && normals(a) == normals(b) && a.color() == b.color() && a.paint() == b.paint()
|
||||
}
|
||||
|
||||
/// A body of the cache's payload, mesh or B-rep by the slice's tag.
|
||||
@@ -486,7 +515,7 @@ pub(super) fn geometry_preview(document: &[u8], model: &SavedModel) -> Result<De
|
||||
}
|
||||
};
|
||||
triangles = triangles.saturating_add(faces.len());
|
||||
display_bytes = display_bytes.saturating_add(positions.len().saturating_mul(24)).saturating_add(faces.len().saturating_mul(12));
|
||||
display_bytes = display_bytes.saturating_add(positions.len().saturating_mul(24)).saturating_add(faces.len().saturating_mul(84));
|
||||
if triangles > 1_000_000 || display_bytes > 128 << 20 {
|
||||
return Err(invalid("regenerated display budget"));
|
||||
}
|
||||
@@ -495,6 +524,7 @@ pub(super) fn geometry_preview(document: &[u8], model: &SavedModel) -> Result<De
|
||||
positions: positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: faces.iter().flatten().copied().collect(),
|
||||
color: entry.body.color(),
|
||||
corner_normals: entry.body.triangle_corner_normals().unwrap_or_default().iter().flatten().flat_map(|n| [n.x, n.y, n.z]).collect(),
|
||||
};
|
||||
validate_mesh(&displayed)?;
|
||||
preview.meshes.push(displayed);
|
||||
|
||||
@@ -704,6 +704,7 @@ impl<'d> Reader<'d, '_, '_> {
|
||||
positions: positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: triangles.iter().flatten().copied().collect(),
|
||||
color: look.as_ref().map(Look::rgb),
|
||||
corner_normals: Default::default(),
|
||||
});
|
||||
self.looks.push(look);
|
||||
self.plan.bodies.push(slot);
|
||||
|
||||
+15
-2
@@ -360,12 +360,14 @@ pub fn mesh_import_feature_in(bytes: &[u8], file: &str, mm_per_unit: f64) -> Res
|
||||
// Check every mesh now, so the command fails rather than the timeline.
|
||||
let mut meshes = Vec::with_capacity(objects.len());
|
||||
for o in &objects {
|
||||
solidcraft_kernel::mesh_body(&o.positions, &o.triangles).map_err(|e| IoError::Invalid(format!("mesh `{}`: {e}", o.name)))?;
|
||||
solidcraft_kernel::mesh_body_with_normals(&o.positions, &o.triangles, &o.corner_normals)
|
||||
.map_err(|e| IoError::Invalid(format!("mesh `{}`: {e}", o.name)))?;
|
||||
meshes.push(solidcraft_doc::MeshData {
|
||||
name: o.name.clone(),
|
||||
positions: o.positions.iter().flat_map(|p| [p.x, p.y, p.z]).collect(),
|
||||
triangles: o.triangles.iter().flatten().copied().collect(),
|
||||
color: o.color,
|
||||
corner_normals: o.corner_normals.iter().flatten().flat_map(|n| [n.x, n.y, n.z]).collect(),
|
||||
});
|
||||
}
|
||||
let name = match objects.as_slice() {
|
||||
@@ -875,7 +877,18 @@ fn mesh_object(b: &solidcraft_doc::ModelBody, tagged: bool, quality: MeshQuality
|
||||
(Vec::new(), 0)
|
||||
};
|
||||
let transparency = 1.0 - paint.map(|p| p.opacity).unwrap_or(1.0);
|
||||
Ok(MeshObject { name: b.name.clone(), positions, triangles, color: b.body.color(), transparency, looks, tri_looks, tri_faces, faces })
|
||||
Ok(MeshObject {
|
||||
name: b.name.clone(),
|
||||
positions,
|
||||
triangles,
|
||||
color: b.body.color(),
|
||||
transparency,
|
||||
looks,
|
||||
tri_looks,
|
||||
tri_faces,
|
||||
faces,
|
||||
corner_normals: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// STEP assembly of a design with components: a product per component holding its bodies in
|
||||
|
||||
@@ -229,6 +229,7 @@ fn designs_survive_mutation() {
|
||||
positions: vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0].into(),
|
||||
triangles: vec![0, 1, 2].into(),
|
||||
color: None,
|
||||
corner_normals: Default::default(),
|
||||
};
|
||||
let preview = crate::DesignPreview { meshes: vec![mesh] };
|
||||
let container = crate::write_design_container_with_preview(&doc, crate::DesignCompression::Fast, Some(&preview)).unwrap();
|
||||
|
||||
+118
-16
@@ -1,9 +1,29 @@
|
||||
//! Wavefront OBJ import: polygon meshes (`v`, `f` with any of `v`, `v/vt`, `v//vn`, `v/vt/vn`,
|
||||
//! negative indices counting back), one mesh body per object (`o`, else group `g`). Polygons
|
||||
//! are split into fans. Texture coordinates, normals and materials are ignored. The file is
|
||||
//! are split into fans. Authored corner normals and smoothing groups are retained. Texture
|
||||
//! coordinates and materials are not consumed. The file is
|
||||
//! untrusted: counts are capped and bad lines become warnings.
|
||||
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Corner {
|
||||
vertex: u32,
|
||||
normal: Option<u32>,
|
||||
}
|
||||
|
||||
struct Face {
|
||||
corners: [Corner; 3],
|
||||
smoothing: u32,
|
||||
}
|
||||
|
||||
fn index(token: &str, len: usize) -> Option<u32> {
|
||||
let value: i64 = token.parse().ok()?;
|
||||
let len = i64::try_from(len).ok()?;
|
||||
let value = if value < 0 { len.checked_add(value)? } else { value.checked_sub(1)? };
|
||||
(0..len).contains(&value).then(|| u32::try_from(value).ok()).flatten()
|
||||
}
|
||||
|
||||
use crate::threemf::MeshObject;
|
||||
use crate::{IoError, Result};
|
||||
@@ -17,11 +37,13 @@ pub fn read_obj(bytes: &[u8], default_name: &str) -> Result<(Vec<MeshObject>, Ve
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let mut verts: Vec<Vec3> = Vec::new();
|
||||
// Objects: name and polygons as vertex indices into `verts`.
|
||||
let mut objects: Vec<(String, Vec<[u32; 3]>)> = vec![(default_name.to_string(), Vec::new())];
|
||||
let mut objects: Vec<(String, Vec<Face>)> = vec![(default_name.to_string(), Vec::new())];
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
let mut bad_lines = 0usize;
|
||||
let mut triangles = 0usize;
|
||||
let mut named_by_o = false;
|
||||
let mut normals = Vec::<Vec3>::new();
|
||||
let mut smoothing = 0u32;
|
||||
for line in text.lines() {
|
||||
let line = line.split('#').next().unwrap_or("").trim();
|
||||
let mut it = line.split_whitespace();
|
||||
@@ -40,6 +62,27 @@ pub fn read_obj(bytes: &[u8], default_name: &str) -> Result<(Vec<MeshObject>, Ve
|
||||
return Err(IoError::Invalid(format!("OBJ with more than {MAX_VERTICES} vertices")));
|
||||
}
|
||||
}
|
||||
Some("vn") => {
|
||||
let c: Vec<f64> = it.take(3).filter_map(|x| x.parse::<f64>().ok()).collect();
|
||||
let normal = match c[..] {
|
||||
[x, y, z] => Vec3::new(x, y, z).normalized().filter(|n| n.is_finite()),
|
||||
_ => None,
|
||||
};
|
||||
normals.push(normal.unwrap_or(Vec3::new(f64::NAN, f64::NAN, f64::NAN)));
|
||||
if normal.is_none() {
|
||||
bad_lines += 1;
|
||||
}
|
||||
if normals.len() > MAX_VERTICES {
|
||||
return Err(IoError::Invalid(format!("OBJ with more than {MAX_VERTICES} normals")));
|
||||
}
|
||||
}
|
||||
Some("s") => {
|
||||
smoothing = match it.next() {
|
||||
Some("off" | "0") | None => 0,
|
||||
Some("on") => 1,
|
||||
Some(v) => v.parse::<u32>().unwrap_or(0),
|
||||
};
|
||||
}
|
||||
Some("o") | Some("g") => {
|
||||
let is_o = line.starts_with('o');
|
||||
let name = it.collect::<Vec<_>>().join(" ");
|
||||
@@ -54,21 +97,26 @@ pub fn read_obj(bytes: &[u8], default_name: &str) -> Result<(Vec<MeshObject>, Ve
|
||||
}
|
||||
}
|
||||
Some("f") => {
|
||||
let n = verts.len() as i64;
|
||||
let idx: Option<Vec<u32>> = it
|
||||
let corners: Option<Vec<Corner>> = it
|
||||
.map(|tok| {
|
||||
let i: i64 = tok.split('/').next()?.parse().ok()?;
|
||||
let i = if i < 0 { n + i } else { i - 1 };
|
||||
(0..n).contains(&i).then(|| u32::try_from(i).ok()).flatten()
|
||||
let mut fields = tok.split('/');
|
||||
let vertex = index(fields.next()?, verts.len())?;
|
||||
fields.next(); // UVs do not replace the saved procedural material mapping.
|
||||
let normal = fields.next().filter(|v| !v.is_empty()).and_then(|v| index(v, normals.len()));
|
||||
Some(Corner { vertex, normal })
|
||||
})
|
||||
.collect();
|
||||
match idx {
|
||||
Some(p) if p.len() >= 3 && p.iter().all(|i| verts.get(*i as usize).is_some_and(|v| v.is_finite())) => {
|
||||
match corners {
|
||||
Some(p) if p.len() >= 3 && p.iter().all(|i| verts.get(i.vertex as usize).is_some_and(|v| v.is_finite())) => {
|
||||
let added = p.len() - 2;
|
||||
triangles = triangles.checked_add(added).ok_or_else(|| IoError::Invalid("OBJ triangle count overflow".into()))?;
|
||||
if triangles > MAX_TRIANGLES {
|
||||
return Err(IoError::Invalid(format!("OBJ with more than {MAX_TRIANGLES} triangles")));
|
||||
}
|
||||
if let Some((_, tris)) = objects.last_mut() {
|
||||
for k in 1..p.len() - 1 {
|
||||
tris.push([p[0], p[k], p[k + 1]]);
|
||||
tris.push(Face { corners: [p[0], p[k], p[k + 1]], smoothing });
|
||||
}
|
||||
triangles += p.len() - 2;
|
||||
}
|
||||
}
|
||||
_ => bad_lines += 1,
|
||||
@@ -92,18 +140,32 @@ pub fn read_obj(bytes: &[u8], default_name: &str) -> Result<(Vec<MeshObject>, Ve
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let mut positions = Vec::new();
|
||||
let mut triangles = Vec::with_capacity(tris.len());
|
||||
for t in tris {
|
||||
for t in &tris {
|
||||
let mut nt = [0u32; 3];
|
||||
for (k, i) in t.iter().enumerate() {
|
||||
for (k, i) in t.corners.iter().enumerate() {
|
||||
let next = u32::try_from(positions.len()).map_err(|_| IoError::Invalid("OBJ too large".into()))?;
|
||||
nt[k] = *map.entry(*i).or_insert_with(|| {
|
||||
positions.push(verts.get(*i as usize).copied().unwrap_or(Vec3::ZERO));
|
||||
nt[k] = *map.entry(i.vertex).or_insert_with(|| {
|
||||
positions.push(verts.get(i.vertex as usize).copied().unwrap_or(Vec3::ZERO));
|
||||
next
|
||||
});
|
||||
}
|
||||
triangles.push(nt);
|
||||
}
|
||||
out.push(MeshObject { name, positions, triangles, ..Default::default() });
|
||||
let mesh = solidcraft_geom::Mesh { positions, triangles, ..Default::default() };
|
||||
let annotated = tris.iter().any(|f| f.smoothing != 0 || f.corners.iter().any(|c| c.normal.is_some()));
|
||||
let mut corner_normals = Vec::new();
|
||||
if annotated {
|
||||
let groups: Vec<u32> = tris.iter().map(|f| f.smoothing).collect();
|
||||
corner_normals = mesh.crease_corner_normals(std::f64::consts::PI, Some(&groups)).map_err(IoError::Invalid)?;
|
||||
for (f, n) in tris.iter().zip(&mut corner_normals) {
|
||||
for (corner, shading) in f.corners.iter().zip(n) {
|
||||
if let Some(normal) = corner.normal.and_then(|k| normals.get(k as usize)).copied().filter(|n| n.is_finite()) {
|
||||
*shading = normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(MeshObject { name, positions: mesh.positions, triangles: mesh.triangles, corner_normals, ..Default::default() });
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(IoError::Invalid("OBJ has no faces".into()));
|
||||
@@ -115,6 +177,46 @@ pub fn read_obj(bytes: &[u8], default_name: &str) -> Result<(Vec<MeshObject>, Ve
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn authored_corner_normals_and_smoothing_groups_survive_import() {
|
||||
let vertices = "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\n";
|
||||
let authored = format!("{vertices}vn 0 0 1\nvn 0 0.5 0.8660254037844386\nf 1//-2 2//-2 3//-2\nf 2//-1 1//-1 4//-1\n");
|
||||
let (objects, warnings) = read_obj(authored.as_bytes(), "corners").unwrap();
|
||||
assert!(warnings.is_empty());
|
||||
assert_eq!(objects[0].positions.len(), 4);
|
||||
assert_eq!(objects[0].corner_normals.len(), 2);
|
||||
assert_eq!(objects[0].corner_normals[0], [Vec3::Z; 3]);
|
||||
assert!((objects[0].corner_normals[1][0] - Vec3::new(0., 0.5, 0.8660254037844386)).len() < 1e-12);
|
||||
let grouped = format!("{vertices}s 5\nf 1 2 3\nf 2 1 4\n");
|
||||
let (objects, _) = read_obj(grouped.as_bytes(), "smooth").unwrap();
|
||||
assert_eq!(objects[0].corner_normals[0][0], objects[0].corner_normals[1][1]);
|
||||
assert!(objects[0].corner_normals[0][0].y > 0.2);
|
||||
let separated = format!("{vertices}s 5\nf 1 2 3\ns off\nf 2 1 4\n");
|
||||
let (objects, _) = read_obj(separated.as_bytes(), "crease").unwrap();
|
||||
assert_eq!(objects[0].corner_normals[0][0], Vec3::Z);
|
||||
let old = format!("{vertices}f 1 2 3\nf 2 1 4\n");
|
||||
assert!(read_obj(old.as_bytes(), "flat").unwrap().0[0].corner_normals.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoothing_groups_ignore_repeated_index_faces_without_losing_normals() {
|
||||
let src = "v 0 0 0\nv 1 0 0\nv 0 1 0\nv 0 -0.8660254037844386 0.5\ns 1\nf 1 2 3\nf 1 1 2\nf 2 1 4\n";
|
||||
let (objects, warnings) = read_obj(src.as_bytes(), "fold").unwrap();
|
||||
assert!(warnings.is_empty());
|
||||
assert_eq!(objects.len(), 1);
|
||||
let object = &objects[0];
|
||||
assert_eq!(object.triangles.len(), 3);
|
||||
assert_eq!(object.corner_normals.len(), 3);
|
||||
let theta = 15f64.to_radians();
|
||||
let shared = Vec3::new(0., theta.sin(), theta.cos());
|
||||
assert!((object.corner_normals[0][0] - shared).len() < 1e-12);
|
||||
assert_eq!(object.corner_normals[0][0], object.corner_normals[2][1]);
|
||||
assert!(object.corner_normals[1].iter().all(|n| n.is_finite() && (n.len() - 1.).abs() < 1e-12));
|
||||
let body = solidcraft_kernel::mesh_body_with_normals(&object.positions, &object.triangles, &object.corner_normals).unwrap();
|
||||
assert_eq!(body.triangle_mesh().unwrap().1, &[[0, 1, 2], [1, 0, 3]]);
|
||||
assert_eq!(body.triangle_corner_normals().unwrap(), &[object.corner_normals[0], object.corner_normals[2]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn objects_quads_and_negative_indices() {
|
||||
let src = "# cube\no Cube\nv 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nv 0 0 1\nv 1 0 1\nv 1 1 1\nv 0 1 1\n\
|
||||
|
||||
@@ -48,6 +48,8 @@ pub struct MeshObject {
|
||||
pub name: String,
|
||||
pub positions: Vec<Vec3>,
|
||||
pub triangles: Vec<[u32; 3]>,
|
||||
/// Optional shading normals at triangle corners; independent of welded geometry.
|
||||
pub corner_normals: Vec<[Vec3; 3]>,
|
||||
pub color: Option<[f32; 3]>,
|
||||
/// 0 = opaque, 1 = invisible (written as the colour's alpha).
|
||||
pub transparency: f32,
|
||||
|
||||
@@ -1096,7 +1096,12 @@ impl Body {
|
||||
/// makes a kept timeline snapshot expensive, so it dominates the estimate.
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
if let Some(m) = &self.mesh {
|
||||
return m.positions.len() * 24 + m.triangles.len() * 12;
|
||||
return m
|
||||
.positions
|
||||
.len()
|
||||
.saturating_mul(24)
|
||||
.saturating_add(m.triangles.len().saturating_mul(12))
|
||||
.saturating_add(m.corner_normals.len().saturating_mul(72));
|
||||
}
|
||||
let uses: usize = self.record.faces.iter().flat_map(|f| &f.loops).map(|l| if let Loop::Edges { edges } = l { edges.len() } else { 1 }).sum();
|
||||
self.record.faces.len() * 2048 + uses * (512 + 96)
|
||||
|
||||
@@ -199,7 +199,7 @@ pub use loft_blend::loft_blend;
|
||||
pub use matchsurf::{match_surface, seam_continuity};
|
||||
pub use measure::{BodyMeasure, MassMeasure, measure, measure_mass};
|
||||
pub use mesh_convert::convert_prismatic_mesh;
|
||||
pub use meshbody::{MAX_TRIANGLES, mesh_body};
|
||||
pub use meshbody::{MAX_TRIANGLES, mesh_body, mesh_body_with_normals};
|
||||
pub use native::{body_from_smlib, smlib_body};
|
||||
pub use native::{brep_record, decode_native_body, encode_native_body, native_preview_mesh};
|
||||
pub use offset::with_same_surface;
|
||||
|
||||
@@ -501,7 +501,7 @@ mod tests {
|
||||
let sphere = crate::sphere(Vec3::ZERO, 10.).unwrap();
|
||||
assert!(convert_prismatic_mesh(&mesh_of(&sphere), 0.05).is_err());
|
||||
for coordinate in [f64::NAN, f64::INFINITY, f64::MAX, 1e30] {
|
||||
let mesh = crate::meshbody::TriMesh { positions: vec![Vec3::new(coordinate, 0., 0.)], triangles: vec![] };
|
||||
let mesh = crate::meshbody::TriMesh { positions: vec![Vec3::new(coordinate, 0., 0.)], triangles: vec![], corner_normals: Vec::new() };
|
||||
assert!(weld(&mesh, 0.05).is_err());
|
||||
}
|
||||
}
|
||||
@@ -541,7 +541,7 @@ mod tests {
|
||||
let err = convert_prismatic_mesh(&body, 0.05).unwrap_err().to_string();
|
||||
assert!(err.contains("50001 triangles") && err.contains("50000"), "{err}");
|
||||
assert!(err.contains("constant-section") && err.contains("freeform"), "{err}");
|
||||
let mesh = crate::meshbody::TriMesh { positions: vec![Vec3::ZERO; MAX_TRIANGLES * 3 + 1], triangles: vec![] };
|
||||
let mesh = crate::meshbody::TriMesh { positions: vec![Vec3::ZERO; MAX_TRIANGLES * 3 + 1], triangles: vec![], corner_normals: Vec::new() };
|
||||
let err = weld(&mesh, 0.05).unwrap_err().to_string();
|
||||
assert!(err.contains("150001 vertices") && err.contains("150000"), "{err}");
|
||||
}
|
||||
|
||||
@@ -18,14 +18,15 @@ pub const MAX_TRIANGLES: usize = 20_000_000;
|
||||
pub struct TriMesh {
|
||||
pub positions: Vec<Vec3>,
|
||||
pub triangles: Vec<[u32; 3]>,
|
||||
pub corner_normals: Vec<[Vec3; 3]>,
|
||||
}
|
||||
|
||||
impl TriMesh {
|
||||
/// Display/measure mesh: corners split per triangle so normals stay flat.
|
||||
/// Display/measure mesh: corners split so authored seams stay independent.
|
||||
pub(crate) fn to_mesh(&self) -> Mesh {
|
||||
let mut out = Mesh::default();
|
||||
out.positions.reserve(self.triangles.len() * 3);
|
||||
for t in &self.triangles {
|
||||
for (index, t) in self.triangles.iter().enumerate() {
|
||||
let (Some(a), Some(b), Some(c)) =
|
||||
(self.positions.get(t[0] as usize), self.positions.get(t[1] as usize), self.positions.get(t[2] as usize))
|
||||
else {
|
||||
@@ -34,7 +35,14 @@ impl TriMesh {
|
||||
let n = (*b - *a).cross(*c - *a).normalized().unwrap_or(Vec3::Z);
|
||||
let base = u32::try_from(out.positions.len()).unwrap_or(u32::MAX);
|
||||
out.positions.extend([*a, *b, *c]);
|
||||
out.normals.extend([n, n, n]);
|
||||
out.normals.extend(self.corner_normals.get(index).copied().unwrap_or([n; 3]).map(|v| {
|
||||
let v = v.normalized().unwrap_or(n);
|
||||
match v.dot(n).partial_cmp(&0.0) {
|
||||
Some(std::cmp::Ordering::Greater) => v,
|
||||
Some(std::cmp::Ordering::Less) => -v,
|
||||
_ => n,
|
||||
}
|
||||
}));
|
||||
out.triangles.push([base, base.saturating_add(1), base.saturating_add(2)]);
|
||||
out.tri_face.push(0);
|
||||
}
|
||||
@@ -65,10 +73,19 @@ impl TriMesh {
|
||||
/// A mesh body from triangles (`positions` in mm). Coincident vertices are merged, degenerate
|
||||
/// triangles dropped, and a closed mesh with inward winding is turned outward.
|
||||
pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
mesh_body_with_normals(positions, triangles, &[])
|
||||
}
|
||||
|
||||
/// A mesh with optional independent, finite nonzero shading normals per triangle corner.
|
||||
/// Welding and winding repair affect geometric indices; seams never affect connectivity.
|
||||
pub fn mesh_body_with_normals(positions: &[Vec3], triangles: &[[u32; 3]], normals: &[[Vec3; 3]]) -> Result<Body> {
|
||||
let _span = solidcraft_profile::span("kernel", "mesh_body");
|
||||
if triangles.len() > MAX_TRIANGLES {
|
||||
return Err(KernelError::Invalid(format!("mesh with {} triangles (limit {MAX_TRIANGLES})", triangles.len())));
|
||||
}
|
||||
if !normals.is_empty() && (normals.len() != triangles.len() || normals.iter().flatten().any(|n| !n.is_finite() || n.normalized().is_none())) {
|
||||
return Err(KernelError::Invalid("mesh shading normals must contain three finite nonzero vectors per triangle".into()));
|
||||
}
|
||||
if positions.iter().any(|p| !(p.is_finite() && p.x.abs() < 1e9 && p.y.abs() < 1e9 && p.z.abs() < 1e9)) {
|
||||
return Err(KernelError::Invalid("mesh with non-finite or huge coordinates".into()));
|
||||
}
|
||||
@@ -85,7 +102,7 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
});
|
||||
remap.push(i);
|
||||
}
|
||||
for t in triangles {
|
||||
for (index, t) in triangles.iter().enumerate() {
|
||||
let (Some(&a), Some(&b), Some(&c)) = (remap.get(t[0] as usize), remap.get(t[1] as usize), remap.get(t[2] as usize)) else {
|
||||
return Err(KernelError::Invalid("mesh triangle refers to a missing vertex".into()));
|
||||
};
|
||||
@@ -93,14 +110,20 @@ pub fn mesh_body(positions: &[Vec3], triangles: &[[u32; 3]]) -> Result<Body> {
|
||||
continue;
|
||||
}
|
||||
out.triangles.push([a, b, c]);
|
||||
if let Some(n) = normals.get(index) {
|
||||
out.corner_normals.push(*n);
|
||||
}
|
||||
}
|
||||
if out.triangles.is_empty() {
|
||||
return Err(KernelError::Invalid("mesh has no triangles".into()));
|
||||
}
|
||||
unify_winding(&mut out);
|
||||
if out.volume6() < 0.0 && is_closed(&out) {
|
||||
for t in &mut out.triangles {
|
||||
for (i, t) in out.triangles.iter_mut().enumerate() {
|
||||
t.swap(1, 2);
|
||||
if let Some(n) = out.corner_normals.get_mut(i) {
|
||||
n.swap(1, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Body::of_mesh(out))
|
||||
@@ -140,6 +163,9 @@ fn unify_winding(m: &mut TriMesh) {
|
||||
&& runs(u, a, b)
|
||||
{
|
||||
u.swap(1, 2);
|
||||
if let Some(n) = m.corner_normals.get_mut(j) {
|
||||
n.swap(1, 2);
|
||||
}
|
||||
}
|
||||
stack.push(j);
|
||||
}
|
||||
@@ -164,6 +190,10 @@ impl Body {
|
||||
pub fn triangle_mesh(&self) -> Option<(&[Vec3], &[[u32; 3]])> {
|
||||
self.mesh.as_ref().map(|m| (m.positions.as_slice(), m.triangles.as_slice()))
|
||||
}
|
||||
/// Shading metadata independent of the mesh body's certified geometric triangles.
|
||||
pub fn triangle_corner_normals(&self) -> Option<&[[Vec3; 3]]> {
|
||||
self.mesh.as_ref().map(|m| m.corner_normals.as_slice())
|
||||
}
|
||||
/// Does a mesh body enclose a volume (every side shared by two triangles)?
|
||||
pub fn is_closed_mesh(&self) -> bool {
|
||||
self.mesh.as_ref().is_some_and(|m| is_closed(m))
|
||||
@@ -181,10 +211,30 @@ impl Body {
|
||||
};
|
||||
let det = m[0][0] * (m[1][1] * m[2][2] - m[2][1] * m[1][2]) - m[1][0] * (m[0][1] * m[2][2] - m[2][1] * m[0][2])
|
||||
+ m[2][0] * (m[0][1] * m[1][2] - m[1][1] * m[0][2]);
|
||||
let mut out = TriMesh { positions: tm.positions.iter().map(|p| f(*p)).collect(), triangles: tm.triangles.clone() };
|
||||
// Cofactors have the inverse-transpose direction after the determinant's
|
||||
// sign is removed. Avoid division by a tiny/zero determinant; display
|
||||
// conversion normalizes and falls back to geometry for a collapsed vector.
|
||||
let a = Vec3::new(m[0][0], m[0][1], m[0][2]);
|
||||
let b = Vec3::new(m[1][0], m[1][1], m[1][2]);
|
||||
let c = Vec3::new(m[2][0], m[2][1], m[2][2]);
|
||||
let normal = |n: Vec3| {
|
||||
let n = (b.cross(c) * n.x + c.cross(a) * n.y + a.cross(b) * n.z) * if det < 0.0 { -1.0 } else { 1.0 };
|
||||
let scale = n.x.abs().max(n.y.abs()).max(n.z.abs());
|
||||
// Normal directions are independent of the scale's magnitude. Rescale
|
||||
// before normalization so a small valid mesh keeps its authored normals.
|
||||
if scale > 0.0 && scale.is_finite() { (n / scale).normalized().unwrap_or(n) } else { n }
|
||||
};
|
||||
let mut out = TriMesh {
|
||||
positions: tm.positions.iter().map(|p| f(*p)).collect(),
|
||||
triangles: tm.triangles.clone(),
|
||||
corner_normals: tm.corner_normals.iter().map(|n| n.map(normal)).collect(),
|
||||
};
|
||||
if det < 0.0 {
|
||||
for t in &mut out.triangles {
|
||||
for (i, t) in out.triangles.iter_mut().enumerate() {
|
||||
t.swap(1, 2);
|
||||
if let Some(n) = out.corner_normals.get_mut(i) {
|
||||
n.swap(1, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut placed = Body::of_mesh(out);
|
||||
|
||||
@@ -1131,6 +1131,29 @@ fn chamfer_wider_than_sloped_basin_rim_builds() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_corner_normals_survive_welding_reflection_and_nonuniform_scale() {
|
||||
let positions = [Vec3::ZERO, Vec3::X, Vec3::Y];
|
||||
let normals = [[Vec3::new(1., 0., 1.), Vec3::new(0., 1., 1.), Vec3::Z]];
|
||||
let body = crate::mesh_body_with_normals(&positions, &[[0, 1, 2]], &normals).unwrap();
|
||||
let matrix = [[-2., 0., 0., 0.], [0., 3., 0., 0.], [0., 0., 0.5, 0.], [7., 11., 13., 1.]];
|
||||
let moved = crate::transform_matrix(&body, matrix).unwrap();
|
||||
let mesh = moved.mesh(&crate::MeshTolerance::chord(0.01)).unwrap();
|
||||
let expected = [Vec3::new(-0.5, 0., 2.).normalized().unwrap(), Vec3::Z, Vec3::new(0., 1. / 3., 2.).normalized().unwrap()];
|
||||
for (actual, expected) in mesh.normals.iter().zip(expected) {
|
||||
assert!((*actual - expected).len() < 1e-12);
|
||||
}
|
||||
let tiny = crate::transform_matrix(&body, [[1e-6, 0., 0., 0.], [0., 1e-6, 0., 0.], [0., 0., 1e-6, 0.], [0., 0., 0., 1.]]).unwrap();
|
||||
let tiny = tiny.mesh(&crate::MeshTolerance::chord(0.01)).unwrap();
|
||||
assert!((tiny.normals[0] - Vec3::new(1., 0., 1.).normalized().unwrap()).len() < 1e-12);
|
||||
assert!((tiny.normals[0].len() - 1.).abs() < 1e-12, "normal magnitude must not depend on a small geometric scale");
|
||||
let [a, b, c] = mesh.triangles[0].map(|i| mesh.positions[i as usize]);
|
||||
assert!((b - a).cross(c - a).z > 0.);
|
||||
assert_eq!(body.triangle_mesh().unwrap().1, &[[0, 1, 2]]);
|
||||
assert!(crate::mesh_body_with_normals(&positions, &[[0, 1, 2]], &[[Vec3::ZERO; 3]]).is_err());
|
||||
assert!(crate::mesh_body_with_normals(&positions, &[[0, 1, 2]], &[[Vec3::Z; 3]; 2]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_bodies_move_measure_and_refuse_solid_ops() {
|
||||
// Unit cube as 12 triangles with repeated corners (welded on construction).
|
||||
|
||||
@@ -10,7 +10,7 @@ workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
raytrace-cpu = ["dep:robust"]
|
||||
raytrace-cpu = ["dep:rayon"]
|
||||
|
||||
[dependencies]
|
||||
solidcore-record = { workspace = true }
|
||||
@@ -20,11 +20,15 @@ serde_json = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg"] }
|
||||
moxcms = { workspace = true }
|
||||
png = { workspace = true }
|
||||
robust = { workspace = true, optional = true }
|
||||
robust = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
weezl = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rayon = { workspace = true, optional = true }
|
||||
sysinfo = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
flate2 = "1"
|
||||
|
||||
|
||||
@@ -71,16 +71,19 @@ pub fn surface_pdf(surface: OpaqueSurface, normal: Vec3, view: Vec3, light: Vec3
|
||||
let v = unit_direction(view)?;
|
||||
let l = unit_direction(light)?;
|
||||
let nl = n.dot(l);
|
||||
if nl <= 0.0 || n.dot(v) <= 0.0 {
|
||||
let nv = n.dot(v).min(1.0);
|
||||
if nl <= 0.0 || nv <= 0.0 {
|
||||
return Ok(0.0);
|
||||
}
|
||||
let h = unit_direction(v + l)?;
|
||||
let nh = n.dot(h).clamp(0.0, 1.0);
|
||||
let vh = v.dot(h).max(1e-20);
|
||||
// Visible normal density times the reflection Jacobian. The view-half
|
||||
// cosine cancels, and G1 / nv has a finite grazing limit.
|
||||
let ggx = |roughness: f64| {
|
||||
let a2 = roughness.powi(4);
|
||||
let d = (1.0 - nh * nh) + nh * nh * a2;
|
||||
a2 / (PI * d * d) * nh / (4.0 * vh)
|
||||
let root = (nv * nv + a2 * (1.0 - nv * nv)).sqrt();
|
||||
a2 / (PI * d * d) / (2.0 * (nv + root))
|
||||
};
|
||||
let base = 0.5 * nl / PI + 0.5 * ggx(surface.roughness());
|
||||
Ok(surface.clear_coat().map_or(base, |coat| {
|
||||
@@ -179,7 +182,7 @@ mod tests {
|
||||
let p = weight * 0.5;
|
||||
let mut energy = 0.0;
|
||||
// Integrate each proposal with its mixture probability.
|
||||
// NDF importance quadrature resolves sharp lobes, unlike
|
||||
// Visible-normal quadrature resolves sharp lobes, unlike
|
||||
// a uniform hemisphere grid at roughness 0.045.
|
||||
for (component, probability) in [(0, (1.0 - p) * 0.5), (1, (1.0 - p) * 0.5), (2, p)] {
|
||||
for i in 0..COUNT {
|
||||
@@ -189,9 +192,7 @@ mod tests {
|
||||
Vec3::new(x.sqrt() * phi.cos(), x.sqrt() * phi.sin(), (1.0 - x).sqrt())
|
||||
} else {
|
||||
let r = if component == 1 { base_roughness } else { coat_roughness };
|
||||
let c = ((1.0 - x) / (1.0 + (r.powi(4) - 1.0) * x)).sqrt();
|
||||
let radius = (1.0 - c * c).sqrt();
|
||||
let h = Vec3::new(radius * phi.cos(), radius * phi.sin(), c);
|
||||
let h = crate::glass_model::visible_normal(v, Vec3::Z, r * r, [x, phi / (2.0 * PI)]).unwrap();
|
||||
h * (2.0 * v.dot(h)) - v
|
||||
};
|
||||
if l.z <= 0.0 {
|
||||
|
||||
@@ -17,20 +17,20 @@ struct Moments {
|
||||
/// reconstruct integer coverage from FLOAT32 alpha or allocate another image.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PixelCounts {
|
||||
covered: u16,
|
||||
samples: u16,
|
||||
planned: u16,
|
||||
covered: u32,
|
||||
samples: u32,
|
||||
planned: u32,
|
||||
}
|
||||
impl PixelCounts {
|
||||
pub fn covered(self) -> u16 {
|
||||
pub fn covered(self) -> u32 {
|
||||
self.covered
|
||||
}
|
||||
pub fn samples(self) -> u16 {
|
||||
pub fn samples(self) -> u32 {
|
||||
self.samples
|
||||
}
|
||||
pub fn alpha8(self) -> Result<u8> {
|
||||
let samples = u32::from(self.samples);
|
||||
let covered = u32::from(self.covered);
|
||||
let samples = self.samples;
|
||||
let covered = self.covered;
|
||||
if samples == 0 || covered > samples {
|
||||
return Err(invalid("adaptive coverage has no valid sample denominator"));
|
||||
}
|
||||
@@ -218,14 +218,26 @@ impl AdaptiveCameraFilm {
|
||||
/// None means this production pixel has already reached its pilot allocation;
|
||||
/// the caller must perform skip_one WITHOUT tracing or consuming a variate.
|
||||
pub fn sampling_identity(&self, index: usize) -> Result<Option<SamplingIdentity>> {
|
||||
let counts = self.ordered(index)?;
|
||||
self.identity_for_counts(self.ordered(index)?)
|
||||
}
|
||||
/// Native camera workers may inspect any uncommitted pixel in the current
|
||||
/// sweep while the owner holds the film immutable. Publication still uses
|
||||
/// sampling_identity/add/skip_one and must follow the exact owner cursor.
|
||||
#[cfg(any(feature = "raytrace-cpu", test))]
|
||||
pub(crate) fn sampling_identity_in_pass(&self, index: usize) -> Result<Option<SamplingIdentity>> {
|
||||
if !self.ready() || self.stage == Stage::Reset || self.stage == Stage::Done || index < self.next_pixel || index >= self.count {
|
||||
return Err(invalid("adaptive sampling needs an uncommitted pixel in the active sweep"));
|
||||
}
|
||||
self.identity_for_counts(self.counts.pixel(index)?)
|
||||
}
|
||||
fn identity_for_counts(&self, counts: PixelCounts) -> Result<Option<SamplingIdentity>> {
|
||||
if self.stage == Stage::Pilot {
|
||||
if self.completed >= self.options.pilot_samples {
|
||||
return Err(invalid("complete pilots require an explicit production reset"));
|
||||
}
|
||||
return Ok(Some(SamplingIdentity { seed: self.seed ^ 0xa511_e9b3, ordinal: self.completed, stage: PreviewStage::Pilot }));
|
||||
}
|
||||
if u32::from(counts.samples) >= u32::from(counts.planned) {
|
||||
if counts.samples >= counts.planned {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(SamplingIdentity { seed: self.seed, ordinal: self.completed, stage: PreviewStage::Production }))
|
||||
@@ -262,11 +274,11 @@ impl AdaptiveCameraFilm {
|
||||
let mut moments = self.moments.get(index).copied().ok_or_else(|| invalid("adaptive moments disappeared"))?;
|
||||
let mut counts = self.counts.pixel(index)?;
|
||||
let samples = counts.samples.checked_add(1).ok_or_else(|| limit("adaptive sample count overflow"))?;
|
||||
if u32::from(samples) != self.completed + 1 {
|
||||
if samples != self.completed + 1 {
|
||||
return Err(invalid("adaptive pixel count disagrees with its active ordinal"));
|
||||
}
|
||||
let hits =
|
||||
counts.covered.checked_add(u16::from(covered)).filter(|hits| *hits <= samples).ok_or_else(|| limit("adaptive coverage overflow"))?;
|
||||
counts.covered.checked_add(u32::from(covered)).filter(|hits| *hits <= samples).ok_or_else(|| limit("adaptive coverage overflow"))?;
|
||||
if self.stage == Stage::Pilot {
|
||||
let value = [rgb[0], rgb[1], rgb[2], f64::from(u8::from(covered))];
|
||||
for ((mean, m2), value) in moments.value.iter_mut().zip(moments.m2.iter_mut()).zip(value) {
|
||||
@@ -274,9 +286,8 @@ impl AdaptiveCameraFilm {
|
||||
*mean += delta / f64::from(samples);
|
||||
*m2 += delta * (value - *mean);
|
||||
}
|
||||
if u32::from(samples) == self.options.pilot_samples {
|
||||
counts.planned = u16::try_from(self.options.target(moments.value, moments.m2, self.maximum)?)
|
||||
.map_err(|_| limit("adaptive target exceeds exact count representation"))?;
|
||||
if samples == self.options.pilot_samples {
|
||||
counts.planned = self.options.target(moments.value, moments.m2, self.maximum)?;
|
||||
}
|
||||
} else {
|
||||
for (sum, value) in moments.value.iter_mut().take(3).zip(rgb) {
|
||||
@@ -291,8 +302,8 @@ impl AdaptiveCameraFilm {
|
||||
// Accumulate bounded report metadata with the existing pixel transaction;
|
||||
// final publication never needs a synchronous image-sized report scan.
|
||||
let mut report = self.report.clone();
|
||||
if self.stage == Stage::Pilot && u32::from(samples) == self.options.pilot_samples {
|
||||
let target = u32::from(counts.planned);
|
||||
if self.stage == Stage::Pilot && samples == self.options.pilot_samples {
|
||||
let target = counts.planned;
|
||||
report.minimum_production_samples = report.minimum_production_samples.min(target);
|
||||
report.maximum_production_samples = report.maximum_production_samples.max(target);
|
||||
report.production_samples =
|
||||
@@ -361,7 +372,7 @@ impl AdaptiveCameraFilm {
|
||||
let index = self.next_pixel;
|
||||
let moments = self.moments.get_mut(index).ok_or_else(|| invalid("adaptive reset index exceeds admission"))?;
|
||||
let counts = self.counts.values.get_mut(index).ok_or_else(|| invalid("adaptive reset count disappeared"))?;
|
||||
if !(self.options.min_samples..=self.maximum).contains(&u32::from(counts.planned)) {
|
||||
if !(self.options.min_samples..=self.maximum).contains(&counts.planned) {
|
||||
return Err(invalid("adaptive reset has no admitted production allocation"));
|
||||
}
|
||||
charge()?;
|
||||
@@ -380,7 +391,7 @@ impl AdaptiveCameraFilm {
|
||||
if self.stage == Stage::Pilot && (self.next_pixel != 0 || self.completed != self.options.pilot_samples) {
|
||||
return Err(invalid("adaptive allocations require the complete pilot pass"));
|
||||
}
|
||||
Ok(u32::from(self.counts.pixel(index)?.planned))
|
||||
Ok(self.counts.pixel(index)?.planned)
|
||||
}
|
||||
pub fn report(&self) -> Result<SamplingReport> {
|
||||
if !self.is_final() {
|
||||
|
||||
@@ -112,13 +112,13 @@ impl PrimaryFilm {
|
||||
|
||||
/// A display consumer can only read a fully completed, immutable pass. The
|
||||
/// returned radiance is premultiplied by coverage; background is kept separate.
|
||||
pub(crate) fn pixel(&self, index: usize) -> Result<([f64; 3], u16, u16)> {
|
||||
pub(crate) fn pixel(&self, index: usize) -> Result<([f64; 3], u32, u32)> {
|
||||
if !self.ready() || self.next_pixel != 0 || self.samples == 0 {
|
||||
return Err(invalid("primary film has no uniform complete pass"));
|
||||
}
|
||||
let pixel = self.pixels.get(index).ok_or_else(|| invalid("primary film resolve index out of range"))?;
|
||||
let covered = u16::try_from(pixel.covered).map_err(|_| limit("primary coverage exceeds resolve representation"))?;
|
||||
let samples = u16::try_from(self.samples).map_err(|_| limit("primary sample count exceeds resolve representation"))?;
|
||||
let covered = pixel.covered;
|
||||
let samples = self.samples;
|
||||
Ok((pixel.sum.map(|sum| sum / f64::from(self.samples)), covered, samples))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,24 @@ mod camera_passes {
|
||||
assert!(film.add(0, 4, [1.0; 3], true, &cancel, &mut || Ok(())).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_film_resolves_65536_exact_samples_and_half_coverage() {
|
||||
let samples = crate::job::MAX_SAMPLES_PER_PIXEL;
|
||||
let options = CpuProgressiveOptions { samples_per_pixel: samples, seed: 7 };
|
||||
let budget = crate::job::CpuBudget::Workstation;
|
||||
let bytes = PrimaryFilm::required_bytes(1, 1, options, budget).unwrap();
|
||||
let cancel = CancelToken::default();
|
||||
let mut film = PrimaryFilm::new(1, 1, options, bytes, &cancel, budget).unwrap();
|
||||
film.initialize_one(&cancel, &mut || Ok(())).unwrap();
|
||||
for ordinal in 0..samples {
|
||||
let covered = ordinal % 2 == 0;
|
||||
film.add(0, ordinal, if covered { [2.0, 4.0, 8.0] } else { [0.0; 3] }, covered, &cancel, &mut || Ok(())).unwrap();
|
||||
}
|
||||
assert_eq!(film.pixel(0).unwrap(), ([1.0, 2.0, 4.0], samples / 2, samples));
|
||||
assert!(film.add(0, samples, [0.0; 3], false, &cancel, &mut || Ok(())).is_err());
|
||||
assert_eq!(film.samples(), samples);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_film_cancellation_invalid_radiance_and_work_failure_are_atomic() {
|
||||
let mut film = primary();
|
||||
@@ -334,7 +352,7 @@ mod cpu_adaptive_foundation {
|
||||
}
|
||||
}
|
||||
}
|
||||
type Snapshot = (PreviewStage, u32, Vec<([f64; 3], f64, u16, u16)>);
|
||||
type Snapshot = (PreviewStage, u32, Vec<([f64; 3], f64, u32, u32)>);
|
||||
fn snapshot(film: &AdaptiveCameraFilm) -> Option<Snapshot> {
|
||||
let pass = film.complete_pass().ok()?;
|
||||
let metadata = pass.metadata();
|
||||
@@ -452,6 +470,169 @@ mod cpu_adaptive_foundation {
|
||||
assert!(film.sampling_identity(0).unwrap().is_none(), "a stopped pixel requests bookkeeping rather than another sample");
|
||||
assert!(film.complete_pass().unwrap().pixel(2).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn cpu_adaptive_parallel_identity_lookahead_keeps_owner_publication_ordered() {
|
||||
let mut candidate = film(48);
|
||||
let mut reference = film(48);
|
||||
let mut work = 0;
|
||||
let mut reference_work = 0;
|
||||
let cancel = CancelToken::default();
|
||||
for _ in 0..2 {
|
||||
assert!(!candidate.ready());
|
||||
for index in [0, 1, 2, usize::MAX] {
|
||||
assert!(candidate.sampling_identity_in_pass(index).is_err(), "uninitialized pixel {index}");
|
||||
}
|
||||
step(&mut candidate, &mut work);
|
||||
step(&mut reference, &mut reference_work);
|
||||
}
|
||||
assert!(candidate.ready());
|
||||
let pilot = candidate.sampling_identity(0).unwrap().unwrap();
|
||||
assert_eq!(candidate.sampling_identity_in_pass(0).unwrap(), Some(pilot));
|
||||
assert_eq!(candidate.sampling_identity_in_pass(1).unwrap(), Some(pilot));
|
||||
for index in [2, usize::MAX] {
|
||||
assert!(candidate.sampling_identity_in_pass(index).is_err());
|
||||
}
|
||||
assert!(candidate.sampling_identity(1).is_err(), "owner lookup still requires the next pixel");
|
||||
let state = |film: &AdaptiveCameraFilm| (film.initialized(), film.ready(), film.next_pixel(), film.completed_passes(), snapshot(film));
|
||||
let before = state(&candidate);
|
||||
let (rgb, covered) = source(1, pilot);
|
||||
let mut debits = 0;
|
||||
assert_eq!(
|
||||
candidate
|
||||
.add(1, pilot, rgb, covered, &cancel, &mut || {
|
||||
debits += 1;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ErrorCode::InvalidRequest
|
||||
);
|
||||
assert_eq!(
|
||||
candidate
|
||||
.skip_one(1, &cancel, &mut || {
|
||||
debits += 1;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ErrorCode::InvalidRequest
|
||||
);
|
||||
assert_eq!(debits, 0, "lookahead never authorizes an out-of-order owner transaction");
|
||||
assert_eq!(state(&candidate), before);
|
||||
step(&mut candidate, &mut work);
|
||||
step(&mut reference, &mut reference_work);
|
||||
assert_eq!(candidate.next_pixel(), 1);
|
||||
assert!(candidate.sampling_identity_in_pass(0).is_err(), "a committed pixel cannot be sampled again in this sweep");
|
||||
assert_eq!(candidate.sampling_identity_in_pass(1).unwrap(), Some(pilot));
|
||||
assert_eq!(candidate.sampling_identity(1).unwrap(), Some(pilot));
|
||||
let before = state(&candidate);
|
||||
let (rgb, covered) = source(0, pilot);
|
||||
assert_eq!(
|
||||
candidate
|
||||
.add(0, pilot, rgb, covered, &cancel, &mut || {
|
||||
debits += 1;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ErrorCode::InvalidRequest
|
||||
);
|
||||
assert_eq!(debits, 0);
|
||||
assert_eq!(state(&candidate), before);
|
||||
step(&mut candidate, &mut work);
|
||||
step(&mut reference, &mut reference_work);
|
||||
assert_eq!(snapshot(&candidate), snapshot(&reference), "failed owner writes cannot alter the completed film");
|
||||
assert_eq!(work, reference_work);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_adaptive_parallel_identity_lookahead_respects_stage_boundaries_and_stopped_pixels() {
|
||||
let mut candidate = film(48);
|
||||
let mut work = 0;
|
||||
let cancel = CancelToken::default();
|
||||
while candidate.completed_passes() < 16 {
|
||||
step(&mut candidate, &mut work);
|
||||
}
|
||||
assert_eq!(candidate.next_pixel(), 0);
|
||||
for index in [0, 1, 2, usize::MAX] {
|
||||
assert!(candidate.sampling_identity_in_pass(index).is_err(), "complete pilots require reset before lookahead");
|
||||
}
|
||||
candidate
|
||||
.begin_production(&cancel, &mut || {
|
||||
work += 1;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(candidate.resetting());
|
||||
while candidate.resetting() {
|
||||
for index in [0, 1, 2, usize::MAX] {
|
||||
assert!(candidate.sampling_identity_in_pass(index).is_err(), "partial reset has no sample identities");
|
||||
}
|
||||
step(&mut candidate, &mut work);
|
||||
}
|
||||
let first = candidate.sampling_identity_in_pass(0).unwrap().unwrap();
|
||||
assert_eq!((first.seed, first.ordinal, first.stage), (81, 0, PreviewStage::Production));
|
||||
assert_eq!(candidate.sampling_identity_in_pass(1).unwrap(), Some(first));
|
||||
while candidate.completed_passes() < 32 {
|
||||
step(&mut candidate, &mut work);
|
||||
}
|
||||
assert_eq!(candidate.sampling_identity_in_pass(0).unwrap(), None);
|
||||
let future = candidate.sampling_identity_in_pass(1).unwrap().unwrap();
|
||||
assert_eq!((future.seed, future.ordinal, future.stage), (81, 16, PreviewStage::Production));
|
||||
assert!(candidate.sampling_identity(1).is_err());
|
||||
step(&mut candidate, &mut work);
|
||||
assert_eq!(candidate.next_pixel(), 1);
|
||||
assert!(candidate.sampling_identity_in_pass(0).is_err());
|
||||
assert_eq!(candidate.sampling_identity_in_pass(1).unwrap(), Some(future));
|
||||
while candidate.completed_passes() < 43 {
|
||||
step(&mut candidate, &mut work);
|
||||
}
|
||||
assert_eq!(candidate.next_pixel(), 0);
|
||||
assert_eq!(candidate.sampling_identity_in_pass(0).unwrap(), None);
|
||||
assert_eq!(candidate.sampling_identity_in_pass(1).unwrap(), None, "a future stopped pixel consumes no variate");
|
||||
let state = |film: &AdaptiveCameraFilm| (film.next_pixel(), film.completed_passes(), snapshot(film));
|
||||
let before = state(&candidate);
|
||||
let mut debits = 0;
|
||||
assert_eq!(
|
||||
candidate
|
||||
.skip_one(1, &cancel, &mut || {
|
||||
debits += 1;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ErrorCode::InvalidRequest
|
||||
);
|
||||
assert_eq!(debits, 0, "a stopped future pixel still cannot be published ahead of the owner cursor");
|
||||
assert_eq!(state(&candidate), before);
|
||||
step(&mut candidate, &mut work);
|
||||
assert_eq!(candidate.next_pixel(), 1);
|
||||
let before = state(&candidate);
|
||||
assert!(candidate.sampling_identity_in_pass(0).is_err());
|
||||
assert_eq!(candidate.sampling_identity_in_pass(1).unwrap(), None);
|
||||
assert_eq!(
|
||||
candidate
|
||||
.skip_one(0, &cancel, &mut || {
|
||||
debits += 1;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ErrorCode::InvalidRequest
|
||||
);
|
||||
assert_eq!(debits, 0);
|
||||
assert_eq!(state(&candidate), before);
|
||||
while !candidate.is_final() {
|
||||
step(&mut candidate, &mut work);
|
||||
}
|
||||
for index in [0, 1, 2, usize::MAX] {
|
||||
assert!(candidate.sampling_identity_in_pass(index).is_err(), "finished films have no active sweep");
|
||||
}
|
||||
let final_pass = candidate.complete_pass().unwrap();
|
||||
assert_eq!((final_pass.counts().pixel(0).unwrap().samples(), final_pass.counts().pixel(1).unwrap().samples()), (16, 27));
|
||||
assert_eq!(work, 133, "lookahead and rejected owner operations consume no additional work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_adaptive_pilots_plus_production_obey_every_unchanged_profile_and_camera_work_cap() {
|
||||
for budget in CpuBudget::ALL {
|
||||
@@ -469,7 +650,7 @@ mod cpu_adaptive_foundation {
|
||||
);
|
||||
let one = AdaptiveCameraFilm::required_bytes(1, 1, production, options(), CpuBudget::Compact).unwrap();
|
||||
let two = AdaptiveCameraFilm::required_bytes(2, 1, production, options(), CpuBudget::Compact).unwrap();
|
||||
assert_eq!(two - one, 70, "64-byte moments plus6-byte exact count/allocation plane, with no snapshot plane");
|
||||
assert_eq!(two - one, 76, "64-byte moments plus12-byte exact count/allocation plane, with no snapshot plane");
|
||||
assert!(AdaptiveCameraFilm::new(1, 1, production, options(), CpuBudget::Compact, one - 1, &CancelToken::default()).is_err());
|
||||
assert!(AdaptiveCameraFilm::required_bytes(0, 1, production, options(), CpuBudget::Compact).is_err());
|
||||
assert!(
|
||||
|
||||
@@ -817,7 +817,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_normal(v: Vec3, n: Vec3, alpha: f64, u: [f64; 2]) -> Result<Vec3> {
|
||||
pub(crate) fn visible_normal(v: Vec3, n: Vec3, alpha: f64, u: [f64; 2]) -> Result<Vec3> {
|
||||
let tangent = direction(if n.z.abs() < 0.9 { Vec3::Z.cross(n) } else { Vec3::X.cross(n) })?;
|
||||
let bitangent = n.cross(tangent);
|
||||
let stretched = direction(Vec3::new(alpha * v.dot(tangent), alpha * v.dot(bitangent), v.dot(n)))?;
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
use super::{ErrorCode, JobError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Maximum independent pilot samples per pixel supported by adaptive allocation.
|
||||
pub const MAX_PILOT_SAMPLES: u32 = 256;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct AdaptiveSampling {
|
||||
@@ -20,9 +23,9 @@ impl Default for AdaptiveSampling {
|
||||
}
|
||||
impl AdaptiveSampling {
|
||||
pub fn validate(self, maximum: u32) -> Result<()> {
|
||||
if !(16..=256).contains(&self.pilot_samples)
|
||||
if !(16..=MAX_PILOT_SAMPLES).contains(&self.pilot_samples)
|
||||
|| !(16..=maximum).contains(&self.min_samples)
|
||||
|| maximum > 4096
|
||||
|| maximum > super::MAX_SAMPLES_PER_PIXEL
|
||||
|| !self.relative_error.is_finite()
|
||||
|| !(0.0..=1.0).contains(&self.relative_error)
|
||||
|| !self.absolute_error.is_finite()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Opt-in finite-radius density estimation for delta-light optical caustics.
|
||||
//! Opt-in finite-radius density estimation for optical caustics.
|
||||
//! Photon count and radius are explicit quality controls, not a convergence certificate.
|
||||
use super::{ErrorCode, JobError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -10,26 +10,73 @@ pub struct CausticOptions {
|
||||
pub radius_mm: f64,
|
||||
pub max_depth: u8,
|
||||
pub seed: u32,
|
||||
/// Include rectangle emitters for smooth closed glass paths to opaque receivers.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub area_lights: bool,
|
||||
/// Include environment illumination for the smooth closed glass path family.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub environment: bool,
|
||||
/// Concentrate emission toward the captured optics with matched proposal densities.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub guided_emission: bool,
|
||||
/// Build a fresh photon map for each complete camera sampling pass.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub refresh_per_pass: bool,
|
||||
/// Allocate photons by emitted power with a uniform full-support mixture.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub power_weighted_sources: bool,
|
||||
}
|
||||
impl Default for CausticOptions {
|
||||
fn default() -> Self {
|
||||
Self { photons: 16384, radius_mm: 1.0, max_depth: 16, seed: 81 }
|
||||
Self {
|
||||
photons: 16384,
|
||||
radius_mm: 1.0,
|
||||
max_depth: 16,
|
||||
seed: 81,
|
||||
area_lights: false,
|
||||
environment: false,
|
||||
guided_emission: false,
|
||||
refresh_per_pass: false,
|
||||
power_weighted_sources: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct CausticWork {
|
||||
pub maps: u32,
|
||||
pub emitted_paths: u64,
|
||||
/// Light-path events only. Traversal and gathering use their own work units.
|
||||
pub max_light_path_events: u64,
|
||||
}
|
||||
|
||||
impl CausticOptions {
|
||||
pub fn validate(self) -> Result<()> {
|
||||
if !(1..=65536).contains(&self.photons)
|
||||
if !(1..=super::MAX_CAUSTIC_PHOTONS).contains(&self.photons)
|
||||
|| !self.radius_mm.is_finite()
|
||||
|| !(1e-6..=1e6).contains(&self.radius_mm)
|
||||
|| !(1..=16).contains(&self.max_depth)
|
||||
|| !(1..=super::MAX_PATH_DEPTH).contains(&self.max_depth)
|
||||
{
|
||||
return Err(JobError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"caustics require 1..65536 photons, finite radius 0.000001..1000000 mm and 1..16 light-path events",
|
||||
"caustics require 1..1048576 photons, finite radius 0.000001..1000000 mm and 1..64 light-path events",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn refresh_work(self, camera_passes: u32) -> Result<Option<CausticWork>> {
|
||||
if !self.refresh_per_pass {
|
||||
return Ok(None);
|
||||
}
|
||||
if camera_passes == 0 {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "photon refresh requires at least one camera pass"));
|
||||
}
|
||||
let overflow = || JobError::new(ErrorCode::LimitExceeded, "photon refresh work estimate overflow");
|
||||
let emitted_paths = u64::from(camera_passes).checked_mul(u64::from(self.photons)).ok_or_else(overflow)?;
|
||||
let max_light_path_events = emitted_paths.checked_mul(u64::from(self.max_depth)).ok_or_else(overflow)?;
|
||||
Ok(Some(CausticWork { maps: camera_passes, emitted_paths, max_light_path_events }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -40,14 +87,109 @@ mod tests {
|
||||
let valid = CausticOptions::default();
|
||||
valid.validate().unwrap();
|
||||
assert_eq!(serde_json::from_value::<CausticOptions>(serde_json::to_value(valid).unwrap()).unwrap(), valid);
|
||||
for photons in [0, 65537, u32::MAX] {
|
||||
assert!(serde_json::to_value(valid).unwrap().get("area_lights").is_none());
|
||||
assert!(serde_json::to_value(valid).unwrap().get("environment").is_none());
|
||||
assert!(serde_json::to_value(valid).unwrap().get("guided_emission").is_none());
|
||||
assert!(serde_json::to_value(valid).unwrap().get("refresh_per_pass").is_none());
|
||||
let area = CausticOptions { area_lights: true, ..valid };
|
||||
assert_eq!(serde_json::from_value::<CausticOptions>(serde_json::to_value(area).unwrap()).unwrap(), area);
|
||||
let environment = CausticOptions { environment: true, ..valid };
|
||||
assert_eq!(serde_json::from_value::<CausticOptions>(serde_json::to_value(environment).unwrap()).unwrap(), environment);
|
||||
let guided = CausticOptions { guided_emission: true, ..valid };
|
||||
assert_eq!(serde_json::from_value::<CausticOptions>(serde_json::to_value(guided).unwrap()).unwrap(), guided);
|
||||
let refresh = CausticOptions { refresh_per_pass: true, ..guided };
|
||||
assert_eq!(serde_json::from_value::<CausticOptions>(serde_json::to_value(refresh).unwrap()).unwrap(), refresh);
|
||||
assert_eq!(guided.refresh_work(20).unwrap(), None);
|
||||
assert_eq!(refresh.refresh_work(20).unwrap(), Some(CausticWork { maps: 20, emitted_paths: 327_680, max_light_path_events: 5_242_880 }));
|
||||
assert!(refresh.refresh_work(0).is_err());
|
||||
assert!(CausticOptions { photons: u32::MAX, ..refresh }.refresh_work(u32::MAX).is_err());
|
||||
for photons in [0, super::super::MAX_CAUSTIC_PHOTONS + 1, u32::MAX] {
|
||||
assert!(CausticOptions { photons, ..valid }.validate().is_err());
|
||||
}
|
||||
for radius_mm in [f64::NAN, f64::INFINITY, 0.0, -1.0, 1e-7, 1e6 + 1.0] {
|
||||
assert!(CausticOptions { radius_mm, ..valid }.validate().is_err());
|
||||
}
|
||||
for max_depth in [0, 17, u8::MAX] {
|
||||
for max_depth in [0, super::super::MAX_PATH_DEPTH + 1, u8::MAX] {
|
||||
assert!(CausticOptions { max_depth, ..valid }.validate().is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod source_proposal_tests {
|
||||
use super::super::photon_sources::*;
|
||||
use super::*;
|
||||
#[test]
|
||||
fn photon_source_bins_preserve_support_normalization_and_analytic_power() {
|
||||
for powers in [vec![0.0; 21], vec![f64::MAX, f64::MIN_POSITIVE, 0.0], vec![1.0], vec![1.0, 9.0, 90.0]] {
|
||||
let table = PhotonSourceDistribution::new(&powers).unwrap();
|
||||
assert_eq!(*table.cumulative().last().unwrap(), SOURCE_BINS);
|
||||
let mut previous = 0;
|
||||
let mut integral = 0.0;
|
||||
for (source, end) in table.cumulative().iter().copied().enumerate() {
|
||||
let width = end - previous;
|
||||
assert!(f64::from(width) / f64::from(SOURCE_BINS) >= 0.05 / powers.len() as f64 - 1.0 / f64::from(SOURCE_BINS));
|
||||
for bin in [previous, end - 1] {
|
||||
let (selected, pdf) = table.select(f64::from(bin) / f64::from(SOURCE_BINS)).unwrap();
|
||||
assert_eq!(selected, source);
|
||||
assert_eq!(pdf, f64::from(width) / f64::from(SOURCE_BINS));
|
||||
}
|
||||
let pdf = f64::from(width) / f64::from(SOURCE_BINS);
|
||||
// Independent integral of source power under the represented proposal.
|
||||
if powers[source] < 1e300 {
|
||||
integral += pdf * (powers[source] / pdf);
|
||||
}
|
||||
previous = end;
|
||||
}
|
||||
if powers.iter().all(|p| *p < 1e300) {
|
||||
assert!((integral - powers.iter().sum::<f64>()).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
let table = PhotonSourceDistribution::new(&[1.0, 9.0, 90.0]).unwrap();
|
||||
let mut counts = [0; 3];
|
||||
for draw in 0..100_000 {
|
||||
counts[table.select((f64::from(draw) + 0.5) / 100_000.0).unwrap().0] += 1;
|
||||
}
|
||||
for (index, power) in [1.0, 9.0, 90.0].into_iter().enumerate() {
|
||||
let expected = 0.95 * power / 100.0 + 0.05 / 3.0;
|
||||
assert!((f64::from(counts[index]) / 100_000.0 - expected).abs() < 0.00002);
|
||||
}
|
||||
for invalid in [vec![], vec![1.0; 22], vec![-1.0], vec![f64::NAN], vec![f64::INFINITY]] {
|
||||
assert!(PhotonSourceDistribution::new(&invalid).is_err());
|
||||
}
|
||||
for invalid in [-1.0, 1.0, f64::NAN, f64::INFINITY] {
|
||||
assert!(table.select(invalid).is_err());
|
||||
}
|
||||
assert!(serde_json::to_value(CausticOptions::default()).unwrap().get("power_weighted_sources").is_none());
|
||||
let enabled = CausticOptions { power_weighted_sources: true, ..Default::default() };
|
||||
assert_eq!(serde_json::from_value::<CausticOptions>(serde_json::to_value(enabled).unwrap()).unwrap(), enabled);
|
||||
}
|
||||
#[test]
|
||||
fn photon_source_power_matches_independent_spot_integral_and_rectangle_disk_units() {
|
||||
use super::super::{PunctualLightOptions, SpotConeOptions};
|
||||
let light = PunctualLightOptions {
|
||||
position_mm: [0.0; 3],
|
||||
color: [1.0; 3],
|
||||
intensity: 2.0,
|
||||
reference_distance_mm: 3.0,
|
||||
minimum_distance_mm: 1.0,
|
||||
cone: None,
|
||||
};
|
||||
assert!((punctual_power(&light).unwrap() - 72.0 * std::f64::consts::PI).abs() < 1e-12);
|
||||
let spot = PunctualLightOptions {
|
||||
cone: Some(SpotConeOptions { emitted_axis: [0.0, 0.0, 1.0], inner_half_angle_radians: 0.2, outer_half_angle_radians: 0.5 }),
|
||||
..light
|
||||
};
|
||||
let mut integral = 0.0;
|
||||
for index in 0..100_000 {
|
||||
let z = -1.0 + 2.0 * (f64::from(index) + 0.5) / 100_000.0;
|
||||
let t = ((z - 0.5_f64.cos()) / (0.2_f64.cos() - 0.5_f64.cos())).clamp(0.0, 1.0);
|
||||
integral += 18.0 * t * t * (3.0 - 2.0 * t) * 4.0 * std::f64::consts::PI / 100_000.0;
|
||||
}
|
||||
assert!((punctual_power(&spot).unwrap() - integral).abs() < 1e-7);
|
||||
assert!((rectangle_power(6.0, [2.0; 3]).unwrap() - 12.0 * std::f64::consts::PI).abs() < 1e-12);
|
||||
assert!((disk_power(2.0, 3.0).unwrap() - 12.0 * std::f64::consts::PI).abs() < 1e-12);
|
||||
assert!(disk_power(f64::MAX, 1.0).is_err());
|
||||
assert!(rectangle_power(-1.0, [1.0; 3]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@ use crate::RenderLimits;
|
||||
const MIB: u64 = 1024 * 1024;
|
||||
const GIB: u64 = 1024 * MIB;
|
||||
/// Native CPU reservations, including retained frames, share this finite ceiling.
|
||||
pub const NATIVE_CPU_POOL_BYTES: u64 = 16 * GIB;
|
||||
pub const NATIVE_CPU_POOL_BYTES: u64 = 256 * GIB;
|
||||
/// Hard implementation ceilings; individual requests retain their selected envelope.
|
||||
pub const MAX_CPU_WORK: u64 = 16_000_000_000_000;
|
||||
pub const MAX_CPU_WORK: u64 = 1 << 60;
|
||||
pub const MAX_CPU_TRIANGLES: usize = 8_000_000;
|
||||
pub const MAX_CPU_BODIES: usize = 262_144;
|
||||
/// Maximum admitted primary-film samples, shared with the display resolver.
|
||||
pub const MAX_CPU_SAMPLES_PER_PIXEL: u32 = 4096;
|
||||
pub const MAX_CPU_SAMPLES_PER_PIXEL: u32 = super::MAX_SAMPLES_PER_PIXEL;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -74,14 +74,14 @@ impl CpuBudget {
|
||||
},
|
||||
Self::Workstation => CpuBudgetLimits {
|
||||
max_dimension: 16_384,
|
||||
max_pixels: 67_108_864,
|
||||
max_progressive_pixels: 67_108_864,
|
||||
max_pixels: 134_217_728,
|
||||
max_progressive_pixels: 134_217_728,
|
||||
max_samples_per_pixel: MAX_CPU_SAMPLES_PER_PIXEL,
|
||||
max_camera_samples: 68_719_476_736,
|
||||
max_camera_samples: super::MAX_TRACE_PIXELS * MAX_CPU_SAMPLES_PER_PIXEL as u64,
|
||||
max_triangles: MAX_CPU_TRIANGLES as u64,
|
||||
max_bodies: MAX_CPU_BODIES,
|
||||
max_geometry_bytes: 4 * GIB,
|
||||
max_job_bytes: 12 * GIB,
|
||||
max_job_bytes: 128 * GIB,
|
||||
max_work_units: MAX_CPU_WORK,
|
||||
},
|
||||
}
|
||||
@@ -206,8 +206,10 @@ mod tests {
|
||||
assert!(budget.validate_samples(limits.max_samples_per_pixel + 1).is_err());
|
||||
assert!(budget.validate_samples(limits.max_samples_per_pixel).is_ok());
|
||||
}
|
||||
let p = Some(CpuProgressiveOptions { samples_per_pixel: 4096, seed: 0 });
|
||||
assert!(CpuBudget::Workstation.validate_image(8192, 8192, p).is_err());
|
||||
let p = Some(CpuProgressiveOptions { samples_per_pixel: MAX_CPU_SAMPLES_PER_PIXEL, seed: 0 });
|
||||
assert!(CpuBudget::Workstation.validate_image(8192, 8192, p).is_ok());
|
||||
assert!(CpuBudget::Workstation.validate_image(16_384, 8192, p).is_ok());
|
||||
assert_eq!(CpuBudget::Workstation.limits().max_camera_samples, 1 << 43);
|
||||
assert!(CpuBudget::Workstation.validate_image(3840, 2160, p).is_ok());
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ impl Default for CpuPathOptions {
|
||||
}
|
||||
impl CpuPathOptions {
|
||||
pub fn validate(self) -> Result<()> {
|
||||
if !(1..=16).contains(&self.max_depth) {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "CPU path depth must be 1..16 surfaces, Glass boundaries or medium collisions"));
|
||||
if !(1..=super::MAX_PATH_DEPTH).contains(&self.max_depth) {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "CPU path depth must be 1..64 surfaces, Glass boundaries or medium collisions"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
//! Original bounded CPU a-trous output filter. Raw film and transport are never borrowed mutably.
|
||||
//! The independent f64 gather oracle remains in the renderer tests, outside this implementation.
|
||||
use super::{CancelToken, ErrorCode, JobError, Result};
|
||||
use super::{CancelToken, DenoiseSettings, ErrorCode, JobError, Result};
|
||||
|
||||
pub(crate) const GUIDE_BYTES: u64 = 48;
|
||||
pub(crate) const GUIDE_BYTES: u64 = 96;
|
||||
pub(crate) const MAX_STEP_PIXELS: usize = 256;
|
||||
const OPTICAL_TAG: u32 = 0xa000_0000;
|
||||
#[cfg(any(test, feature = "raytrace-cpu"))]
|
||||
const OPTICAL_ID_MAX: u32 = 0x1ff;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct Guide {
|
||||
pub(crate) struct SurfaceGuide {
|
||||
pub albedo: [f32; 3],
|
||||
/// Captured body ordinal plus one; zero means that first-hit guidance is unavailable.
|
||||
/// Captured body ordinal plus one, or a packed optical correspondence.
|
||||
/// Zero means that guidance is unavailable.
|
||||
pub body: u32,
|
||||
pub normal: [f32; 3],
|
||||
pub depth: f32,
|
||||
@@ -16,12 +20,54 @@ pub(crate) struct Guide {
|
||||
pub position: [f32; 3],
|
||||
pub footprint: f32,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct Guide {
|
||||
pub albedo: [f32; 3],
|
||||
/// Captured body ordinal plus one, or a packed optical correspondence.
|
||||
/// Zero means that guidance is unavailable.
|
||||
pub body: u32,
|
||||
pub normal: [f32; 3],
|
||||
pub depth: f32,
|
||||
/// Camera-relative position, converted only after the f64 subtraction.
|
||||
pub position: [f32; 3],
|
||||
pub footprint: f32,
|
||||
/// Reflected endpoint when the main guide describes transmission.
|
||||
pub reflected: SurfaceGuide,
|
||||
}
|
||||
const _: [(); GUIDE_BYTES as usize] = [(); std::mem::size_of::<Guide>()];
|
||||
|
||||
fn invalid(message: &str) -> JobError {
|
||||
JobError::new(ErrorCode::InvalidRequest, message)
|
||||
}
|
||||
impl Guide {
|
||||
/// Identity is shared with WGSL. Each endpoint uses a nonzero captured
|
||||
/// body ID. A zero reflected or transmitted ID denotes an absent branch.
|
||||
#[cfg(any(test, feature = "raytrace-cpu"))]
|
||||
pub(crate) fn optical_body(primary: u32, reflected: u32, transmitted: u32) -> Option<u32> {
|
||||
(primary != 0 && (reflected != 0 || transmitted != 0) && [primary, reflected, transmitted].into_iter().all(|id| id <= OPTICAL_ID_MAX))
|
||||
.then_some(OPTICAL_TAG | (primary << 18) | (reflected << 9) | transmitted)
|
||||
}
|
||||
|
||||
fn optical(self) -> bool {
|
||||
self.body & 0xf800_0000 == OPTICAL_TAG
|
||||
}
|
||||
|
||||
pub(crate) fn surface(self) -> SurfaceGuide {
|
||||
SurfaceGuide {
|
||||
albedo: self.albedo,
|
||||
body: self.body,
|
||||
normal: self.normal,
|
||||
depth: self.depth,
|
||||
position: self.position,
|
||||
footprint: self.footprint,
|
||||
}
|
||||
}
|
||||
pub(crate) fn validate(self) -> Result<()> {
|
||||
self.surface().validate()?;
|
||||
self.reflected.validate()
|
||||
}
|
||||
}
|
||||
impl SurfaceGuide {
|
||||
pub(crate) fn validate(self) -> Result<()> {
|
||||
if self.body == 0 {
|
||||
return Ok(());
|
||||
@@ -47,9 +93,14 @@ pub(crate) struct DenoiseTask {
|
||||
destination: Vec<[f32; 4]>,
|
||||
pixel: usize,
|
||||
pass: usize,
|
||||
settings: DenoiseSettings,
|
||||
}
|
||||
impl DenoiseTask {
|
||||
pub(crate) fn new(width: u32, height: u32, source: Vec<[f32; 4]>) -> Result<Self> {
|
||||
Self::new_with_settings(width, height, source, DenoiseSettings::default())
|
||||
}
|
||||
pub(crate) fn new_with_settings(width: u32, height: u32, source: Vec<[f32; 4]>, settings: DenoiseSettings) -> Result<Self> {
|
||||
settings.validate()?;
|
||||
let count = usize::try_from(u64::from(width) * u64::from(height)).map_err(|_| invalid("CPU denoising dimensions overflow"))?;
|
||||
if width == 0 || height == 0 || source.len() != count || source.capacity() != count {
|
||||
return Err(invalid("CPU denoising requires one admitted complete linear pass"));
|
||||
@@ -59,7 +110,7 @@ impl DenoiseTask {
|
||||
if destination.capacity() != count {
|
||||
return Err(invalid("CPU denoising colour capacity exceeds admission"));
|
||||
}
|
||||
Ok(Self { width: width as usize, height: height as usize, source, destination, pixel: 0, pass: 0 })
|
||||
Ok(Self { width: width as usize, height: height as usize, source, destination, pixel: 0, pass: 0, settings })
|
||||
}
|
||||
pub(crate) fn next_pixels(&self) -> usize {
|
||||
(self.source.len() - self.pixel).min(MAX_STEP_PIXELS)
|
||||
@@ -68,13 +119,13 @@ impl DenoiseTask {
|
||||
/// The caller debits next_pixels before calling; no work, limit or cancellation is reset here.
|
||||
pub(crate) fn step(&mut self, guides: &[Guide], cancel: &CancelToken) -> Result<bool> {
|
||||
cancel.check()?;
|
||||
if self.pass >= 3 || guides.len() != self.source.len() {
|
||||
if self.pass >= self.settings.passes as usize || guides.len() != self.source.len() {
|
||||
return Err(invalid("CPU denoising guide count or pass is invalid"));
|
||||
}
|
||||
let end = self.pixel + self.next_pixels();
|
||||
for pixel in self.pixel..end {
|
||||
cancel.check()?;
|
||||
let value = filter_pixel(self.width, self.height, &self.source, guides, pixel, 1 << self.pass)?;
|
||||
let value = filter_pixel(self.width, self.height, &self.source, guides, pixel, 1 << self.pass, self.settings)?;
|
||||
if let Some(destination) = self.destination.get_mut(pixel) {
|
||||
*destination = value;
|
||||
} else if self.destination.len() == pixel && self.destination.len() < self.destination.capacity() {
|
||||
@@ -90,10 +141,10 @@ impl DenoiseTask {
|
||||
self.pass += 1;
|
||||
}
|
||||
cancel.check()?;
|
||||
Ok(self.pass == 3)
|
||||
Ok(self.pass == self.settings.passes as usize)
|
||||
}
|
||||
pub(crate) fn finish(self) -> Result<Vec<[f32; 4]>> {
|
||||
if self.pass != 3 {
|
||||
if self.pass != self.settings.passes as usize {
|
||||
return Err(invalid("CPU denoising is incomplete"));
|
||||
}
|
||||
Ok(self.source)
|
||||
@@ -118,16 +169,45 @@ fn checked_color(value: [f32; 4]) -> Result<[f32; 4]> {
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn filter_pixel(width: usize, height: usize, source: &[[f32; 4]], guides: &[Guide], pixel: usize, step: usize) -> Result<[f32; 4]> {
|
||||
fn surface_weight(a: SurfaceGuide, b: SurfaceGuide, step: usize) -> f64 {
|
||||
if a.body != b.body {
|
||||
return 0.0;
|
||||
}
|
||||
if a.body == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let albedo = difference(a.albedo, b.albedo);
|
||||
let position = difference(b.position, a.position);
|
||||
let normal = a.normal.map(f64::from);
|
||||
let neighbour_normal = b.normal.map(f64::from);
|
||||
let plane = dot(position, normal).abs() + dot(position, neighbour_normal).abs();
|
||||
let footprint = f64::from(a.footprint.max(b.footprint)) * step as f64;
|
||||
let depth = (f64::from(a.depth) - f64::from(b.depth)).abs() / (8.0 * footprint).max(0.000001);
|
||||
(-dot(albedo, albedo) / 0.04 - plane / (0.05 * footprint).max(0.000001) - depth).exp() * dot(normal, neighbour_normal).max(0.0).powi(32)
|
||||
}
|
||||
fn filter_pixel(
|
||||
width: usize,
|
||||
height: usize,
|
||||
source: &[[f32; 4]],
|
||||
guides: &[Guide],
|
||||
pixel: usize,
|
||||
step: usize,
|
||||
settings: DenoiseSettings,
|
||||
) -> Result<[f32; 4]> {
|
||||
let center = checked_color(*source.get(pixel).ok_or_else(|| invalid("CPU denoising source pixel missing"))?)?;
|
||||
let a = *guides.get(pixel).ok_or_else(|| invalid("CPU denoising guide missing"))?;
|
||||
a.validate()?;
|
||||
if a.body == 0 || center[3] < 0.999 {
|
||||
let optical = a.optical();
|
||||
if a.body == 0 || center[3] < 0.999 || settings.strength == 0.0 || (optical && step > 2) {
|
||||
return Ok(center);
|
||||
}
|
||||
let mut mean = [0.0; 3];
|
||||
let mut total = 0.0;
|
||||
let center_rgb = rgb(center);
|
||||
let center_peak = maximum(center_rgb);
|
||||
let contrast_strength = if optical { 4.0 } else { 1.0 };
|
||||
let mut support_count = 0;
|
||||
let mut support_peak: f64 = 0.0;
|
||||
let kernel = [1.0, 4.0, 6.0, 4.0, 1.0];
|
||||
for (ky, wy) in kernel.into_iter().enumerate() {
|
||||
for (kx, wx) in kernel.into_iter().enumerate() {
|
||||
@@ -143,20 +223,27 @@ fn filter_pixel(width: usize, height: usize, source: &[[f32; 4]], guides: &[Guid
|
||||
continue;
|
||||
}
|
||||
b.validate()?;
|
||||
let albedo = difference(a.albedo, b.albedo);
|
||||
let position = difference(b.position, a.position);
|
||||
let normal = a.normal.map(f64::from);
|
||||
let neighbour_normal = b.normal.map(f64::from);
|
||||
let plane = dot(position, normal).abs() + dot(position, neighbour_normal).abs();
|
||||
let footprint = f64::from(a.footprint.max(b.footprint)) * step as f64;
|
||||
let depth = (f64::from(a.depth) - f64::from(b.depth)).abs() / (8.0 * footprint).max(0.000001);
|
||||
let value_rgb = rgb(value);
|
||||
let value_peak = maximum(value_rgb);
|
||||
let guide_weight = surface_weight(a.surface(), b.surface(), step) * surface_weight(a.reflected, b.reflected, step);
|
||||
if settings.firefly_suppression
|
||||
&& !optical
|
||||
&& step == 1
|
||||
&& index != pixel
|
||||
&& kx.abs_diff(2) <= 1
|
||||
&& ky.abs_diff(2) <= 1
|
||||
&& guide_weight >= 0.5
|
||||
{
|
||||
support_count += 1;
|
||||
support_peak = support_peak.max(value_peak);
|
||||
}
|
||||
let contrast = maximum(std::array::from_fn(|i| (center_rgb[i] - value_rgb[i]).abs()));
|
||||
let color_scale = 0.25 + 0.5 * maximum(center_rgb).max(maximum(value_rgb));
|
||||
let weight = wx
|
||||
* wy
|
||||
* (-dot(albedo, albedo) / 0.04 - plane / (0.05 * footprint).max(0.000001) - depth - contrast / color_scale).exp()
|
||||
* dot(normal, neighbour_normal).max(0.0).powi(32);
|
||||
let brighter = center_peak.max(value_peak);
|
||||
let color_scale = 0.25 + 0.5 * brighter;
|
||||
let peak_bound = 16.0 * (0.25 + center_peak.min(value_peak));
|
||||
// Keep a high contrast tap from spreading a single bright sample.
|
||||
let peak_weight = if settings.firefly_suppression && brighter > peak_bound { (peak_bound / brighter).powi(2) } else { 1.0 };
|
||||
let weight = wx * wy * guide_weight * (-contrast_strength * contrast / color_scale).exp() * peak_weight;
|
||||
if weight > 0.0 {
|
||||
total += weight;
|
||||
for (channel, value) in mean.iter_mut().zip(value_rgb) {
|
||||
@@ -168,5 +255,190 @@ fn filter_pixel(width: usize, height: usize, source: &[[f32; 4]], guides: &[Guid
|
||||
if !total.is_finite() || total <= 0.0 {
|
||||
return Err(invalid("CPU denoising centre did not contribute"));
|
||||
}
|
||||
// Clamp only isolated outliers with enough compatible immediate neighbours.
|
||||
if settings.firefly_suppression && !optical && step == 1 && support_count >= 5 && center_peak > 16.0 * (0.25 + support_peak) {
|
||||
let cap = 2.0 * support_peak + 0.25;
|
||||
let peak = maximum(mean);
|
||||
if peak > cap {
|
||||
let scale = cap / peak;
|
||||
mean = mean.map(|channel| channel * scale);
|
||||
}
|
||||
}
|
||||
if settings.strength != 1.0 {
|
||||
mean = std::array::from_fn(|channel| center_rgb[channel] + (mean[channel] - center_rgb[channel]) * settings.strength);
|
||||
}
|
||||
checked_color([mean[0] as f32, mean[1] as f32, mean[2] as f32, center[3]])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mixed_glass_filter_preserves_edges_within_the_same_reflected_body() {
|
||||
let mut guide = Guide {
|
||||
body: Guide::optical_body(1, 2, 3).unwrap(),
|
||||
albedo: [0.5; 3],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
depth: 1.0,
|
||||
position: [0.0; 3],
|
||||
footprint: 1.0,
|
||||
reflected: SurfaceGuide::default(),
|
||||
};
|
||||
guide.reflected = SurfaceGuide { body: 2, ..guide.surface() };
|
||||
let mut colors = [[0.5, 0.5, 0.5, 1.0]; 25];
|
||||
colors[12] = [0.7, 0.7, 0.7, 1.0];
|
||||
let smooth = filter_pixel(5, 5, &colors, &[guide; 25], 12, 1, DenoiseSettings::default()).unwrap();
|
||||
assert!(smooth[0] < 0.65);
|
||||
for edge in 0..4 {
|
||||
let mut neighbor = guide;
|
||||
match edge {
|
||||
0 => neighbor.reflected.albedo = [1.0, 0.0, 0.0],
|
||||
1 => neighbor.reflected.normal = [1.0, 0.0, 0.0],
|
||||
2 => neighbor.reflected.depth = 200.0,
|
||||
_ => neighbor.reflected.position = [0.0, 0.0, 2.0],
|
||||
}
|
||||
let mut guides = [neighbor; 25];
|
||||
guides[12] = guide;
|
||||
let filtered = filter_pixel(5, 5, &colors, &guides, 12, 1, DenoiseSettings::default()).unwrap();
|
||||
assert!(filtered[0] > 0.699, "reflected edge {edge}: {filtered:?}");
|
||||
assert_eq!(filtered[3], 1.0);
|
||||
}
|
||||
let invalid = Guide { reflected: SurfaceGuide { depth: f32::NAN, ..guide.reflected }, ..guide };
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optical_filter_respects_both_endpoints_and_preserves_unsupported_glints() {
|
||||
let identity = Guide::optical_body(1, 2, 3).unwrap();
|
||||
let boundary = Guide::optical_body(1, 4, 3).unwrap();
|
||||
assert_ne!(identity, boundary);
|
||||
let packed = f32::from_bits(Guide::optical_body(511, 511, 511).unwrap());
|
||||
assert!(packed.is_normal() && packed.is_sign_negative());
|
||||
assert!(Guide::optical_body(512, 2, 3).is_none());
|
||||
|
||||
let guide = Guide {
|
||||
body: identity,
|
||||
albedo: [0.5; 3],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
depth: 1.0,
|
||||
position: [0.0; 3],
|
||||
footprint: 1.0,
|
||||
reflected: SurfaceGuide::default(),
|
||||
};
|
||||
let mut guides = [guide; 25];
|
||||
let mut colors = [[0.5, 0.5, 0.5, 1.0]; 25];
|
||||
colors[12] = [0.7, 0.7, 0.7, 1.0];
|
||||
assert!(filter_pixel(5, 5, &colors, &guides, 12, 1, DenoiseSettings::default()).unwrap()[0] < 0.65);
|
||||
assert_eq!(filter_pixel(5, 5, &colors, &guides, 12, 4, DenoiseSettings::default()).unwrap(), colors[12]);
|
||||
|
||||
colors[12] = [512.0, 128.0, 32.0, 1.0];
|
||||
let glint = filter_pixel(5, 5, &colors, &guides, 12, 1, DenoiseSettings { firefly_suppression: true, ..Default::default() }).unwrap();
|
||||
assert!(glint[0] > 511.0);
|
||||
assert_eq!(glint[3], colors[12][3]);
|
||||
|
||||
colors[12] = [0.5, 0.5, 0.5, 1.0];
|
||||
colors[13] = [1.0; 4];
|
||||
guides[13].body = boundary;
|
||||
assert_eq!(filter_pixel(5, 5, &colors, &guides, 12, 1, DenoiseSettings::default()).unwrap(), colors[12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_passes_and_strength_preserve_alpha_and_default_behavior() {
|
||||
let guide = Guide {
|
||||
body: 1,
|
||||
albedo: [0.5; 3],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
depth: 1.0,
|
||||
position: [0.0; 3],
|
||||
footprint: 1.0,
|
||||
reflected: SurfaceGuide::default(),
|
||||
};
|
||||
let guides = [guide; 25];
|
||||
let mut colors = vec![[0.5, 0.25, 0.125, 1.0]; 25];
|
||||
colors[12] = [0.7, 0.4, 0.2, 1.0];
|
||||
colors[0] = [0.2, 0.1, 0.05, 0.5];
|
||||
let cancel = CancelToken::default();
|
||||
let run = |settings: DenoiseSettings| {
|
||||
let mut task = DenoiseTask::new_with_settings(5, 5, colors.clone(), settings).unwrap();
|
||||
let mut visits = 0;
|
||||
for _ in 0..3 {
|
||||
visits += task.next_pixels();
|
||||
if task.step(&guides, &cancel).unwrap() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(visits, 25 * settings.passes as usize);
|
||||
let result = task.finish().unwrap();
|
||||
for (filtered, original) in result.iter().zip(&colors) {
|
||||
assert_eq!(filtered[3], original[3]);
|
||||
}
|
||||
assert_eq!(result[0], colors[0]);
|
||||
result
|
||||
};
|
||||
let one = run(DenoiseSettings { passes: 1, ..Default::default() });
|
||||
let half = run(DenoiseSettings { passes: 1, strength: 0.5, ..Default::default() });
|
||||
for channel in 0..3 {
|
||||
let expected = (f64::from(colors[12][channel]) + f64::from(one[12][channel])) * 0.5;
|
||||
assert!((f64::from(half[12][channel]) - expected).abs() < 1e-7);
|
||||
}
|
||||
let two = run(DenoiseSettings { passes: 2, ..Default::default() });
|
||||
let three = run(DenoiseSettings::default());
|
||||
assert!(one[12][0] > two[12][0]);
|
||||
// On this 5x5 fixture, the centre has no neighbours at spacing 4.
|
||||
assert_eq!(two[12], three[12]);
|
||||
for passes in 1..=3 {
|
||||
assert_eq!(run(DenoiseSettings { passes, strength: 0.0, ..Default::default() }), colors);
|
||||
}
|
||||
let mut legacy = DenoiseTask::new(5, 5, colors.clone()).unwrap();
|
||||
for _ in 0..3 {
|
||||
legacy.step(&guides, &cancel).unwrap();
|
||||
}
|
||||
assert_eq!(legacy.finish().unwrap(), three);
|
||||
for settings in [
|
||||
DenoiseSettings { passes: 0, ..Default::default() },
|
||||
DenoiseSettings { passes: 4, ..Default::default() },
|
||||
DenoiseSettings { strength: f64::NAN, ..Default::default() },
|
||||
] {
|
||||
assert!(DenoiseTask::new_with_settings(5, 5, colors.clone(), settings).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firefly_control_preserves_legacy_opaque_filter_and_optical_glints() {
|
||||
let guide = Guide {
|
||||
body: 1,
|
||||
albedo: [0.5; 3],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
depth: 1.0,
|
||||
position: [0.0; 3],
|
||||
footprint: 1.0,
|
||||
reflected: SurfaceGuide::default(),
|
||||
};
|
||||
let mut colors = [[0.5, 0.125, 0.0625, 1.0]; 25];
|
||||
colors[12] = [4096.0, 1024.0, 512.0, 1.0];
|
||||
let off = DenoiseSettings::default();
|
||||
assert!(!off.firefly_suppression);
|
||||
assert_eq!(serde_json::from_str::<DenoiseSettings>("{}").unwrap(), off);
|
||||
let on = DenoiseSettings { firefly_suppression: true, ..off };
|
||||
let capped = filter_pixel(5, 5, &colors, &[guide; 25], 12, 1, on).unwrap();
|
||||
let uncapped = filter_pixel(5, 5, &colors, &[guide; 25], 12, 1, off).unwrap();
|
||||
assert!(capped[0] <= 1.25);
|
||||
// Independent legacy opaque oracle: the constant 5x5 guide has total
|
||||
// kernel weight 256, of which the centre owns 36. All other taps share
|
||||
// the original contrast weight, with neither peak attenuation nor a cap.
|
||||
let tap_weight = (-(4096.0_f64 - 0.5) / (0.25 + 0.5 * 4096.0)).exp();
|
||||
for channel in 0..3 {
|
||||
let expected = (36.0 * f64::from(colors[12][channel]) + 220.0 * tap_weight * f64::from(colors[0][channel])) / (36.0 + 220.0 * tap_weight);
|
||||
assert!((f64::from(uncapped[channel]) - expected).abs() < 0.0002, "{uncapped:?} vs {expected}");
|
||||
}
|
||||
assert_eq!((capped[3], uncapped[3]), (1.0, 1.0));
|
||||
let optical = Guide { body: Guide::optical_body(1, 0, 2).unwrap(), ..guide };
|
||||
let a = filter_pixel(5, 5, &colors, &[optical; 25], 12, 1, on).unwrap();
|
||||
let b = filter_pixel(5, 5, &colors, &[optical; 25], 12, 1, off).unwrap();
|
||||
assert!(a[0] > 4095.0);
|
||||
assert!(b[0] > 4096.0 * 0.99, "the disabled opaque cap must not flatten an optical glint: {b:?}");
|
||||
assert_eq!((a[3], b[3]), (1.0, 1.0));
|
||||
assert_eq!(filter_pixel(5, 5, &colors, &[optical; 25], 12, 4, on).unwrap(), colors[12]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Bounded optical-guide diagnostics, separate from radiance and final colour.
|
||||
use serde::{Serialize, Serializer, ser::SerializeMap};
|
||||
|
||||
/// Stable wire codes shared by native CPU guide generation and GPU shaders.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[repr(u8)]
|
||||
pub enum DenoiseGuideClass {
|
||||
Unavailable = 0,
|
||||
Surface = 1,
|
||||
Optical = 2,
|
||||
Background = 3,
|
||||
Emitter = 4,
|
||||
UnsupportedMaterial = 5,
|
||||
NestedMedium = 6,
|
||||
DepthOfField = 7,
|
||||
Volume = 8,
|
||||
PathDepth = 9,
|
||||
DegeneratePath = 10,
|
||||
IncompatibleExit = 11,
|
||||
TotalInternalReflection = 12,
|
||||
FurtherTransmission = 13,
|
||||
ReflectiveEndpoint = 14,
|
||||
IdentityLimit = 15,
|
||||
PartialCoverage = 16,
|
||||
}
|
||||
|
||||
impl DenoiseGuideClass {
|
||||
pub const ALL: &[Self] = &[
|
||||
Self::Unavailable,
|
||||
Self::Surface,
|
||||
Self::Optical,
|
||||
Self::Background,
|
||||
Self::Emitter,
|
||||
Self::UnsupportedMaterial,
|
||||
Self::NestedMedium,
|
||||
Self::DepthOfField,
|
||||
Self::Volume,
|
||||
Self::PathDepth,
|
||||
Self::DegeneratePath,
|
||||
Self::IncompatibleExit,
|
||||
Self::TotalInternalReflection,
|
||||
Self::FurtherTransmission,
|
||||
Self::ReflectiveEndpoint,
|
||||
Self::IdentityLimit,
|
||||
Self::PartialCoverage,
|
||||
];
|
||||
|
||||
pub fn from_code(code: u32) -> Option<Self> {
|
||||
Self::ALL.get(code as usize).copied()
|
||||
}
|
||||
|
||||
pub const fn code(self) -> u32 {
|
||||
self as u32
|
||||
}
|
||||
|
||||
pub const fn eligible(self) -> bool {
|
||||
matches!(self, Self::Surface | Self::Optical)
|
||||
}
|
||||
|
||||
pub const fn color(self) -> [u8; 4] {
|
||||
let rgb = match self {
|
||||
Self::Unavailable => [80, 80, 80],
|
||||
Self::Surface => [48, 190, 96],
|
||||
Self::Optical => [35, 198, 220],
|
||||
Self::Background => [25, 33, 46],
|
||||
Self::Emitter => [255, 232, 90],
|
||||
Self::UnsupportedMaterial => [229, 121, 42],
|
||||
Self::NestedMedium => [202, 82, 185],
|
||||
Self::DepthOfField => [143, 105, 223],
|
||||
Self::Volume => [116, 137, 168],
|
||||
Self::PathDepth => [196, 45, 63],
|
||||
Self::DegeneratePath => [145, 81, 49],
|
||||
Self::IncompatibleExit => [239, 103, 115],
|
||||
Self::TotalInternalReflection => [77, 108, 220],
|
||||
Self::FurtherTransmission => [173, 62, 228],
|
||||
Self::ReflectiveEndpoint => [235, 157, 190],
|
||||
Self::IdentityLimit => [178, 167, 98],
|
||||
Self::PartialCoverage => [229, 229, 229],
|
||||
};
|
||||
[rgb[0], rgb[1], rgb[2], 255]
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_counts<S: Serializer>(counts: &[u64; 17], serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut map = serializer.serialize_map(Some(DenoiseGuideClass::ALL.len()))?;
|
||||
for (class, count) in DenoiseGuideClass::ALL.iter().zip(counts) {
|
||||
map.serialize_entry(class, count)?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
|
||||
/// Counts describe the internal trace grid. The separate categorical PNG is
|
||||
/// resolved to the selected output size; it never averages class numbers.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct DenoiseDiagnostics {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
#[serde(serialize_with = "serialize_counts")]
|
||||
pub counts: [u64; 17],
|
||||
}
|
||||
|
||||
impl DenoiseDiagnostics {
|
||||
pub fn new(width: u32, height: u32) -> Self {
|
||||
Self { width, height, counts: [0; 17] }
|
||||
}
|
||||
|
||||
pub fn record(&mut self, class: DenoiseGuideClass) {
|
||||
if let Some(count) = self.counts.get_mut(class.code() as usize) {
|
||||
*count = count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pixels(&self, class: DenoiseGuideClass) -> u64 {
|
||||
self.counts.get(class.code() as usize).copied().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A bypass is never hidden by a neighbouring eligible subpixel. Otherwise
|
||||
/// mixed optical/surface guidance is labelled unavailable, not interpolated.
|
||||
pub fn resolve_denoise_class(classes: impl IntoIterator<Item = DenoiseGuideClass>) -> DenoiseGuideClass {
|
||||
let mut selected = None;
|
||||
let mut mixed = false;
|
||||
for class in classes {
|
||||
if !class.eligible() {
|
||||
return class;
|
||||
}
|
||||
if selected.is_some_and(|first| first != class) {
|
||||
mixed = true;
|
||||
}
|
||||
selected = Some(class);
|
||||
}
|
||||
if mixed { DenoiseGuideClass::Unavailable } else { selected.unwrap_or(DenoiseGuideClass::Unavailable) }
|
||||
}
|
||||
+405
-60
@@ -26,6 +26,25 @@ use super::admit;
|
||||
const MEMORY_LIMIT: u64 = 512 * 1024 * 1024;
|
||||
const QUEUE_LIMIT: usize = 2;
|
||||
|
||||
fn validate_image_profiles(descriptor: &ExecutorDescriptor) -> Result<()> {
|
||||
if descriptor.profiles.is_empty()
|
||||
|| descriptor.profiles.len() > 2
|
||||
|| descriptor.profiles.iter().any(|profile| {
|
||||
!matches!(profile.mode, RenderMode::Preview | RenderMode::Final)
|
||||
|| profile.operation != RenderOperation::Image
|
||||
|| profile.formats != [RenderFormat::Png]
|
||||
})
|
||||
|| descriptor
|
||||
.profiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(index, profile)| descriptor.profiles[..index].iter().any(|previous| previous.mode == profile.mode))
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "image worker requires unique Preview/Final Image/PNG profiles"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
struct CpuExecutor;
|
||||
#[cfg(test)]
|
||||
@@ -88,14 +107,22 @@ pub enum HostKind {
|
||||
struct Budget {
|
||||
used: AtomicU64,
|
||||
limit: u64,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
memory: Option<Arc<super::host_memory::NativeMemoryPool>>,
|
||||
}
|
||||
pub(crate) struct Reservation {
|
||||
budget: Arc<Budget>,
|
||||
bytes: u64,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
memory: Option<super::host_memory::MemoryLease>,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
fn reserve(self: &Arc<Self>, bytes: u64) -> Result<Reservation> {
|
||||
// The native process-wide lease is acquired first and rolls back if this
|
||||
// individual host's finite software pool subsequently refuses the job.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let memory = self.memory.as_ref().map(|pool| pool.reserve(bytes)).transpose()?;
|
||||
let mut used = self.used.load(Ordering::Acquire);
|
||||
loop {
|
||||
let next = used.checked_add(bytes).filter(|n| *n <= self.limit).ok_or_else(|| {
|
||||
@@ -106,7 +133,24 @@ impl Budget {
|
||||
Err(current) => used = current,
|
||||
}
|
||||
}
|
||||
Ok(Reservation { budget: self.clone(), bytes })
|
||||
Ok(Reservation {
|
||||
budget: self.clone(),
|
||||
bytes,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
memory,
|
||||
})
|
||||
}
|
||||
|
||||
fn memory_admission(&self, required: u64, credit: u64) -> Result<Option<super::HostMemoryAdmission>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.memory.as_ref().map(|pool| pool.check(required, credit)).transpose()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (required, credit);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Reservation {
|
||||
@@ -116,8 +160,15 @@ impl Reservation {
|
||||
if bytes > self.bytes {
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "preview exceeds its prepaid reservation"));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let memory = self.memory.as_mut().map(|lease| lease.split(bytes)).transpose()?;
|
||||
self.bytes -= bytes;
|
||||
Ok(Self { budget: self.budget.clone(), bytes })
|
||||
Ok(Self {
|
||||
budget: self.budget.clone(),
|
||||
bytes,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
memory,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return a lease to the same prepaid pool without reserving memory again.
|
||||
@@ -125,7 +176,14 @@ impl Reservation {
|
||||
if !Arc::ptr_eq(&self.budget, &other.budget) {
|
||||
return Err(JobError::new(ErrorCode::Internal, "checkpoint lease belongs to another pool"));
|
||||
}
|
||||
self.bytes = self.bytes.checked_add(other.bytes).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "checkpoint lease overflow"))?;
|
||||
let bytes = self.bytes.checked_add(other.bytes).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "checkpoint lease overflow"))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
match (&mut self.memory, &mut other.memory) {
|
||||
(Some(lease), Some(other)) => lease.merge_from(other)?,
|
||||
(None, None) => {}
|
||||
_ => return Err(JobError::new(ErrorCode::Internal, "checkpoint native memory lease mismatch")),
|
||||
}
|
||||
self.bytes = bytes;
|
||||
other.bytes = 0;
|
||||
Ok(())
|
||||
}
|
||||
@@ -134,6 +192,10 @@ impl Reservation {
|
||||
if bytes > self.bytes {
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "completed PNG exceeds its reservation"));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(memory) = &mut self.memory {
|
||||
memory.shrink(bytes)?;
|
||||
}
|
||||
self.budget.used.fetch_sub(self.bytes - bytes, Ordering::AcqRel);
|
||||
self.bytes = bytes;
|
||||
Ok(())
|
||||
@@ -243,7 +305,13 @@ mod light_union_lease_tests {
|
||||
}
|
||||
#[test]
|
||||
fn failed_union_metadata_reservation_releases_work_and_does_not_enqueue() {
|
||||
let host = CpuHost::for_platform().unwrap();
|
||||
let host = ImageHost(Arc::new(Owner {
|
||||
executor: None,
|
||||
shared: ImageHost::shared(MEMORY_LIMIT),
|
||||
kind: HostKind::NativeWorker,
|
||||
running: Mutex::new(None),
|
||||
_worker: None,
|
||||
}));
|
||||
let request = request().unwrap();
|
||||
let work = admit(&scene(), &request).unwrap().estimated_total_bytes;
|
||||
let fill = host.0.shared.budget.reserve(host.pool_limit_bytes() - work).unwrap();
|
||||
@@ -287,6 +355,15 @@ struct RetainedPresentation {
|
||||
_reservation: Reservation,
|
||||
}
|
||||
|
||||
/// Identity of the categorical guide image, independent of the colour PNG.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DenoiseMaskInfo {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub bytes: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
pub struct RenderFrame {
|
||||
// Derivatives point directly at the original, never form a retention chain.
|
||||
source_master: Option<Arc<RenderFrame>>,
|
||||
@@ -294,6 +371,7 @@ pub struct RenderFrame {
|
||||
pub info: FrameInfo,
|
||||
png: Vec<u8>,
|
||||
exr: Option<(Vec<u8>, LinearMasterInfo)>,
|
||||
denoise_mask: Option<(Vec<u8>, DenoiseMaskInfo)>,
|
||||
_reservation: Reservation,
|
||||
}
|
||||
impl RenderFrame {
|
||||
@@ -315,6 +393,15 @@ impl RenderFrame {
|
||||
pub fn linear_master_info(&self) -> Option<&LinearMasterInfo> {
|
||||
self.exr.as_ref().map(|(_, info)| info).or_else(|| self.source_master.as_ref().and_then(|source| source.linear_master_info()))
|
||||
}
|
||||
pub fn denoise_mask_png(&self) -> Option<&[u8]> {
|
||||
self.denoise_mask
|
||||
.as_ref()
|
||||
.map(|(bytes, _)| bytes.as_slice())
|
||||
.or_else(|| self.source_master.as_ref().and_then(|source| source.denoise_mask_png()))
|
||||
}
|
||||
pub fn denoise_mask_info(&self) -> Option<&DenoiseMaskInfo> {
|
||||
self.denoise_mask.as_ref().map(|(_, info)| info).or_else(|| self.source_master.as_ref().and_then(|source| source.denoise_mask_info()))
|
||||
}
|
||||
pub fn png(&self) -> &[u8] {
|
||||
&self.png
|
||||
}
|
||||
@@ -435,6 +522,10 @@ pub enum JobEnvironmentInfo {
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct JobStatus {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub denoise_diagnostics: Option<super::DenoiseDiagnostics>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub denoise_mask: Option<DenoiseMaskInfo>,
|
||||
/// Complete passes omitted only when both prepaid snapshots are externally retained.
|
||||
/// Final rendering continues; this is not a dropped sample or a stalled job.
|
||||
pub preview_retention_skips: u32,
|
||||
@@ -465,6 +556,7 @@ pub struct JobStatus {
|
||||
}
|
||||
|
||||
struct Data {
|
||||
denoise_diagnostics: Option<super::DenoiseDiagnostics>,
|
||||
preview_retention_skips: u32,
|
||||
completed_duration: Option<std::time::Duration>,
|
||||
punctual_diagnostics: Option<super::PunctualDiagnostics>,
|
||||
@@ -531,6 +623,8 @@ impl JobHandle {
|
||||
pub fn status(&self) -> Result<JobStatus> {
|
||||
let data = self.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
|
||||
Ok(JobStatus {
|
||||
denoise_diagnostics: data.denoise_diagnostics,
|
||||
denoise_mask: data.frame.as_ref().and_then(|frame| frame.denoise_mask_info().cloned()),
|
||||
preview_retention_skips: data.preview_retention_skips,
|
||||
preview_unavailable_reason: self.0.preview_unavailable_reason,
|
||||
punctual_diagnostics: data.punctual_diagnostics,
|
||||
@@ -745,6 +839,7 @@ pub struct PreparedSubmission {
|
||||
environment_info: Option<JobEnvironmentInfo>,
|
||||
textures: Option<super::textures::TextureSummary>,
|
||||
resolved: crate::ResolvedRequest,
|
||||
execution_descriptor: [u8; 32],
|
||||
admission: Admission,
|
||||
cancel: CancelToken,
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
@@ -754,6 +849,48 @@ pub struct PreparedSubmission {
|
||||
host: ImageHost,
|
||||
}
|
||||
impl PreparedSubmission {
|
||||
/// Preserve the requested image mode in queued/status metadata without
|
||||
/// changing its owning producer, captured scene, numerical options or proof.
|
||||
pub fn select_image_mode(&mut self, mode: RenderMode) -> Result<()> {
|
||||
self.check_cancel()?;
|
||||
if !matches!(mode, RenderMode::Preview | RenderMode::Final) {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "image mode must be Preview or Final"));
|
||||
}
|
||||
let mut descriptor = self.host.descriptor_for_request(self.request())?;
|
||||
for profile in &mut descriptor.profiles {
|
||||
profile.formats.sort_unstable();
|
||||
profile.features.sort_unstable();
|
||||
}
|
||||
descriptor.profiles.sort_unstable_by_key(|profile| (profile.mode, profile.operation));
|
||||
if capture_fingerprint(&descriptor)? != self.execution_descriptor {
|
||||
return Err(JobError::new(ErrorCode::HostUnavailable, "prepared image executor descriptor changed"));
|
||||
}
|
||||
if !descriptor.profiles.iter().any(|profile| profile == &self.resolved.profile) {
|
||||
return Err(JobError::new(ErrorCode::HostUnavailable, "prepared image executor profile changed"));
|
||||
}
|
||||
let mut backend = crate::builtin_backend();
|
||||
backend.id = self.resolved.backend.clone();
|
||||
backend.executors = vec![descriptor];
|
||||
let resolved = crate::resolve_request(
|
||||
&[backend],
|
||||
&crate::RenderRequest {
|
||||
backend: Some(self.resolved.backend.clone()),
|
||||
mode,
|
||||
operation: RenderOperation::Image,
|
||||
width: self.resolved.width,
|
||||
height: self.resolved.height,
|
||||
format: self.resolved.format,
|
||||
features: self.resolved.profile.features.clone(),
|
||||
policy: Default::default(),
|
||||
},
|
||||
)
|
||||
.map_err(|error| JobError::new(ErrorCode::InvalidRequest, error.to_string()))?;
|
||||
if resolved.executor != self.resolved.executor {
|
||||
return Err(JobError::new(ErrorCode::WrongOwner, "image mode changed the captured executor"));
|
||||
}
|
||||
self.resolved = resolved;
|
||||
Ok(())
|
||||
}
|
||||
/// Check a simultaneously retained metadata owner under the same unchanged
|
||||
/// per-job/profile cap. It is not funded by a Work/frame lease.
|
||||
pub fn check_retained_metadata(&self, bytes: u64) -> Result<()> {
|
||||
@@ -795,6 +932,8 @@ impl PreparedSubmission {
|
||||
struct Work {
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
proof: Vec<crate::raytrace::image::CertifiedOptics>,
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
cpu_workers: Option<u16>,
|
||||
regrade: Option<(Arc<RenderFrame>, super::presentation::Presentation)>,
|
||||
request: Option<ImageRequest>,
|
||||
executor: Option<Arc<dyn ImageExecutor>>,
|
||||
@@ -815,7 +954,8 @@ const _: [(); 1] = [(); (std::mem::size_of::<Work>() <= 4096) as usize];
|
||||
// this fixed state until publication; it is not another image task or pool.
|
||||
const PNG_HASH_CHUNK_BYTES: usize = 16_384;
|
||||
const PNG_HASH_CHUNKS_PER_ADVANCE: usize = 4;
|
||||
pub(crate) const FRAME_METADATA_BYTES: u64 = 1024;
|
||||
// Includes separate colour, linear-master and categorical-mask identities.
|
||||
pub(crate) const FRAME_METADATA_BYTES: u64 = 2048;
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
const PREPARED_PROOF_HEADER_BYTES: usize = std::mem::size_of::<Vec<crate::raytrace::image::CertifiedOptics>>();
|
||||
#[cfg(not(feature = "raytrace-cpu"))]
|
||||
@@ -835,6 +975,10 @@ struct PendingPng {
|
||||
preview_samples: Option<u32>,
|
||||
exr: Option<Vec<u8>>,
|
||||
exr_hashed: usize,
|
||||
exr_digest: Option<sha2::digest::Output<Sha256>>,
|
||||
denoise_mask: Option<Vec<u8>>,
|
||||
denoise_mask_hashed: usize,
|
||||
denoise_mask_digest: Option<sha2::digest::Output<Sha256>>,
|
||||
}
|
||||
// Keep the new fixed completion state within the existing metadata allowance
|
||||
// even if the hashing dependency's representation changes on another target.
|
||||
@@ -850,7 +994,19 @@ const _: [(); 1] = [(); (std::mem::size_of::<Running>()
|
||||
|
||||
impl PendingPng {
|
||||
fn new(png: Vec<u8>) -> Self {
|
||||
Self { png, digest: Sha256::new(), png_digest: None, hashed: 0, preview_samples: None, exr: None, exr_hashed: 0 }
|
||||
Self {
|
||||
png,
|
||||
digest: Sha256::new(),
|
||||
png_digest: None,
|
||||
hashed: 0,
|
||||
preview_samples: None,
|
||||
exr: None,
|
||||
exr_hashed: 0,
|
||||
exr_digest: None,
|
||||
denoise_mask: None,
|
||||
denoise_mask_hashed: 0,
|
||||
denoise_mask_digest: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn preview(png: Vec<u8>, samples: u32) -> Self {
|
||||
@@ -860,11 +1016,13 @@ impl PendingPng {
|
||||
fn step(&mut self, units: usize, cancel: &CancelToken) -> Result<bool> {
|
||||
cancel.check()?;
|
||||
for _ in 0..units.min(PNG_HASH_CHUNKS_PER_ADVANCE) {
|
||||
self.finish_png_digest();
|
||||
self.finish_digests();
|
||||
let (bytes, hashed) = if self.hashed < self.png.len() {
|
||||
(&self.png, &mut self.hashed)
|
||||
} else if let Some(exr) = self.exr.as_ref().filter(|exr| self.exr_hashed < exr.len()) {
|
||||
(exr, &mut self.exr_hashed)
|
||||
} else if let Some(mask) = self.denoise_mask.as_ref().filter(|mask| self.denoise_mask_hashed < mask.len()) {
|
||||
(mask, &mut self.denoise_mask_hashed)
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
@@ -873,25 +1031,66 @@ impl PendingPng {
|
||||
*hashed = end;
|
||||
cancel.check()?;
|
||||
}
|
||||
self.finish_png_digest();
|
||||
Ok(self.hashed == self.png.len() && self.exr.as_ref().is_none_or(|exr| self.exr_hashed == exr.len()))
|
||||
self.finish_digests();
|
||||
Ok(self.hashed == self.png.len()
|
||||
&& self.exr.as_ref().is_none_or(|exr| self.exr_hashed == exr.len())
|
||||
&& self.denoise_mask.as_ref().is_none_or(|mask| self.denoise_mask_hashed == mask.len()))
|
||||
}
|
||||
fn finish_png_digest(&mut self) {
|
||||
fn finish_digests(&mut self) {
|
||||
if self.hashed == self.png.len() && self.png_digest.is_none() {
|
||||
// PNG and EXR are scanned sequentially. Keep only its fixed digest
|
||||
// while resetting the same SHA state for the optional raw companion.
|
||||
self.png_digest = Some(self.digest.finalize_reset());
|
||||
}
|
||||
if self.png_digest.is_some() && self.exr.as_ref().is_some_and(|exr| self.exr_hashed == exr.len()) && self.exr_digest.is_none() {
|
||||
self.exr_digest = Some(self.digest.finalize_reset());
|
||||
}
|
||||
if self.png_digest.is_some()
|
||||
&& (self.exr.is_none() || self.exr_digest.is_some())
|
||||
&& self.denoise_mask.as_ref().is_some_and(|mask| self.denoise_mask_hashed == mask.len())
|
||||
&& self.denoise_mask_digest.is_none()
|
||||
{
|
||||
self.denoise_mask_digest = Some(self.digest.finalize_reset());
|
||||
}
|
||||
}
|
||||
fn progress(&self) -> Progress {
|
||||
Progress {
|
||||
phase: Phase::Encoding,
|
||||
done: self.hashed as u64 + self.exr_hashed as u64,
|
||||
total: self.png.len() as u64 + self.exr.as_ref().map_or(0, |bytes| bytes.len() as u64),
|
||||
done: self.hashed as u64 + self.exr_hashed as u64 + self.denoise_mask_hashed as u64,
|
||||
total: self.png.len() as u64
|
||||
+ self.exr.as_ref().map_or(0, |bytes| bytes.len() as u64)
|
||||
+ self.denoise_mask.as_ref().map_or(0, |bytes| bytes.len() as u64),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_denoise_mask(bytes: &Vec<u8>, request: &ImageRequest, scale: u32, report: Option<super::DenoiseDiagnostics>) -> Result<()> {
|
||||
// Native CPU Workstation requests retain their earlier dimension envelope;
|
||||
// the producer has already admitted it. X2 uses the shared 8K output cap.
|
||||
let (trace_width, trace_height) =
|
||||
if scale == 1 { (request.width, request.height) } else { super::supersampled_dimensions(request.width, request.height, scale)? };
|
||||
let report = report.ok_or_else(|| JobError::new(ErrorCode::Internal, "guide mask has no classification report"))?;
|
||||
let total = report
|
||||
.counts
|
||||
.iter()
|
||||
.try_fold(0_u64, |sum, count| sum.checked_add(*count))
|
||||
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "guide classification count overflow"))?;
|
||||
if (report.width, report.height) != (trace_width, trace_height) || total != u64::from(trace_width) * u64::from(trace_height) {
|
||||
return Err(JobError::new(ErrorCode::Internal, "guide classification counts differ from the admitted trace grid"));
|
||||
}
|
||||
let limit = u64::from(request.width)
|
||||
.checked_mul(u64::from(request.height))
|
||||
.and_then(|pixels| pixels.checked_mul(8))
|
||||
.and_then(|bytes| bytes.checked_add(65_536))
|
||||
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "guide PNG bound overflow"))?;
|
||||
if bytes.capacity() as u64 > limit
|
||||
|| bytes.get(..16) != Some(&[137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82])
|
||||
|| bytes.get(16..20) != Some(request.width.to_be_bytes().as_slice())
|
||||
|| bytes.get(20..24) != Some(request.height.to_be_bytes().as_slice())
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "guide mask exceeds its bound or has invalid PNG dimensions"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Running {
|
||||
source_master: Option<Arc<RenderFrame>>,
|
||||
task: Option<WorkTask>,
|
||||
@@ -967,6 +1166,23 @@ enum WorkPoll {
|
||||
}
|
||||
|
||||
impl WorkTask {
|
||||
fn denoise_diagnostics(&self) -> Option<super::DenoiseDiagnostics> {
|
||||
match self {
|
||||
Self::Registered(task) => task.denoise_diagnostics(),
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
Self::Raytrace(slot) => slot.0.first().and_then(|task| task.denoise_diagnostics()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn take_denoise_mask(&mut self) -> Option<Vec<u8>> {
|
||||
match self {
|
||||
Self::Registered(task) => task.take_denoise_mask(),
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
Self::Raytrace(slot) => slot.0.first_mut().and_then(|task| task.take_denoise_mask()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn punctual_diagnostics(&self) -> Option<super::PunctualDiagnostics> {
|
||||
match self {
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
@@ -1006,6 +1222,7 @@ impl WorkTask {
|
||||
cancel: CancelToken,
|
||||
environment: Option<crate::lighting::environment_input::EnvironmentInput>,
|
||||
#[cfg(feature = "raytrace-cpu")] proof: Option<crate::raytrace::image::CertifiedOptics>,
|
||||
#[cfg(feature = "raytrace-cpu")] cpu_workers: Option<u16>,
|
||||
) -> Result<Self> {
|
||||
let _ = &environment;
|
||||
match request.integrator {
|
||||
@@ -1014,7 +1231,8 @@ impl WorkTask {
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
{
|
||||
let options = request.raytrace.clone().unwrap_or_default();
|
||||
crate::raytrace::image::ImageTask::new_prepared(scene, request, cancel, options, environment, proof)
|
||||
let workers = cpu_workers.ok_or_else(|| JobError::new(ErrorCode::Internal, "CPU work is missing its admitted worker count"))?;
|
||||
crate::raytrace::image::ImageTask::new_prepared_with_workers(scene, request, cancel, options, environment, proof, workers)
|
||||
.and_then(TaskSlot::new)
|
||||
.map(Self::Raytrace)
|
||||
}
|
||||
@@ -1131,6 +1349,8 @@ impl Running {
|
||||
work.environment,
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
work.proof.pop(),
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
work.cpu_workers,
|
||||
)
|
||||
}
|
||||
}))
|
||||
@@ -1163,6 +1383,9 @@ impl Running {
|
||||
let result = self.task.as_mut().ok_or_else(|| JobError::new(ErrorCode::Internal, "missing render task"))?.step(units)?;
|
||||
let mut data = self.handle.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
|
||||
data.punctual_diagnostics = self.task.as_ref().and_then(WorkTask::punctual_diagnostics);
|
||||
if let Some(diagnostics) = self.task.as_ref().and_then(WorkTask::denoise_diagnostics) {
|
||||
data.denoise_diagnostics = Some(diagnostics);
|
||||
}
|
||||
// The final publication check is under the same lock as cancellation.
|
||||
if self.handle.0.cancel.is_cancelled() {
|
||||
data.progress.phase = Phase::Cancelled;
|
||||
@@ -1199,9 +1422,25 @@ impl Running {
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "linear master exceeds its bound or has an invalid signature"));
|
||||
}
|
||||
}
|
||||
let denoise_mask = self.task.as_mut().and_then(WorkTask::take_denoise_mask);
|
||||
let options = self.handle.0.options()?;
|
||||
let (collect_guides, supersampling) = self
|
||||
.handle
|
||||
.0
|
||||
.request
|
||||
.raytrace
|
||||
.as_ref()
|
||||
.map_or((options.denoise_diagnostics, options.supersampling), |cpu| (cpu.denoise_diagnostics, cpu.supersampling));
|
||||
if denoise_mask.is_some() != collect_guides {
|
||||
return Err(JobError::new(ErrorCode::Internal, "guide mask availability differs from the admitted request"));
|
||||
}
|
||||
if let Some(mask) = &denoise_mask {
|
||||
validate_denoise_mask(mask, &self.handle.0.request, supersampling, data.denoise_diagnostics)?;
|
||||
}
|
||||
self.task = None;
|
||||
let mut pending = PendingPng::new(png);
|
||||
pending.exr = exr;
|
||||
pending.denoise_mask = denoise_mask;
|
||||
data.progress = pending.progress();
|
||||
self.pending_png = Some(pending);
|
||||
Ok(true)
|
||||
@@ -1227,7 +1466,7 @@ impl Running {
|
||||
data.progress = progress;
|
||||
return Ok(true);
|
||||
}
|
||||
let PendingPng { png, digest, png_digest, preview_samples, exr, .. } =
|
||||
let PendingPng { png, png_digest, preview_samples, exr, exr_digest, denoise_mask, denoise_mask_digest, .. } =
|
||||
self.pending_png.take().ok_or_else(|| JobError::new(ErrorCode::Internal, "missing completed PNG digest"))?;
|
||||
let info = FrameInfo {
|
||||
job: self.handle.0.id,
|
||||
@@ -1250,7 +1489,7 @@ impl Running {
|
||||
// the checkpoint's own metadata allowance as well as its PNG capacity.
|
||||
let checkpoint_metadata = if preview_samples.is_some() { std::mem::size_of::<CheckpointCredits>() + 32 } else { 0 };
|
||||
let fixed_metadata = std::mem::size_of::<Running>() + frame_layout + checkpoint_metadata + 2 * crate::MAX_RENDER_ID_BYTES + 32;
|
||||
let linear_digest_bytes = if exr.is_some() { 64 } else { 0 };
|
||||
let linear_digest_bytes = (if exr.is_some() { 64 } else { 0 }) + (if denoise_mask.is_some() { 64 } else { 0 });
|
||||
if fixed_metadata
|
||||
.checked_add(info.png_sha256.capacity())
|
||||
.and_then(|n| n.checked_add(linear_digest_bytes))
|
||||
@@ -1337,16 +1576,33 @@ impl Running {
|
||||
self.handle.0.notify();
|
||||
return Ok(true);
|
||||
}
|
||||
let exr = exr.map(|bytes| {
|
||||
let info = LinearMasterInfo {
|
||||
bytes: bytes.len() as u64,
|
||||
sha256: format!("{:x}", digest.finalize()),
|
||||
encoding: LinearColourEncoding::SceneLinearSrgbD65,
|
||||
alpha: LinearAlphaConvention::CoveragePremultiplied,
|
||||
};
|
||||
(bytes, info)
|
||||
});
|
||||
let retained = png.capacity() as u64 + exr.as_ref().map_or(0, |(bytes, _)| bytes.capacity() as u64) + FRAME_METADATA_BYTES;
|
||||
let exr = exr
|
||||
.map(|bytes| -> Result<_> {
|
||||
let info = LinearMasterInfo {
|
||||
bytes: bytes.len() as u64,
|
||||
sha256: format!("{:x}", exr_digest.ok_or_else(|| JobError::new(ErrorCode::Internal, "missing linear master digest"))?),
|
||||
encoding: LinearColourEncoding::SceneLinearSrgbD65,
|
||||
alpha: LinearAlphaConvention::CoveragePremultiplied,
|
||||
};
|
||||
Ok((bytes, info))
|
||||
})
|
||||
.transpose()?;
|
||||
let denoise_mask = denoise_mask
|
||||
.map(|bytes| -> Result<_> {
|
||||
let mask_info = DenoiseMaskInfo {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
bytes: bytes.len() as u64,
|
||||
sha256: format!("{:x}", denoise_mask_digest.ok_or_else(|| JobError::new(ErrorCode::Internal, "missing guide mask digest"))?),
|
||||
};
|
||||
Ok((bytes, mask_info))
|
||||
})
|
||||
.transpose()?;
|
||||
let retained = (png.capacity() as u64)
|
||||
.checked_add(exr.as_ref().map_or(0, |(bytes, _)| bytes.capacity() as u64))
|
||||
.and_then(|bytes| bytes.checked_add(denoise_mask.as_ref().map_or(0, |(bytes, _)| bytes.capacity() as u64)))
|
||||
.and_then(|bytes| bytes.checked_add(FRAME_METADATA_BYTES))
|
||||
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "retained image byte overflow"))?;
|
||||
if let Some(credits) = &self.checkpoints {
|
||||
credits.close();
|
||||
}
|
||||
@@ -1356,6 +1612,7 @@ impl Running {
|
||||
info,
|
||||
png,
|
||||
exr,
|
||||
denoise_mask,
|
||||
source_master: self.source_master.take(),
|
||||
presentation: self.handle.0.presentation.clone(),
|
||||
_reservation: reservation,
|
||||
@@ -1435,11 +1692,24 @@ pub type CpuHost = ImageHost;
|
||||
|
||||
impl ImageHost {
|
||||
fn shared(limit: u64) -> Arc<Shared> {
|
||||
Self::shared_with_memory(
|
||||
limit,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn shared_with_memory(limit: u64, #[cfg(not(target_arch = "wasm32"))] memory: Option<Arc<super::host_memory::NativeMemoryPool>>) -> Arc<Shared> {
|
||||
Arc::new(Shared {
|
||||
queue: Mutex::new(VecDeque::new()),
|
||||
active: Mutex::new(None),
|
||||
stop: AtomicBool::new(false),
|
||||
budget: Arc::new(Budget { used: AtomicU64::new(0), limit }),
|
||||
budget: Arc::new(Budget {
|
||||
used: AtomicU64::new(0),
|
||||
limit,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
memory,
|
||||
}),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
wake: Condvar::new(),
|
||||
})
|
||||
@@ -1477,7 +1747,7 @@ impl ImageHost {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let pool = if usize::BITS >= 64 { super::NATIVE_CPU_POOL_BYTES } else { MEMORY_LIMIT };
|
||||
let shared = Self::shared(pool);
|
||||
let shared = Self::shared_with_memory(pool, Some(super::host_memory::shared_native_memory()));
|
||||
let worker_shared = shared.clone();
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("solidcraft-render".into())
|
||||
@@ -1491,20 +1761,13 @@ impl ImageHost {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn with_executor(executor: Arc<dyn ImageExecutor>) -> Result<Self> {
|
||||
let descriptor = executor.descriptor()?;
|
||||
if descriptor.profiles.len() != 1
|
||||
|| descriptor
|
||||
.profiles
|
||||
.iter()
|
||||
.any(|p| p.mode != RenderMode::Preview || p.operation != RenderOperation::Image || p.formats != [RenderFormat::Png])
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "image worker currently accepts one Preview/Image/PNG profile"));
|
||||
}
|
||||
validate_image_profiles(&descriptor)?;
|
||||
let limit = executor.memory_limit_bytes();
|
||||
let ceiling = if usize::BITS >= 64 { super::NATIVE_CPU_POOL_BYTES } else { MEMORY_LIMIT };
|
||||
if limit == 0 || limit > ceiling {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "registered image pool exceeds supported address space"));
|
||||
}
|
||||
let shared = Self::shared(limit);
|
||||
let shared = Self::shared_with_memory(limit, Some(super::host_memory::shared_native_memory()));
|
||||
let worker_shared = shared.clone();
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("solidcraft-render".into())
|
||||
@@ -1568,7 +1831,7 @@ impl ImageHost {
|
||||
budget.validate_execution()?;
|
||||
}
|
||||
let descriptor = self.descriptor_for(profile.integrator())?;
|
||||
if descriptor.availability != Availability::Available || descriptor.profiles.len() != 1 {
|
||||
if descriptor.availability != Availability::Available || descriptor.profiles.is_empty() || descriptor.profiles.len() > 2 {
|
||||
return Err(JobError::new(ErrorCode::HostUnavailable, "capture executor is unavailable"));
|
||||
}
|
||||
Ok(descriptor)
|
||||
@@ -1593,7 +1856,8 @@ impl ImageHost {
|
||||
profile.formats.sort_unstable();
|
||||
profile.features.sort_unstable();
|
||||
}
|
||||
let execution = capture_fingerprint(&(&descriptor.id, descriptor.profiles.first()))?;
|
||||
descriptor.profiles.sort_unstable_by_key(|profile| (profile.mode, profile.operation));
|
||||
let execution = capture_fingerprint(&(&descriptor.id, &descriptor.profiles))?;
|
||||
let reservation = self.0.shared.budget.reserve(bytes)?;
|
||||
Ok(CaptureLease {
|
||||
host: self.clone(),
|
||||
@@ -1610,7 +1874,15 @@ impl ImageHost {
|
||||
/// Strict blocking API. Native UI callers prepare beside the window. The
|
||||
/// temporary lease exists before any new box proof backing is allocated.
|
||||
pub fn admit(&self, scene: &ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission> {
|
||||
let admission = self.admit_layout(scene, request, options)?;
|
||||
let mut admission = self.admit_layout(scene, request, options)?;
|
||||
let option_bytes = options.retained_bytes()?;
|
||||
let required = admission
|
||||
.estimated_total_bytes
|
||||
.checked_add(retained_request_bytes(request)?)
|
||||
.and_then(|bytes| bytes.checked_add(request.retained_resource_metadata_bytes()))
|
||||
.and_then(|bytes| bytes.checked_add(option_bytes))
|
||||
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "native preflight byte count overflow"))?;
|
||||
admission.host_memory = self.0.shared.budget.memory_admission(required, 0)?;
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
if self.0.executor.is_none() && request.integrator == Integrator::Raytrace {
|
||||
let defaults = super::RaytraceOptions::default();
|
||||
@@ -1642,7 +1914,7 @@ impl ImageHost {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "registered executor does not implement the authored advanced Glass models"));
|
||||
}
|
||||
if options.caustics.is_some() && !executor.supports_caustics() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "registered executor does not implement delta-light caustic photons"));
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "registered executor does not implement caustic photons"));
|
||||
}
|
||||
if request.raytrace.is_some() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "registered executors do not accept CPU raytrace options"));
|
||||
@@ -1665,6 +1937,15 @@ impl ImageHost {
|
||||
if options.transmission_samples != 1 && !executor.supports_transmission_sampling() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "registered executor does not implement transmission strata"));
|
||||
}
|
||||
if options.supersampling != 1 && !executor.supports_supersampling() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "this executor does not support supersampling"));
|
||||
}
|
||||
if !options.denoise_settings.is_default() && !executor.supports_denoise_controls() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "this executor does not support denoising settings"));
|
||||
}
|
||||
if options.denoise_diagnostics && !executor.supports_denoise_diagnostics() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "this executor does not support denoising guide diagnostics"));
|
||||
}
|
||||
if options.linear_exr && !executor.supports_linear_exr() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "this executor cannot retain a raw linear EXR"));
|
||||
}
|
||||
@@ -1768,18 +2049,25 @@ impl ImageHost {
|
||||
} else {
|
||||
Availability::Available
|
||||
},
|
||||
profiles: vec![RenderProfile {
|
||||
mode: RenderMode::Preview,
|
||||
operation: RenderOperation::Image,
|
||||
formats: vec![RenderFormat::Png],
|
||||
features: if integrator == Integrator::Raytrace && cfg!(feature = "raytrace-cpu") && self.kind() == HostKind::NativeWorker {
|
||||
// Refraction is the certified thin-sheet/axis-aligned-box CPU preview
|
||||
// contract. Admission still refuses unsupported geometry and combinations.
|
||||
vec![crate::RenderFeature::Pbr, crate::RenderFeature::Hdri, crate::RenderFeature::Refraction]
|
||||
} else {
|
||||
vec![crate::RenderFeature::Pbr]
|
||||
},
|
||||
}],
|
||||
profiles: [RenderMode::Preview, RenderMode::Final]
|
||||
.into_iter()
|
||||
.filter(|mode| {
|
||||
*mode == RenderMode::Preview
|
||||
|| (integrator == Integrator::Raytrace && cfg!(feature = "raytrace-cpu") && self.kind() == HostKind::NativeWorker)
|
||||
})
|
||||
.map(|mode| RenderProfile {
|
||||
mode,
|
||||
operation: RenderOperation::Image,
|
||||
formats: vec![RenderFormat::Png],
|
||||
features: if integrator == Integrator::Raytrace && cfg!(feature = "raytrace-cpu") && self.kind() == HostKind::NativeWorker {
|
||||
// Admission certifies the same physical producer and combinations
|
||||
// for both image modes. Numerical quality remains authored intent.
|
||||
vec![crate::RenderFeature::Pbr, crate::RenderFeature::Hdri, crate::RenderFeature::Refraction]
|
||||
} else {
|
||||
vec![crate::RenderFeature::Pbr]
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
limits: if integrator == Integrator::Raytrace && self.kind() == HostKind::NativeWorker && usize::BITS >= 64 {
|
||||
super::CpuBudget::Workstation.image_limits(false)
|
||||
} else {
|
||||
@@ -1939,8 +2227,12 @@ impl ImageHost {
|
||||
}
|
||||
let mut admission = self.admit_layout(&scene, &request, options)?;
|
||||
let estimated_total_bytes = admission.estimated_total_bytes;
|
||||
let environment_bytes =
|
||||
environment.as_ref().map_or(Ok(0), |e| e.required_bytes()).map_err(|e| JobError::new(ErrorCode::InvalidRequest, e.to_string()))?;
|
||||
let environment_bytes = environment
|
||||
.as_ref()
|
||||
.map_or(Ok(0), |e| {
|
||||
e.required_bytes_with_importance(request.raytrace.as_ref().is_some_and(|options| options.environment_importance_sampling))
|
||||
})
|
||||
.map_err(|e| JobError::new(ErrorCode::InvalidRequest, e.to_string()))?;
|
||||
let estimated_total_bytes = estimated_total_bytes
|
||||
.checked_add(environment_bytes)
|
||||
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "environment admission overflow"))?;
|
||||
@@ -1949,20 +2241,29 @@ impl ImageHost {
|
||||
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "retained preparation byte overflow"))?;
|
||||
self.check_request_sum(estimated_total_bytes, simultaneous, request.cpu_budget())?;
|
||||
let mut backend = crate::builtin_backend();
|
||||
let descriptor = self.descriptor_for_request(&request)?;
|
||||
let mut descriptor = self.descriptor_for_request(&request)?;
|
||||
if let Some(credit) = capture.as_deref()
|
||||
&& credit.profile == CaptureProfile::Registered
|
||||
&& capture_fingerprint(&descriptor)? != credit.descriptor
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::HostUnavailable, "resolved capture executor metadata changed"));
|
||||
}
|
||||
for profile in &mut descriptor.profiles {
|
||||
profile.formats.sort_unstable();
|
||||
profile.features.sort_unstable();
|
||||
}
|
||||
descriptor.profiles.sort_unstable_by_key(|profile| (profile.mode, profile.operation));
|
||||
let execution = capture_fingerprint(&(&descriptor.id, &descriptor.profiles))?;
|
||||
let execution_descriptor = capture_fingerprint(&descriptor)?;
|
||||
let initial_mode =
|
||||
if descriptor.profiles.iter().any(|profile| profile.mode == RenderMode::Preview) { RenderMode::Preview } else { RenderMode::Final };
|
||||
let expected_executor = descriptor.id.clone();
|
||||
backend.executors = vec![descriptor];
|
||||
let resolved = crate::resolve_request(
|
||||
&[backend],
|
||||
&crate::RenderRequest {
|
||||
backend: None,
|
||||
mode: RenderMode::Preview,
|
||||
mode: initial_mode,
|
||||
operation: RenderOperation::Image,
|
||||
width: u64::from(request.width),
|
||||
height: u64::from(request.height),
|
||||
@@ -1986,7 +2287,7 @@ impl ImageHost {
|
||||
.checked_add(request_bytes)
|
||||
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "prepared work/request byte overflow"))?;
|
||||
if let Some(credit) = capture.as_deref()
|
||||
&& capture_fingerprint(&(&resolved.executor, Some(&resolved.profile)))? != credit.execution
|
||||
&& execution != credit.execution
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::WrongOwner, "resolved capture executor/profile changed"));
|
||||
}
|
||||
@@ -2032,6 +2333,7 @@ impl ImageHost {
|
||||
environment_info,
|
||||
textures,
|
||||
resolved,
|
||||
execution_descriptor,
|
||||
admission,
|
||||
cancel,
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
@@ -2061,8 +2363,17 @@ impl ImageHost {
|
||||
prepared.proof.push(certified);
|
||||
}
|
||||
}
|
||||
if let Some(executor) = &self.0.executor {
|
||||
executor.validate_prepared(&prepared.scene, prepared.request(), options, &prepared.cancel)?;
|
||||
}
|
||||
prepared.cancel.check()?;
|
||||
prepared.options = if options.is_structural_default() { None } else { Some(RetainedOptions::new(options)?) };
|
||||
let held = prepared
|
||||
.reservation
|
||||
.bytes
|
||||
.checked_add(prepared.request_reservation.as_ref().map_or(0, |lease| lease.bytes))
|
||||
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "prepared native memory receipt overflow"))?;
|
||||
prepared.admission.host_memory = self.0.shared.budget.memory_admission(held, held)?;
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
@@ -2105,6 +2416,7 @@ impl ImageHost {
|
||||
environment_info,
|
||||
textures,
|
||||
resolved,
|
||||
execution_descriptor: _,
|
||||
admission,
|
||||
cancel,
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
@@ -2128,6 +2440,7 @@ impl ImageHost {
|
||||
resolved,
|
||||
cancel,
|
||||
data: Mutex::new(Data {
|
||||
denoise_diagnostics: None,
|
||||
preview_retention_skips: 0,
|
||||
completed_duration: None,
|
||||
punctual_diagnostics: None,
|
||||
@@ -2144,6 +2457,8 @@ impl ImageHost {
|
||||
regrade: None,
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
proof,
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
cpu_workers: admission.cpu_workers,
|
||||
request: work_request,
|
||||
executor: self.0.executor.clone(),
|
||||
scene,
|
||||
@@ -2166,6 +2481,7 @@ impl ImageHost {
|
||||
return Err(JobError::new(ErrorCode::WrongOwner, "regrade requires a new identity in the source document session"));
|
||||
}
|
||||
let frame = source.frame()?.ok_or_else(|| JobError::new(ErrorCode::NotCompleted, "regrade requires a completed frame"))?;
|
||||
let denoise_diagnostics = source.status()?.denoise_diagnostics;
|
||||
let master = frame.master_source()?;
|
||||
let bytes = super::regrade::required_bytes(&master, presentation)?;
|
||||
let raw = master.linear_master_info().ok_or_else(|| JobError::new(ErrorCode::InvalidRequest, "raw master metadata is missing"))?;
|
||||
@@ -2259,6 +2575,7 @@ impl ImageHost {
|
||||
resolved,
|
||||
cancel: CancelToken::default(),
|
||||
data: Mutex::new(Data {
|
||||
denoise_diagnostics,
|
||||
preview_retention_skips: 0,
|
||||
completed_duration: None,
|
||||
punctual_diagnostics: None,
|
||||
@@ -2274,6 +2591,8 @@ impl ImageHost {
|
||||
queue.push_back(Work {
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
proof: Vec::new(),
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
cpu_workers: None,
|
||||
regrade: Some((master, presentation)),
|
||||
request: None,
|
||||
executor: None,
|
||||
@@ -2579,7 +2898,12 @@ mod retained_request_tests {
|
||||
+ 32 * std::mem::size_of::<super::super::SurfaceEmission>() as u64
|
||||
+ 256;
|
||||
assert_eq!(retained_request_bytes(&request).unwrap(), expected);
|
||||
let budget = Arc::new(Budget { used: AtomicU64::new(0), limit: MEMORY_LIMIT });
|
||||
let budget = Arc::new(Budget {
|
||||
used: AtomicU64::new(0),
|
||||
limit: MEMORY_LIMIT,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
memory: None,
|
||||
});
|
||||
let lease = budget.reserve(expected).unwrap();
|
||||
assert_eq!(budget.used.load(Ordering::Acquire), expected);
|
||||
assert!(admit_request_sum(super::super::image::MAX_JOB_BYTES - expected, expected).is_ok());
|
||||
@@ -3039,3 +3363,24 @@ mod surface_request_metadata_tests {
|
||||
JobId { session: owner, serial, generation: serial }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn completed_colour_linear_and_guide_images_have_independent_incremental_digests() {
|
||||
let mut pending = PendingPng::new(b"display".to_vec());
|
||||
pending.exr = Some(b"linear".to_vec());
|
||||
pending.denoise_mask = Some(b"mask".to_vec());
|
||||
let cancel = CancelToken::default();
|
||||
assert!(!pending.step(1, &cancel).unwrap());
|
||||
assert_eq!(pending.progress().done, 7);
|
||||
assert!(!pending.step(1, &cancel).unwrap());
|
||||
assert_eq!(pending.progress().done, 13);
|
||||
assert!(pending.step(1, &cancel).unwrap());
|
||||
assert_eq!(pending.progress().done, pending.progress().total);
|
||||
assert_eq!(format!("{:x}", pending.png_digest.unwrap()), "dfbb889cf19bda82ed8deadf82de232979aa4ecc83238625c6453dc4c597b43a");
|
||||
assert_eq!(format!("{:x}", pending.exr_digest.unwrap()), "7f2fe580edb35154041fa3d4b41dd6d3adaef0c85d2ff6309f1d4b520eeecda3");
|
||||
assert_eq!(format!("{:x}", pending.denoise_mask_digest.unwrap()), "48bf9b4f142ab45cac6f33cf99415bca6ca8e07608a031237aa76560fec2204b");
|
||||
let cancelled = CancelToken::default();
|
||||
cancelled.cancel();
|
||||
assert!(pending.step(1, &cancelled).is_err());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! A shared native RAM allowance for all image hosts. The OS capacity snapshot
|
||||
//! stays fixed while leases exist, so resident buffers are not charged twice.
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HostMemorySource {
|
||||
SystemAvailable,
|
||||
SystemAndCgroup,
|
||||
ConservativeFallback,
|
||||
}
|
||||
|
||||
/// Point-in-time admission against the process-wide native render allowance.
|
||||
/// Required bytes include this request's retained metadata and capture credit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct HostMemoryAdmission {
|
||||
/// This request's total debit, including any already-held capture credit.
|
||||
pub required_bytes: u64,
|
||||
/// Other held leases in the shared native render ledger.
|
||||
pub admitted_bytes: u64,
|
||||
/// Capacity available to this request before subtracting required_bytes.
|
||||
pub available_bytes: u64,
|
||||
/// The idle-time RAM snapshot, bounded by the finite software ceiling.
|
||||
pub capacity_bytes: u64,
|
||||
pub source: HostMemorySource,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native {
|
||||
use super::{HostMemoryAdmission, HostMemorySource};
|
||||
use crate::job::{ErrorCode, JobError, NATIVE_CPU_POOL_BYTES, Result};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
const FALLBACK_BYTES: u64 = 512 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct MemorySnapshot {
|
||||
pub(crate) capacity_bytes: u64,
|
||||
pub(crate) source: HostMemorySource,
|
||||
}
|
||||
impl MemorySnapshot {
|
||||
pub(crate) fn from_probe(total: u64, available: u64, cgroup: Option<u64>) -> Self {
|
||||
if total == 0 {
|
||||
return Self { capacity_bytes: FALLBACK_BYTES.min(cgroup.unwrap_or(u64::MAX)), source: HostMemorySource::ConservativeFallback };
|
||||
}
|
||||
let capacity_bytes = available.min(total).min(cgroup.unwrap_or(u64::MAX)).min(NATIVE_CPU_POOL_BYTES);
|
||||
Self { capacity_bytes, source: if cgroup.is_some() { HostMemorySource::SystemAndCgroup } else { HostMemorySource::SystemAvailable } }
|
||||
}
|
||||
}
|
||||
|
||||
fn system_snapshot() -> MemorySnapshot {
|
||||
let Ok(mut system) = sysinfo::System::new() else {
|
||||
return MemorySnapshot::from_probe(0, 0, None);
|
||||
};
|
||||
system.refresh_memory_specifics(sysinfo::MemoryRefreshKind::nothing().with_ram());
|
||||
// Linux service/container ceilings can be below the root cgroup's limit.
|
||||
// Refresh only our process header, without task, CPU, disk or environment data.
|
||||
#[cfg(target_os = "linux")]
|
||||
let cgroup = {
|
||||
let pid = sysinfo::Pid::from_u32(std::process::id());
|
||||
system.refresh_processes_specifics(sysinfo::ProcessesToUpdate::Some(&[pid]), false, sysinfo::ProcessRefreshKind::nothing());
|
||||
system.process(pid).and_then(sysinfo::Process::cgroup_limits).or_else(|| system.cgroup_limits())
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let cgroup = system.cgroup_limits();
|
||||
MemorySnapshot::from_probe(system.total_memory(), system.available_memory(), cgroup.map(|limits| limits.free_memory.min(limits.total_memory)))
|
||||
}
|
||||
|
||||
struct State {
|
||||
snapshot: MemorySnapshot,
|
||||
used: u64,
|
||||
invalid: bool,
|
||||
#[cfg(test)]
|
||||
injected: Option<MemorySnapshot>,
|
||||
}
|
||||
impl State {
|
||||
fn refresh_if_idle(&mut self) {
|
||||
if self.used != 0 || self.invalid {
|
||||
return;
|
||||
}
|
||||
#[cfg(test)]
|
||||
if let Some(snapshot) = self.injected {
|
||||
self.snapshot = snapshot;
|
||||
return;
|
||||
}
|
||||
self.snapshot = system_snapshot();
|
||||
}
|
||||
|
||||
fn receipt(&self, required_bytes: u64, credit: u64) -> Result<HostMemoryAdmission> {
|
||||
if self.invalid {
|
||||
return Err(JobError::new(ErrorCode::Internal, "native render memory ledger is inconsistent"));
|
||||
}
|
||||
let admitted_bytes = self
|
||||
.used
|
||||
.checked_sub(credit)
|
||||
.ok_or_else(|| JobError::new(ErrorCode::Internal, "native memory credit exceeds the held render leases"))?;
|
||||
let capacity_bytes = self.snapshot.capacity_bytes;
|
||||
let available_bytes = capacity_bytes.saturating_sub(admitted_bytes);
|
||||
if required_bytes > available_bytes {
|
||||
let source = match self.snapshot.source {
|
||||
HostMemorySource::SystemAvailable => "system available RAM",
|
||||
HostMemorySource::SystemAndCgroup => "system and cgroup available RAM",
|
||||
HostMemorySource::ConservativeFallback => "512 MiB fallback because the RAM query was unavailable",
|
||||
};
|
||||
return Err(JobError::new(
|
||||
ErrorCode::LimitExceeded,
|
||||
format!(
|
||||
"native render memory needs {required_bytes} additional bytes; {admitted_bytes} bytes already admitted, \
|
||||
{available_bytes} bytes available from the {capacity_bytes}-byte {source} snapshot"
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(HostMemoryAdmission { required_bytes, admitted_bytes, available_bytes, capacity_bytes, source: self.snapshot.source })
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct NativeMemoryPool {
|
||||
state: Mutex<State>,
|
||||
}
|
||||
impl NativeMemoryPool {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
state: Mutex::new(State {
|
||||
snapshot: MemorySnapshot::from_probe(0, 0, None),
|
||||
used: 0,
|
||||
invalid: false,
|
||||
#[cfg(test)]
|
||||
injected: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn check(&self, required_bytes: u64, credit: u64) -> Result<HostMemoryAdmission> {
|
||||
let mut state = self.state.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "native render memory lock poisoned"))?;
|
||||
state.refresh_if_idle();
|
||||
state.receipt(required_bytes, credit)
|
||||
}
|
||||
|
||||
pub(crate) fn reserve(self: &Arc<Self>, bytes: u64) -> Result<MemoryLease> {
|
||||
let mut state = self.state.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "native render memory lock poisoned"))?;
|
||||
state.refresh_if_idle();
|
||||
state.receipt(bytes, 0)?;
|
||||
state.used =
|
||||
state.used.checked_add(bytes).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "native memory reservation overflow"))?;
|
||||
Ok(MemoryLease { pool: self.clone(), bytes })
|
||||
}
|
||||
|
||||
fn release(&self, bytes: u64) {
|
||||
if bytes == 0 {
|
||||
return;
|
||||
}
|
||||
let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(used) = state.used.checked_sub(bytes) {
|
||||
state.used = used;
|
||||
} else {
|
||||
// An inconsistent ledger must never create more credit.
|
||||
state.snapshot.capacity_bytes = 0;
|
||||
state.invalid = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn testing(capacity_bytes: u64) -> Arc<Self> {
|
||||
let snapshot = MemorySnapshot { capacity_bytes, source: HostMemorySource::SystemAvailable };
|
||||
Arc::new(Self { state: Mutex::new(State { snapshot, used: 0, invalid: false, injected: Some(snapshot) }) })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_capacity(&self, capacity_bytes: u64) {
|
||||
self.state.lock().unwrap().injected = Some(MemorySnapshot { capacity_bytes, source: HostMemorySource::SystemAvailable });
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct MemoryLease {
|
||||
pool: Arc<NativeMemoryPool>,
|
||||
bytes: u64,
|
||||
}
|
||||
impl MemoryLease {
|
||||
pub(crate) fn split(&mut self, bytes: u64) -> Result<Self> {
|
||||
if bytes > self.bytes {
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "native memory split exceeds its lease"));
|
||||
}
|
||||
self.bytes -= bytes;
|
||||
Ok(Self { pool: self.pool.clone(), bytes })
|
||||
}
|
||||
|
||||
pub(crate) fn merge_from(&mut self, other: &mut Self) -> Result<()> {
|
||||
if !Arc::ptr_eq(&self.pool, &other.pool) {
|
||||
return Err(JobError::new(ErrorCode::WrongOwner, "native memory leases belong to different pools"));
|
||||
}
|
||||
self.bytes =
|
||||
self.bytes.checked_add(other.bytes).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "native memory merge overflow"))?;
|
||||
other.bytes = 0;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn shrink(&mut self, bytes: u64) -> Result<()> {
|
||||
let released =
|
||||
self.bytes.checked_sub(bytes).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "native memory shrink exceeds its lease"))?;
|
||||
self.pool.release(released);
|
||||
self.bytes = bytes;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Drop for MemoryLease {
|
||||
fn drop(&mut self) {
|
||||
self.pool.release(self.bytes);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn shared_native_memory() -> Arc<NativeMemoryPool> {
|
||||
static MEMORY: OnceLock<Arc<NativeMemoryPool>> = OnceLock::new();
|
||||
MEMORY.get_or_init(|| Arc::new(NativeMemoryPool::new())).clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) use native::MemorySnapshot;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use native::{MemoryLease, NativeMemoryPool, shared_native_memory};
|
||||
@@ -330,7 +330,7 @@ fn cooperative_raytrace_descriptor_matches_native_only_validation() {
|
||||
|
||||
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn native_raytrace_refraction_capability_resolves_only_preview_images() {
|
||||
fn native_raytrace_refraction_capability_resolves_preview_and_final_images() {
|
||||
let host = CpuHost::for_platform().unwrap();
|
||||
let descriptor = host.descriptor_for(Integrator::Raytrace).unwrap();
|
||||
assert_eq!(descriptor.availability, Availability::Available);
|
||||
@@ -350,7 +350,9 @@ fn native_raytrace_refraction_capability_resolves_only_preview_images() {
|
||||
let resolved = crate::resolve_request(std::slice::from_ref(&backend), &request).unwrap();
|
||||
assert_eq!(resolved.executor.as_str(), "cpu-raytrace");
|
||||
request.mode = RenderMode::Final;
|
||||
assert!(crate::resolve_request(std::slice::from_ref(&backend), &request).is_err());
|
||||
let final_image = crate::resolve_request(std::slice::from_ref(&backend), &request).unwrap();
|
||||
assert_eq!(final_image.executor, resolved.executor);
|
||||
assert_eq!(final_image.profile.mode, RenderMode::Final);
|
||||
request.mode = RenderMode::Preview;
|
||||
backend.executors = vec![host.descriptor_for(Integrator::Raster).unwrap()];
|
||||
assert!(crate::resolve_request(&[backend], &request).is_err(), "CPU raster must not advertise physical refraction");
|
||||
@@ -665,21 +667,30 @@ fn a_cancel_after_the_last_digest_chunk_still_wins_the_publication_lock() {
|
||||
fn png_and_exr_reuse_one_sha_state_without_mixing_chunk_boundaries() {
|
||||
let png: Vec<u8> = (0..PNG_HASH_CHUNK_BYTES + 7).map(|i| (i % 251) as u8).collect();
|
||||
let exr: Vec<u8> = (0..PNG_HASH_CHUNK_BYTES * 2 + 19).map(|i| (i % 239) as u8).collect();
|
||||
let mut pending = PendingPng::new(png.clone());
|
||||
pending.exr = Some(exr.clone());
|
||||
let cancel = CancelToken::default();
|
||||
assert!(!pending.step(1, &cancel).unwrap());
|
||||
assert!(pending.png_digest.is_none());
|
||||
assert!(!pending.step(1, &cancel).unwrap());
|
||||
assert_eq!(pending.png_digest.as_ref().unwrap(), &Sha256::digest(&png));
|
||||
assert_eq!(pending.exr_hashed, 0);
|
||||
while !pending.step(1, &cancel).unwrap() {}
|
||||
assert_eq!(pending.digest.finalize(), Sha256::digest(&exr));
|
||||
let mask: Vec<u8> = (0..PNG_HASH_CHUNK_BYTES * 3 + 31).map(|i| (i % 233) as u8).collect();
|
||||
for mask in [None, Some(mask)] {
|
||||
let mut pending = PendingPng::new(png.clone());
|
||||
pending.exr = Some(exr.clone());
|
||||
pending.denoise_mask = mask.clone();
|
||||
let cancel = CancelToken::default();
|
||||
assert!(!pending.step(1, &cancel).unwrap());
|
||||
assert!(pending.png_digest.is_none());
|
||||
assert!(!pending.step(1, &cancel).unwrap());
|
||||
assert_eq!(pending.png_digest.as_ref().unwrap(), &Sha256::digest(&png));
|
||||
assert_eq!(pending.exr_hashed, 0);
|
||||
while !pending.step(1, &cancel).unwrap() {}
|
||||
assert_eq!(pending.png_digest.as_ref().unwrap(), &Sha256::digest(&png));
|
||||
assert_eq!(pending.exr_digest.as_ref().unwrap(), &Sha256::digest(&exr));
|
||||
assert_eq!(pending.denoise_mask_digest, mask.as_ref().map(Sha256::digest));
|
||||
assert_eq!(pending.hashed, png.len());
|
||||
assert_eq!(pending.exr_hashed, exr.len());
|
||||
assert_eq!(pending.denoise_mask_hashed, mask.as_ref().map_or(0, Vec::len));
|
||||
}
|
||||
let metadata = std::mem::size_of::<Running>()
|
||||
+ std::mem::size_of::<RenderFrame>()
|
||||
+ std::mem::size_of::<RenderPreview>()
|
||||
+ PREPARED_PROOF_HEADER_BYTES
|
||||
+ 64
|
||||
+ 3 * 64
|
||||
+ 2 * crate::MAX_RENDER_ID_BYTES
|
||||
+ 32;
|
||||
assert!(metadata <= FRAME_METADATA_BYTES as usize);
|
||||
@@ -695,7 +706,7 @@ fn png_and_exr_reuse_one_sha_state_without_mixing_chunk_boundaries() {
|
||||
#[test]
|
||||
fn png_digest_metadata_fits_the_existing_allowance_without_changing_admission() {
|
||||
// Fixed digest/offset state and the completed frame (including its bounded
|
||||
// hex string and backend/executor IDs) fit the existing 1024-byte allowance.
|
||||
// hex string and backend/executor IDs) fit the shared metadata allowance.
|
||||
assert!(std::mem::size_of::<PendingPng>() <= 512);
|
||||
for linear_master in [false, true] {
|
||||
let metadata = std::mem::size_of::<Running>()
|
||||
@@ -761,6 +772,9 @@ impl ImageExecutor for DropProbe {
|
||||
triangles: 0,
|
||||
face_appearances: 0,
|
||||
projected_pixel_tests: None,
|
||||
caustic_work: None,
|
||||
cpu_workers: None,
|
||||
host_memory: None,
|
||||
geometry_bytes: 0,
|
||||
pixel_scratch_bytes: self.estimated,
|
||||
preparation_bytes: 0,
|
||||
@@ -1034,6 +1048,9 @@ fn registered_large_pool_is_explicit_finite_and_accounted() {
|
||||
triangles: 0,
|
||||
face_appearances: 0,
|
||||
projected_pixel_tests: None,
|
||||
caustic_work: None,
|
||||
cpu_workers: None,
|
||||
host_memory: None,
|
||||
geometry_bytes: 0,
|
||||
pixel_scratch_bytes: 0,
|
||||
preparation_bytes: 0,
|
||||
@@ -1048,8 +1065,16 @@ fn registered_large_pool_is_explicit_finite_and_accounted() {
|
||||
for limit in [0, super::super::NATIVE_CPU_POOL_BYTES + 1] {
|
||||
assert!(ImageHost::with_executor(Arc::new(Envelope(limit))).is_err());
|
||||
}
|
||||
let host = ImageHost::with_executor(Arc::new(Envelope(1024 * 1024 * 1024))).unwrap();
|
||||
assert_eq!(host.pool_limit_bytes(), 1024 * 1024 * 1024);
|
||||
let limit = 1024 * 1024 * 1024;
|
||||
let memory = super::super::host_memory::NativeMemoryPool::testing(limit);
|
||||
let host = ImageHost(Arc::new(Owner {
|
||||
executor: Some(Arc::new(Envelope(limit))),
|
||||
shared: ImageHost::shared_with_memory(limit, Some(memory)),
|
||||
kind: HostKind::NativeWorker,
|
||||
running: Mutex::new(None),
|
||||
_worker: None,
|
||||
}));
|
||||
assert_eq!(host.pool_limit_bytes(), limit);
|
||||
assert!(host.admit(&empty(), &request(), JobOptions::default()).is_ok());
|
||||
assert_eq!(host.admitted_bytes(), 0, "preflight does not allocate or reserve");
|
||||
let lease = host.0.shared.budget.reserve(600 * 1024 * 1024).unwrap();
|
||||
@@ -2137,6 +2162,9 @@ mod capture_credit {
|
||||
// The full raw descriptor still must remain exactly pinned.
|
||||
descriptor.profiles[0].features.push(crate::RenderFeature::Hdri);
|
||||
descriptor.profiles[0].formats.insert(0, RenderFormat::Exr);
|
||||
let mut final_image = descriptor.profiles[0].clone();
|
||||
final_image.mode = RenderMode::Final;
|
||||
descriptor.profiles.push(final_image);
|
||||
let state = self.0.load(Ordering::Acquire);
|
||||
descriptor.id = ExecutorId::new(if state == 1 { "changed-capture-map" } else { "capture-map" }).unwrap();
|
||||
if state == 3 {
|
||||
@@ -2598,3 +2626,348 @@ fn actual_cpu_texture_worker_releases_sources_after_completion_or_cancel_while_s
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn registered_preview_and_final_profiles_keep_capture_identity_and_completed_mode() {
|
||||
struct TwoModes;
|
||||
impl ImageExecutor for TwoModes {
|
||||
fn descriptor(&self) -> Result<ExecutorDescriptor> {
|
||||
let mut descriptor = CpuExecutor.descriptor()?;
|
||||
let mut final_image = descriptor.profiles[0].clone();
|
||||
final_image.mode = RenderMode::Final;
|
||||
descriptor.profiles.push(final_image);
|
||||
Ok(descriptor)
|
||||
}
|
||||
fn admit(&self, scene: &ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission> {
|
||||
CpuExecutor.admit(scene, request, options)
|
||||
}
|
||||
fn begin(&self, scene: ProductScene, request: ImageRequest, options: JobOptions, cancel: CancelToken) -> Result<Box<dyn ImageWork>> {
|
||||
CpuExecutor.begin(scene, request, options, cancel)
|
||||
}
|
||||
}
|
||||
let host = ImageHost::with_executor(Arc::new(TwoModes)).unwrap();
|
||||
let cancel = CancelToken::default();
|
||||
let credit = host.reserve_capture(0, CaptureProfile::Registered, &cancel, None).unwrap();
|
||||
assert_eq!(host.descriptor().unwrap().profiles.len(), 2);
|
||||
let mut unsupported = CpuExecutor.descriptor().unwrap();
|
||||
unsupported.profiles[0].mode = RenderMode::Quick;
|
||||
assert!(validate_image_profiles(&unsupported).is_err());
|
||||
drop(credit);
|
||||
let mut submission = host.prepare_submission_with_metadata(empty(), request(), JobOptions::default(), None, cancel, None).unwrap();
|
||||
assert!(submission.select_image_mode(RenderMode::Quick).is_err());
|
||||
assert_eq!(submission.resolved.profile.mode, RenderMode::Preview);
|
||||
submission.select_image_mode(RenderMode::Final).unwrap();
|
||||
let job = host.submit_prepared(JobId { session: 981, serial: 1, generation: 1 }, 1, submission).unwrap();
|
||||
let status = job.wait_timeout(std::time::Duration::from_secs(30)).unwrap();
|
||||
assert_eq!(status.progress.phase, Phase::Completed);
|
||||
assert_eq!(status.resolved.profile.mode, RenderMode::Final);
|
||||
assert_eq!(status.resolved.executor.as_str(), "cpu-product");
|
||||
assert!(job.frame().unwrap().is_some());
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn prepared_image_mode_pins_the_final_profile_limits_and_scratch_before_job_creation() {
|
||||
struct ChangingDescriptor(Arc<AtomicU64>);
|
||||
impl ImageExecutor for ChangingDescriptor {
|
||||
fn descriptor(&self) -> Result<ExecutorDescriptor> {
|
||||
let mut descriptor = CpuExecutor.descriptor()?;
|
||||
let mut final_image = descriptor.profiles[0].clone();
|
||||
final_image.mode = RenderMode::Final;
|
||||
match self.0.load(Ordering::Acquire) {
|
||||
1 => final_image.features.push(crate::RenderFeature::Hdri),
|
||||
2 => descriptor.limits.max_dimension -= 1,
|
||||
3 => descriptor.scratch.sample_bytes_per_pixel += 1,
|
||||
_ => {}
|
||||
}
|
||||
descriptor.profiles.push(final_image);
|
||||
Ok(descriptor)
|
||||
}
|
||||
fn admit(&self, scene: &ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission> {
|
||||
CpuExecutor.admit(scene, request, options)
|
||||
}
|
||||
fn begin(&self, scene: ProductScene, request: ImageRequest, options: JobOptions, cancel: CancelToken) -> Result<Box<dyn ImageWork>> {
|
||||
CpuExecutor.begin(scene, request, options, cancel)
|
||||
}
|
||||
}
|
||||
let changed = Arc::new(AtomicU64::new(0));
|
||||
let host = ImageHost::with_executor(Arc::new(ChangingDescriptor(changed.clone()))).unwrap();
|
||||
for mutation in 1..=3 {
|
||||
changed.store(0, Ordering::Release);
|
||||
let mut prepared = host.prepare_submission(empty(), request(), JobOptions::default(), None, CancelToken::default()).unwrap();
|
||||
let held = host.admitted_bytes();
|
||||
changed.store(mutation, Ordering::Release);
|
||||
let error = prepared.select_image_mode(RenderMode::Final).unwrap_err();
|
||||
assert_eq!(error.code, ErrorCode::HostUnavailable);
|
||||
assert!(error.message.contains("descriptor changed"));
|
||||
assert_eq!(prepared.resolved.profile.mode, RenderMode::Preview);
|
||||
assert!(host.0.shared.queue.lock().unwrap().is_empty());
|
||||
assert_eq!(host.admitted_bytes(), held);
|
||||
drop(prepared);
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_memory_admission {
|
||||
use super::*;
|
||||
use crate::job::host_memory::{MemorySnapshot, NativeMemoryPool};
|
||||
use crate::job::{HostMemorySource, NATIVE_CPU_POOL_BYTES};
|
||||
|
||||
fn host(memory: &Arc<NativeMemoryPool>, limit: u64, registered: bool) -> ImageHost {
|
||||
ImageHost(Arc::new(Owner {
|
||||
executor: registered.then(|| Arc::new(CpuExecutor) as Arc<dyn ImageExecutor>),
|
||||
shared: ImageHost::shared_with_memory(limit, Some(memory.clone())),
|
||||
kind: HostKind::NativeWorker,
|
||||
running: Mutex::new(None),
|
||||
_worker: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_cpu_and_registered_constructors_use_the_same_native_memory_pool() {
|
||||
let cpu = ImageHost::for_platform().unwrap();
|
||||
let registered = ImageHost::with_executor(Arc::new(CpuExecutor)).unwrap();
|
||||
assert!(Arc::ptr_eq(cpu.0.shared.budget.memory.as_ref().unwrap(), registered.0.shared.budget.memory.as_ref().unwrap()));
|
||||
assert!(cpu.0.shared.budget.memory_admission(0, 0).unwrap().is_some());
|
||||
assert_eq!(cpu.admitted_bytes(), 0);
|
||||
assert_eq!(registered.admitted_bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_and_registered_hosts_share_capacity_and_all_lease_transfers() {
|
||||
let memory = NativeMemoryPool::testing(100);
|
||||
let cpu = host(&memory, 80, false);
|
||||
let registered = host(&memory, 80, true);
|
||||
let mut cpu_lease = cpu.0.shared.budget.reserve(60).unwrap();
|
||||
let error = registered.0.shared.budget.reserve(41).err().unwrap();
|
||||
assert_eq!(error.code, ErrorCode::LimitExceeded);
|
||||
assert!(error.message.contains("41 additional bytes"), "{}", error.message);
|
||||
assert!(error.message.contains("60 bytes already admitted"), "{}", error.message);
|
||||
assert!(error.message.contains("40 bytes available"), "{}", error.message);
|
||||
assert_eq!(registered.admitted_bytes(), 0);
|
||||
|
||||
// The process ledger can accommodate 30, but this host cannot. Its
|
||||
// temporary physical lease must roll back on the software-pool refusal.
|
||||
assert!(cpu.0.shared.budget.reserve(30).is_err());
|
||||
assert_eq!(memory.check(0, 0).unwrap().admitted_bytes, 60);
|
||||
let mut gpu_lease = registered.0.shared.budget.reserve(40).unwrap();
|
||||
assert_eq!(memory.check(0, 0).unwrap().available_bytes, 0);
|
||||
assert!(cpu_lease.merge_from(&mut gpu_lease).is_err());
|
||||
assert_eq!(memory.check(0, 0).unwrap().admitted_bytes, 100);
|
||||
|
||||
let mut checkpoint = cpu_lease.split(20).unwrap();
|
||||
assert_eq!(memory.check(0, 0).unwrap().admitted_bytes, 100);
|
||||
cpu_lease.shrink(30).unwrap();
|
||||
assert_eq!(memory.check(0, 0).unwrap().admitted_bytes, 90);
|
||||
cpu_lease.merge_from(&mut checkpoint).unwrap();
|
||||
drop(checkpoint);
|
||||
assert_eq!(cpu_lease.bytes, 50);
|
||||
assert!(cpu_lease.shrink(51).is_err());
|
||||
assert_eq!(memory.check(0, 0).unwrap().admitted_bytes, 90);
|
||||
drop(gpu_lease);
|
||||
assert_eq!(memory.check(0, 0).unwrap().admitted_bytes, 50);
|
||||
drop(cpu_lease);
|
||||
assert_eq!(cpu.admitted_bytes(), 0);
|
||||
assert_eq!(registered.admitted_bytes(), 0);
|
||||
assert_eq!(memory.check(0, 0).unwrap().admitted_bytes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_snapshot_refreshes_only_after_all_reservations_are_released() {
|
||||
let memory = NativeMemoryPool::testing(100);
|
||||
let lease = memory.reserve(60).unwrap();
|
||||
memory.set_test_capacity(20);
|
||||
let snapshot = memory.check(40, 0).unwrap();
|
||||
assert_eq!((snapshot.capacity_bytes, snapshot.admitted_bytes, snapshot.available_bytes), (100, 60, 40));
|
||||
let own = memory.check(60, 60).unwrap();
|
||||
assert_eq!((own.admitted_bytes, own.available_bytes), (0, 100));
|
||||
assert!(memory.check(0, 61).is_err());
|
||||
drop(lease);
|
||||
let snapshot = memory.check(20, 0).unwrap();
|
||||
assert_eq!((snapshot.capacity_bytes, snapshot.admitted_bytes, snapshot.available_bytes), (20, 0, 20));
|
||||
assert!(memory.reserve(21).is_err());
|
||||
memory.set_test_capacity(100);
|
||||
assert_eq!(memory.check(100, 0).unwrap().capacity_bytes, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_probe_distinguishes_zero_capacity_cgroup_bounds_and_unavailable_ram() {
|
||||
let fallback = MemorySnapshot::from_probe(0, 0, None);
|
||||
assert_eq!(fallback.capacity_bytes, 512 * 1024 * 1024);
|
||||
assert_eq!(fallback.source, HostMemorySource::ConservativeFallback);
|
||||
assert_eq!(MemorySnapshot::from_probe(0, 0, Some(0)).capacity_bytes, 0);
|
||||
let exhausted = MemorySnapshot::from_probe(100, 0, None);
|
||||
assert_eq!(exhausted.capacity_bytes, 0);
|
||||
assert_eq!(exhausted.source, HostMemorySource::SystemAvailable);
|
||||
let cgroup = MemorySnapshot::from_probe(100, 80, Some(30));
|
||||
assert_eq!(cgroup.capacity_bytes, 30);
|
||||
assert_eq!(cgroup.source, HostMemorySource::SystemAndCgroup);
|
||||
assert_eq!(MemorySnapshot::from_probe(100, 80, Some(0)).capacity_bytes, 0);
|
||||
assert_eq!(MemorySnapshot::from_probe(100, u64::MAX, None).capacity_bytes, 100);
|
||||
assert_eq!(MemorySnapshot::from_probe(u64::MAX, u64::MAX, None).capacity_bytes, NATIVE_CPU_POOL_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_preflight_reports_capacity_without_reserving_or_creating_an_image() {
|
||||
let memory = NativeMemoryPool::testing(MEMORY_LIMIT);
|
||||
for registered in [false, true] {
|
||||
let host = host(&memory, MEMORY_LIMIT, registered);
|
||||
let report = host.admit(&empty(), &request(), JobOptions::default()).unwrap();
|
||||
let physical = report.host_memory.unwrap();
|
||||
assert_eq!(physical.required_bytes, report.estimated_total_bytes + fixed_request_metadata_bytes());
|
||||
assert_eq!(physical.capacity_bytes, MEMORY_LIMIT);
|
||||
assert_eq!(physical.admitted_bytes, 0);
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
assert!(host.0.shared.queue.lock().unwrap().is_empty());
|
||||
assert!(report.cpu_workers.is_none(), "raster/registered admission omits CPU worker execution");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "raytrace-cpu", target_pointer_width = "64"))]
|
||||
#[test]
|
||||
fn maximum_cpu_preflight_uses_real_capacity_gate_without_allocating_film() {
|
||||
use crate::job::{CpuBudget, CpuPathOptions, CpuProgressiveOptions, DenoiseSettings, OutputOptions, RaytraceOptions};
|
||||
let request = ImageRequest {
|
||||
integrator: Integrator::Raytrace,
|
||||
raytrace: Some(RaytraceOptions {
|
||||
threads: crate::job::MAX_CPU_THREADS,
|
||||
budget: CpuBudget::Workstation,
|
||||
progressive: Some(CpuProgressiveOptions { samples_per_pixel: crate::job::MAX_UI_SAMPLES, seed: 7 }),
|
||||
path: Some(CpuPathOptions { max_depth: crate::job::MAX_PATH_DEPTH }),
|
||||
texture_filtering: true,
|
||||
metal_energy_compensation: true,
|
||||
adaptive: Some(Box::new(crate::job::adaptive::AdaptiveSampling {
|
||||
pilot_samples: crate::job::adaptive::MAX_PILOT_SAMPLES,
|
||||
min_samples: crate::job::MAX_UI_SAMPLES,
|
||||
..Default::default()
|
||||
})),
|
||||
caustics: Some(crate::job::CausticOptions {
|
||||
photons: crate::job::MAX_CAUSTIC_PHOTONS,
|
||||
max_depth: crate::job::MAX_PATH_DEPTH,
|
||||
refresh_per_pass: true,
|
||||
..Default::default()
|
||||
}),
|
||||
supersampling: 2,
|
||||
denoise: true,
|
||||
denoise_settings: DenoiseSettings { passes: 3, strength: 1.0, firefly_suppression: true },
|
||||
denoise_diagnostics: true,
|
||||
output: Some(Box::new(OutputOptions {
|
||||
linear_exr: true,
|
||||
finishing: crate::job::finishing::Finishing {
|
||||
bloom: Some(crate::job::bloom::Bloom { strength: 2.0, threshold: 1e6, radius: 64 }),
|
||||
photographic: crate::job::photographic::Photographic {
|
||||
flare: crate::job::photographic::Flare { strength: 2.0, threshold: 1e6, ghosts: 4, halo: 2.0, streak: 2.0 },
|
||||
chromatic_pixels: 12.0,
|
||||
radial_distortion: 0.3,
|
||||
soft_focus: 1.0,
|
||||
sharpening: 1.0,
|
||||
grain: 0.15,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
}),
|
||||
width: 7680,
|
||||
height: 4320,
|
||||
..request()
|
||||
};
|
||||
let layout = crate::job::admit(&empty(), &request).unwrap();
|
||||
let required = layout.estimated_total_bytes + retained_request_bytes(&request).unwrap() + request.retained_resource_metadata_bytes();
|
||||
assert!(required < CpuBudget::Workstation.limits().max_job_bytes);
|
||||
let memory = NativeMemoryPool::testing(required + 1024);
|
||||
let cpu = host(&memory, NATIVE_CPU_POOL_BYTES, false);
|
||||
let registered = host(&memory, NATIVE_CPU_POOL_BYTES, true);
|
||||
let report = cpu.admit(&empty(), &request, JobOptions::default()).unwrap();
|
||||
let physical = report.host_memory.unwrap();
|
||||
assert_eq!((physical.required_bytes, physical.admitted_bytes, physical.available_bytes), (required, 0, required + 1024));
|
||||
assert_eq!(report.cpu_workers, Some(64));
|
||||
assert_eq!(report.caustic_work.unwrap().maps, crate::job::MAX_UI_SAMPLES + crate::job::adaptive::MAX_PILOT_SAMPLES);
|
||||
assert_eq!(cpu.admitted_bytes(), 0);
|
||||
assert!(cpu.0.shared.queue.lock().unwrap().is_empty());
|
||||
println!("{}", serde_json::json!({ "maximum_cpu_preflight": report }));
|
||||
|
||||
let competing = registered.0.shared.budget.reserve(1025).unwrap();
|
||||
let failure = cpu.admit(&empty(), &request, JobOptions::default()).unwrap_err();
|
||||
assert_eq!(failure.code, ErrorCode::LimitExceeded);
|
||||
assert!(failure.message.contains(&format!("{required} additional bytes")));
|
||||
assert!(failure.message.contains("1025 bytes already admitted"));
|
||||
assert!(failure.message.contains(&format!("{} bytes available", required - 1)));
|
||||
assert_eq!(cpu.admitted_bytes(), 0);
|
||||
drop(competing);
|
||||
assert_eq!(memory.check(0, 0).unwrap().admitted_bytes, 0);
|
||||
}
|
||||
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
#[test]
|
||||
fn prepared_cpu_workers_and_memory_receipt_survive_queueing_and_cancellation() {
|
||||
use crate::job::{CpuProgressiveOptions, RaytraceOptions};
|
||||
let memory = NativeMemoryPool::testing(MEMORY_LIMIT);
|
||||
let host = host(&memory, MEMORY_LIMIT, false);
|
||||
for (serial, threads) in [(1, 0), (2, 1), (3, 2)] {
|
||||
let request = ImageRequest {
|
||||
integrator: Integrator::Raytrace,
|
||||
raytrace: Some(RaytraceOptions {
|
||||
threads,
|
||||
progressive: Some(CpuProgressiveOptions { samples_per_pixel: 3, seed: 7 }),
|
||||
lights: Some(Vec::new()),
|
||||
..Default::default()
|
||||
}),
|
||||
width: 4,
|
||||
height: 4,
|
||||
..request()
|
||||
};
|
||||
let prepared = host.prepare_submission(empty(), request, JobOptions::default(), None, CancelToken::default()).unwrap();
|
||||
let workers = prepared.admission.cpu_workers.unwrap();
|
||||
assert!((1..=crate::job::MAX_CPU_THREADS).contains(&workers));
|
||||
if threads != 0 {
|
||||
assert_eq!(workers, threads);
|
||||
}
|
||||
assert_eq!(prepared.request().raytrace.as_ref().unwrap().threads, threads);
|
||||
let physical = prepared.admission.host_memory.unwrap();
|
||||
assert_eq!(physical.required_bytes, host.admitted_bytes());
|
||||
assert_eq!(physical.admitted_bytes, 0, "the receipt credits its own already-held lease");
|
||||
assert_eq!(memory.check(0, 0).unwrap().admitted_bytes, host.admitted_bytes());
|
||||
let handle = host.submit_prepared(id(7910, serial), 1, prepared).unwrap();
|
||||
let work = host.0.shared.queue.lock().unwrap().pop_front().unwrap();
|
||||
assert_eq!(work.cpu_workers, Some(workers));
|
||||
handle.cancel().unwrap();
|
||||
assert!(Running::begin(work).is_none());
|
||||
assert_eq!(handle.status().unwrap().progress.phase, Phase::Cancelled);
|
||||
drop(handle);
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
assert_eq!(memory.check(0, 0).unwrap().admitted_bytes, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
#[test]
|
||||
fn queued_cpu_work_refuses_missing_or_reduced_custom_worker_admission() {
|
||||
use crate::job::RaytraceOptions;
|
||||
for cpu_workers in [None, Some(1), Some(0), Some(65)] {
|
||||
let memory = NativeMemoryPool::testing(MEMORY_LIMIT);
|
||||
let host = host(&memory, MEMORY_LIMIT, false);
|
||||
let request = ImageRequest {
|
||||
integrator: Integrator::Raytrace,
|
||||
raytrace: Some(RaytraceOptions { threads: 2, ..Default::default() }),
|
||||
width: 1,
|
||||
height: 1,
|
||||
..request()
|
||||
};
|
||||
let handle = host.submit(id(7911, 1), 1, empty(), request).unwrap();
|
||||
let mut work = host.0.shared.queue.lock().unwrap().pop_front().unwrap();
|
||||
assert_eq!(work.cpu_workers, Some(2));
|
||||
work.cpu_workers = cpu_workers;
|
||||
assert!(Running::begin(work).is_none());
|
||||
let failure = handle.status().unwrap().error.unwrap();
|
||||
assert_eq!(failure.code, if cpu_workers.is_none() { ErrorCode::Internal } else { ErrorCode::InvalidRequest });
|
||||
drop(handle);
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
assert_eq!(memory.check(0, 0).unwrap().admitted_bytes, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,16 @@ pub struct Admission {
|
||||
/// Supersampled raster work only; absent for ray tracing and registered executors.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub projected_pixel_tests: Option<u64>,
|
||||
/// Present when the job explicitly refreshes its photon map every pass.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caustic_work: Option<super::caustics::CausticWork>,
|
||||
/// CPU worker count selected during admission and retained through execution.
|
||||
/// Auto records the available parallelism at admission; other producers omit it.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cpu_workers: Option<u16>,
|
||||
/// Native host RAM snapshot and shared reservations; absent without a native host.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub host_memory: Option<super::HostMemoryAdmission>,
|
||||
pub geometry_bytes: u64,
|
||||
pub pixel_scratch_bytes: u64,
|
||||
pub preparation_bytes: u64,
|
||||
@@ -132,15 +142,31 @@ fn admit_source(scene: &ProductScene, request: &ImageRequest, max_triangles: u64
|
||||
/// Ray tracing has no raster projected-triangle lists. Keep a conservative
|
||||
/// pixel/codec allowance, then let the ray tracer charge its actual BVH and film.
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
pub(crate) fn admit_raytrace_source(scene: &ProductScene, request: &ImageRequest, budget: super::CpuBudget) -> Result<Admission> {
|
||||
pub(crate) fn admit_raytrace_source(
|
||||
scene: &ProductScene,
|
||||
request: &ImageRequest,
|
||||
budget: super::CpuBudget,
|
||||
dimensions: (u32, u32),
|
||||
) -> Result<Admission> {
|
||||
budget.validate_execution()?;
|
||||
budget.validate_image(
|
||||
request.width,
|
||||
request.height,
|
||||
dimensions.0,
|
||||
dimensions.1,
|
||||
request.raytrace.as_ref().map(super::RaytraceOptions::camera_sampling).transpose()?.flatten(),
|
||||
)?;
|
||||
let source = admit_source_with_budget(scene, request, budget.limits().max_triangles, budget)?;
|
||||
let limits = budget.limits();
|
||||
let source = admit_source_with_dimensions(
|
||||
scene,
|
||||
request,
|
||||
limits.max_triangles,
|
||||
limits.max_bodies,
|
||||
limits.max_geometry_bytes,
|
||||
budget.image_limits(false),
|
||||
dimensions,
|
||||
)?;
|
||||
let mut a = source.admission;
|
||||
// The codec and retained previews have the requested final dimensions.
|
||||
a.encoded_limit = u64::from(request.width) * u64::from(request.height) * 8 + 65_536;
|
||||
a.pixel_scratch_bytes = source.raster_scratch_bytes;
|
||||
a.preparation_bytes = PREPARATION_METADATA_BYTES;
|
||||
a.estimated_total_bytes = a
|
||||
@@ -176,7 +202,7 @@ pub fn admit_geometry_with_limits(
|
||||
|| max_geometry_bytes == 0
|
||||
|| max_geometry_bytes > 4 * 1024 * 1024 * 1024
|
||||
|| image_limits.max_dimension > 16_384
|
||||
|| image_limits.max_pixels > 67_108_864
|
||||
|| image_limits.max_pixels > 134_217_728
|
||||
|| image_limits.max_scratch_bytes > super::NATIVE_CPU_POOL_BYTES
|
||||
{
|
||||
return Err(limit("registered source envelope exceeds implementation bounds"));
|
||||
@@ -191,6 +217,19 @@ fn admit_source_with_limits(
|
||||
max_bodies: usize,
|
||||
max_geometry_bytes: u64,
|
||||
image_limits: crate::RenderLimits,
|
||||
) -> Result<Source> {
|
||||
admit_source_with_dimensions(scene, request, max_triangles, max_bodies, max_geometry_bytes, image_limits, (request.width, request.height))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn admit_source_with_dimensions(
|
||||
scene: &ProductScene,
|
||||
request: &ImageRequest,
|
||||
max_triangles: u64,
|
||||
max_bodies: usize,
|
||||
max_geometry_bytes: u64,
|
||||
image_limits: crate::RenderLimits,
|
||||
dimensions: (u32, u32),
|
||||
) -> Result<Source> {
|
||||
if !request.camera.is_valid() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid image camera"));
|
||||
@@ -214,8 +253,8 @@ fn admit_source_with_limits(
|
||||
backend: None,
|
||||
mode: RenderMode::Preview,
|
||||
operation: RenderOperation::Image,
|
||||
width: u64::from(request.width),
|
||||
height: u64::from(request.height),
|
||||
width: u64::from(dimensions.0),
|
||||
height: u64::from(dimensions.1),
|
||||
format: Some(RenderFormat::Png),
|
||||
features: Vec::new(),
|
||||
policy: Default::default(),
|
||||
@@ -256,12 +295,15 @@ fn admit_source_with_limits(
|
||||
return Err(invalid("invalid body opacity"));
|
||||
}
|
||||
}
|
||||
let encoded_limit = u64::from(request.width) * u64::from(request.height) * 8 + 65_536;
|
||||
let encoded_limit = u64::from(dimensions.0) * u64::from(dimensions.1) * 8 + 65_536;
|
||||
Ok(Source {
|
||||
admission: Admission {
|
||||
triangles,
|
||||
face_appearances: faces,
|
||||
projected_pixel_tests: None,
|
||||
caustic_work: None,
|
||||
cpu_workers: None,
|
||||
host_memory: None,
|
||||
geometry_bytes: geometry,
|
||||
pixel_scratch_bytes: 0,
|
||||
preparation_bytes: 0,
|
||||
|
||||
@@ -11,8 +11,8 @@ fn invalid(message: &str) -> JobError {
|
||||
/// The writer checks exact dimensions and payload before reserving any output.
|
||||
pub fn encoded_limit(width: u32, height: u32) -> Result<u64> {
|
||||
let (w, h) = (u64::from(width), u64::from(height));
|
||||
if w == 0 || h == 0 || w > 8192 || h > 8192 || w * h > 16_777_216 {
|
||||
return Err(invalid("linear EXR requires 1..8192 sides and at most 16777216 pixels"));
|
||||
if w == 0 || h == 0 || w > 8192 || h > 8192 || w * h > super::MAX_OUTPUT_PIXELS {
|
||||
return Err(invalid("linear EXR requires 1..8192 sides and at most 33554432 pixels"));
|
||||
}
|
||||
Ok(w * h * 16 + h * 16 + 4096)
|
||||
}
|
||||
|
||||
+119
-16
@@ -21,9 +21,11 @@ mod cpu_path;
|
||||
mod cpu_sky;
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
pub(crate) mod denoise;
|
||||
mod denoise_diagnostics;
|
||||
pub(crate) mod encoding;
|
||||
pub mod finishing;
|
||||
mod host;
|
||||
mod host_memory;
|
||||
pub(crate) mod image;
|
||||
pub mod led_strip;
|
||||
mod lens;
|
||||
@@ -36,9 +38,11 @@ pub mod looks;
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
pub(crate) mod output;
|
||||
pub mod photographic;
|
||||
pub mod photon_sources;
|
||||
mod png;
|
||||
pub mod presentation;
|
||||
mod punctual;
|
||||
mod quality;
|
||||
mod regrade;
|
||||
#[cfg(test)]
|
||||
mod rt2_tests;
|
||||
@@ -49,32 +53,50 @@ pub use caustics::CausticOptions;
|
||||
pub use cpu_budget::{CpuBudget, CpuBudgetLimits, MAX_CPU_BODIES, MAX_CPU_SAMPLES_PER_PIXEL, MAX_CPU_TRIANGLES, MAX_CPU_WORK, NATIVE_CPU_POOL_BYTES};
|
||||
pub use cpu_path::CpuPathOptions;
|
||||
pub use cpu_sky::CpuSky;
|
||||
pub use denoise_diagnostics::{DenoiseDiagnostics, DenoiseGuideClass, resolve_denoise_class};
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
pub(crate) use host::FRAME_METADATA_BYTES;
|
||||
pub(crate) use host::Reservation;
|
||||
pub use host::{
|
||||
CaptureLease, CaptureProfile, CpuHost, HostKind, ImageHost, JobEnvironmentInfo, JobHandle, JobStatus, LinearAlphaConvention,
|
||||
CaptureLease, CaptureProfile, CpuHost, DenoiseMaskInfo, HostKind, ImageHost, JobEnvironmentInfo, JobHandle, JobStatus, LinearAlphaConvention,
|
||||
LinearColourEncoding, LinearMasterInfo, PreparedSubmission, PreviewInfo, RenderFrame, RenderPreview, RetainedMetadata,
|
||||
};
|
||||
pub use host_memory::{HostMemoryAdmission, HostMemorySource};
|
||||
pub use image::{Admission, ImageTask, TaskPoll, admit, admit_geometry, admit_geometry_with_limits};
|
||||
pub use lens::ThinLens;
|
||||
pub use lighting::{AreaLight, AreaLights, DirectionalLights, MAX_AREA_LIGHTS, MAX_DIRECTIONAL_LIGHTS, StudioLighting, Sun, ToneMap};
|
||||
pub use png::PngTask;
|
||||
pub use presentation::OutputOptions;
|
||||
pub use punctual::{MAX_PUNCTUAL_LIGHTS, MIN_GPU_SPOT_COSINE_GAP, PunctualDiagnostics, PunctualLightOptions, PunctualLights, SpotConeOptions};
|
||||
pub use quality::{
|
||||
DenoiseSettings, MAX_CPU_THREADS, MAX_OUTPUT_DIMENSION, MAX_OUTPUT_PIXELS, MAX_TRACE_DIMENSION, MAX_TRACE_PIXELS, MAX_UI_SAMPLES,
|
||||
resolve_supersampling, single_supersample, supersampled_dimensions,
|
||||
};
|
||||
|
||||
/// Native path-tracing ceilings, further restricted by the actual device.
|
||||
/// Kept in the device-independent layer so saved intent and UI agree with execution.
|
||||
pub const GPU_PATH_LIMITS: crate::RenderLimits =
|
||||
crate::RenderLimits { max_dimension: 8192, max_pixels: 16_777_216, max_scratch_bytes: 8 * 1024 * 1024 * 1024 };
|
||||
crate::RenderLimits { max_dimension: 8192, max_pixels: 33_554_432, max_scratch_bytes: 128 * 1024 * 1024 * 1024 };
|
||||
pub const MAX_GPU_PATH_TRIANGLES: u64 = 8_000_000;
|
||||
pub const MAX_GPU_PATH_BODIES: usize = 65_536;
|
||||
pub const MAX_GPU_DIELECTRICS: usize = 32;
|
||||
/// Optical target count is independent of simultaneous containment and ray depth.
|
||||
pub const MAX_OPTICAL_TARGETS: usize = 128;
|
||||
pub const MAX_GPU_DIELECTRICS: usize = MAX_OPTICAL_TARGETS;
|
||||
pub const MAX_NESTED_MEDIA: usize = 16;
|
||||
pub const MAX_PATH_DEPTH: u8 = 64;
|
||||
pub const MAX_SAMPLES_PER_PIXEL: u32 = 65_536;
|
||||
pub const MAX_CAUSTIC_PHOTONS: u32 = 1_048_576;
|
||||
|
||||
/// Runtime sampling intent, separate from saved documents and device resources.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct JobOptions {
|
||||
/// Explicit ray-footprint mip and bounded anisotropic texture filtering.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub texture_filtering: bool,
|
||||
/// Approximate the missing microfacet bounces of rough isotropic conductors.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub metal_energy_compensation: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caustics: Option<CausticOptions>,
|
||||
#[serde(skip_serializing_if = "aging::AgingStack::is_empty")]
|
||||
@@ -102,11 +124,20 @@ pub struct JobOptions {
|
||||
pub tone_map: ToneMap,
|
||||
/// Optional final spatial filter. Raw progressive accumulation remains unchanged.
|
||||
pub denoise: bool,
|
||||
#[serde(skip_serializing_if = "DenoiseSettings::is_default")]
|
||||
pub denoise_settings: DenoiseSettings,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub denoise_diagnostics: bool,
|
||||
/// Trace at this multiple of both output dimensions, then resolve in linear light.
|
||||
#[serde(skip_serializing_if = "single_supersample")]
|
||||
pub supersampling: u32,
|
||||
}
|
||||
|
||||
impl Default for JobOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
texture_filtering: false,
|
||||
metal_energy_compensation: false,
|
||||
caustics: None,
|
||||
aging: Default::default(),
|
||||
nested_media: false,
|
||||
@@ -124,6 +155,9 @@ impl Default for JobOptions {
|
||||
lighting: None,
|
||||
tone_map: ToneMap::default(),
|
||||
denoise: false,
|
||||
denoise_settings: DenoiseSettings::default(),
|
||||
denoise_diagnostics: false,
|
||||
supersampling: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,6 +204,10 @@ impl JobOptions {
|
||||
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "sampling pass target overflow"))
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.denoise_settings.validate()?;
|
||||
if !matches!(self.supersampling, 1 | 2) {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "supersampling must be 1 or 2"));
|
||||
}
|
||||
if let Some(caustics) = self.caustics {
|
||||
caustics.validate()?;
|
||||
}
|
||||
@@ -205,12 +243,12 @@ impl JobOptions {
|
||||
if let Some(lens) = self.lens {
|
||||
lens.validate()?;
|
||||
}
|
||||
if !(1..=4096).contains(&self.samples)
|
||||
|| !(1..=16).contains(&self.max_depth)
|
||||
if !(1..=MAX_SAMPLES_PER_PIXEL).contains(&self.samples)
|
||||
|| !(1..=u32::from(MAX_PATH_DEPTH)).contains(&self.max_depth)
|
||||
|| !self.exposure_ev.is_finite()
|
||||
|| !(-32.0..=32.0).contains(&self.exposure_ev)
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "samples must be 1..4096, depth 1..16 and exposure -32..32 EV"));
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "samples must be 1..65536, depth 1..64 and exposure -32..32 EV"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -227,6 +265,15 @@ pub struct ImageWorkPreview {
|
||||
|
||||
/// One bounded quantum of image work. Implementations check cancellation between quanta.
|
||||
pub trait ImageWork: Send {
|
||||
/// Bounded guide counts on the internal trace grid.
|
||||
fn denoise_diagnostics(&self) -> Option<DenoiseDiagnostics> {
|
||||
None
|
||||
}
|
||||
/// Categorical diagnostic PNG at the requested final output dimensions.
|
||||
fn take_denoise_mask(&mut self) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Cumulative omissions seen through the most recent complete image pass.
|
||||
/// Adaptive jobs include pilot paths and every production Fresnel stratum.
|
||||
fn punctual_diagnostics(&self) -> Option<PunctualDiagnostics> {
|
||||
@@ -256,6 +303,16 @@ impl ImageWork for ImageTask {
|
||||
/// Device-independent executor boundary. The host owns admission leases and publication;
|
||||
/// an executor owns computation only. GPU implementations live above the render crate.
|
||||
pub trait ImageExecutor: Send + Sync {
|
||||
fn supports_supersampling(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn supports_denoise_controls(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn supports_denoise_diagnostics(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn supports_glass_models(&self) -> bool {
|
||||
false
|
||||
}
|
||||
@@ -307,6 +364,12 @@ pub trait ImageExecutor: Send + Sync {
|
||||
}
|
||||
fn descriptor(&self) -> Result<crate::ExecutorDescriptor>;
|
||||
fn admit(&self, scene: &crate::ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission>;
|
||||
/// Validate the captured producer representation under its already reserved
|
||||
/// work lease. Check and Start run this before a job identity is created.
|
||||
/// Implementations must observe cancellation and retain no unbilled state.
|
||||
fn validate_prepared(&self, _scene: &crate::ProductScene, _request: &ImageRequest, _options: JobOptions, cancel: &CancelToken) -> Result<()> {
|
||||
cancel.check()
|
||||
}
|
||||
fn begin(&self, scene: crate::ProductScene, request: ImageRequest, options: JobOptions, cancel: CancelToken) -> Result<Box<dyn ImageWork>>;
|
||||
}
|
||||
|
||||
@@ -721,14 +784,14 @@ fn bounded_dielectrics<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std
|
||||
impl<'de> serde::de::Visitor<'de> for Inputs {
|
||||
type Value = Vec<DielectricOptions>;
|
||||
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("at most eight dielectric overrides")
|
||||
f.write_str("at most 128 dielectric overrides")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut sequence: A) -> std::result::Result<Self::Value, A::Error> {
|
||||
let mut inputs = Vec::new();
|
||||
inputs.try_reserve_exact(8).map_err(serde::de::Error::custom)?;
|
||||
while let Some(input) = sequence.next_element()? {
|
||||
if inputs.len() == 8 {
|
||||
return Err(serde::de::Error::custom("at most eight dielectric overrides"));
|
||||
if inputs.len() == MAX_OPTICAL_TARGETS {
|
||||
return Err(serde::de::Error::custom("at most 128 dielectric overrides"));
|
||||
}
|
||||
inputs.push(input);
|
||||
}
|
||||
@@ -798,17 +861,35 @@ pub fn cpu_camera_sampling(
|
||||
Ok(Some(CpuProgressiveOptions { samples_per_pixel, ..progressive }))
|
||||
}
|
||||
|
||||
/// The CPU filter shares the bounded float4 presentation envelope; it never
|
||||
/// enlarges a profile or implies that a linear EXR companion is requested.
|
||||
/// Filtering runs on the internal trace grid, before supersample reconstruction.
|
||||
/// Its storage and work still have to fit the explicitly selected CPU profile.
|
||||
pub fn validate_cpu_denoise_image(width: u32, height: u32) -> Result<()> {
|
||||
linear_exr::encoded_limit(width, height)
|
||||
.map(|_| ())
|
||||
.map_err(|_| JobError::new(ErrorCode::InvalidRequest, "CPU denoising requires 1..8192 sides and at most 16777216 pixels"))
|
||||
let pixels = u64::from(width).checked_mul(u64::from(height));
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| width > MAX_TRACE_DIMENSION
|
||||
|| height > MAX_TRACE_DIMENSION
|
||||
|| pixels.is_none_or(|pixels| pixels > MAX_TRACE_PIXELS)
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "CPU denoising requires 1..16384 internal sides and at most 134217728 pixels"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct RaytraceOptions {
|
||||
/// Zero selects a bounded native worker count; one keeps serial execution.
|
||||
pub threads: u16,
|
||||
/// Sample captured HDR radiance using its exact-solid-angle importance table.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub environment_importance_sampling: bool,
|
||||
/// Explicit ray-footprint mip and bounded anisotropic texture filtering.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub texture_filtering: bool,
|
||||
/// Approximate the missing microfacet bounces of rough isotropic conductors.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub metal_energy_compensation: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub caustics: Option<CausticOptions>,
|
||||
/// One bounded world-space participating medium, available with explicit Path.
|
||||
@@ -829,6 +910,13 @@ pub struct RaytraceOptions {
|
||||
/// Optional CPU spatial output filtering; never changes the raw film or sampling.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub denoise: bool,
|
||||
#[serde(skip_serializing_if = "DenoiseSettings::is_default")]
|
||||
pub denoise_settings: DenoiseSettings,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub denoise_diagnostics: bool,
|
||||
/// Trace at this multiple of both output dimensions, then resolve in linear light.
|
||||
#[serde(skip_serializing_if = "single_supersample")]
|
||||
pub supersampling: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lens: Option<ThinLens>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -868,6 +956,10 @@ pub struct RaytraceOptions {
|
||||
impl Default for RaytraceOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
threads: 0,
|
||||
environment_importance_sampling: false,
|
||||
texture_filtering: false,
|
||||
metal_energy_compensation: false,
|
||||
caustics: None,
|
||||
volume: None,
|
||||
sun: None,
|
||||
@@ -875,6 +967,9 @@ impl Default for RaytraceOptions {
|
||||
aging: None,
|
||||
adaptive: None,
|
||||
denoise: false,
|
||||
denoise_settings: DenoiseSettings::default(),
|
||||
denoise_diagnostics: false,
|
||||
supersampling: 1,
|
||||
path: None,
|
||||
lens: None,
|
||||
output: None,
|
||||
@@ -1126,7 +1221,14 @@ impl RaytraceOptions {
|
||||
Ok(())
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.denoise_settings.validate()?;
|
||||
if self.threads > MAX_CPU_THREADS || !matches!(self.supersampling, 1 | 2) {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "CPU threads must be 0..64 and supersampling 1 or 2"));
|
||||
}
|
||||
let _ = self.retained_volume_bytes()?;
|
||||
if self.metal_energy_compensation && self.path.is_none() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "CPU rough metal energy compensation requires explicit Indirect lighting"));
|
||||
}
|
||||
if let Some(caustics) = self.caustics {
|
||||
caustics.validate()?;
|
||||
if self.path.is_none() {
|
||||
@@ -1215,8 +1317,9 @@ impl RaytraceOptions {
|
||||
}) {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid constant raytrace environment"));
|
||||
}
|
||||
if self.dielectrics.len() > 8 || self.dielectrics.capacity() > 8 {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "at most eight dielectric overrides"));
|
||||
let optical_limit = if self.path.is_some() { MAX_OPTICAL_TARGETS } else { 8 };
|
||||
if self.dielectrics.len() > optical_limit || self.dielectrics.capacity() > optical_limit {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, format!("at most {optical_limit} dielectric overrides for this CPU transport")));
|
||||
}
|
||||
for (index, input) in self.dielectrics.iter().enumerate() {
|
||||
if let Some(model) = input.model {
|
||||
|
||||
+233
-24
@@ -1,7 +1,7 @@
|
||||
//! Bounded presentation of an owned scene-linear CPU pass. Transport and film remain separate.
|
||||
//! The caller supplies the cumulative work debit; every mutating stage checks it first.
|
||||
use super::denoise::{DenoiseTask, Guide};
|
||||
use super::{CancelToken, ErrorCode, JobError, Phase, Progress, Result, TaskPoll};
|
||||
use super::{CancelToken, DenoiseDiagnostics, DenoiseGuideClass, DenoiseSettings, ErrorCode, JobError, Phase, Progress, Result, TaskPoll};
|
||||
use super::{bloom::BloomTask, encoding::PngEncoding, linear_exr::ExrTask, photographic::PhotographicTask, presentation::Presentation};
|
||||
use crate::film::adaptive::CountsPlane;
|
||||
|
||||
@@ -9,23 +9,32 @@ fn invalid(message: &str) -> JobError {
|
||||
JobError::new(ErrorCode::InvalidRequest, message)
|
||||
}
|
||||
|
||||
fn coverage_alpha(coverage: f32, samples: u16) -> Result<u8> {
|
||||
if samples == 0 || !coverage.is_finite() || !(0.0..=1.0).contains(&coverage) {
|
||||
fn coverage_alpha(coverage: f32, samples: u32) -> Result<u8> {
|
||||
if samples == 0 || samples > super::MAX_SAMPLES_PER_PIXEL || !coverage.is_finite() || !(0.0..=1.0).contains(&coverage) {
|
||||
return Err(invalid("invalid CPU display coverage count"));
|
||||
}
|
||||
// Every CPU pass has one known denominator. Even at u16::MAX, float32
|
||||
// Every CPU pass has one known denominator. At the admitted 65536 samples, float32
|
||||
// quantisation is far below half a hit, so the original count is unique.
|
||||
// Keep the raw FLOAT32 plane unchanged and round only the final byte from
|
||||
// the integer ratio: 5/6 rounds to 213; float32 alpha alone gives 212.
|
||||
let covered = (f64::from(coverage) * f64::from(samples)).round() as u32;
|
||||
if covered > u32::from(samples) || (f64::from(covered) / f64::from(samples)) as f32 != coverage {
|
||||
if covered > samples || (f64::from(covered) / f64::from(samples)) as f32 != coverage {
|
||||
return Err(invalid("CPU display coverage disagrees with its sample count"));
|
||||
}
|
||||
let denominator = u32::from(samples);
|
||||
let denominator = samples;
|
||||
u8::try_from((covered * 510 + denominator) / (denominator * 2)).map_err(|_| invalid("CPU display alpha exceeds one byte"))
|
||||
}
|
||||
|
||||
struct Resolve {
|
||||
source: Vec<[f32; 4]>,
|
||||
pixels: Vec<[f32; 4]>,
|
||||
raw: bool,
|
||||
}
|
||||
|
||||
enum Stage {
|
||||
Diagnostics(usize),
|
||||
MaskPng,
|
||||
Resolve(Resolve),
|
||||
Exr(ExrTask),
|
||||
Denoise(DenoiseTask),
|
||||
Bloom(BloomTask),
|
||||
@@ -38,6 +47,16 @@ enum Stage {
|
||||
pub(crate) struct OutputTask {
|
||||
width: u32,
|
||||
height: u32,
|
||||
trace_width: u32,
|
||||
trace_height: u32,
|
||||
supersampling: u32,
|
||||
denoise_settings: DenoiseSettings,
|
||||
retain_exr: bool,
|
||||
original: Option<Vec<[f32; 4]>>,
|
||||
resolved_alpha: Vec<u8>,
|
||||
diagnostics: Option<DenoiseDiagnostics>,
|
||||
mask_rgba: Vec<u8>,
|
||||
mask_png: Option<Vec<u8>>,
|
||||
encoded_limit: u64,
|
||||
presentation: Presentation,
|
||||
cancel: CancelToken,
|
||||
@@ -48,12 +67,12 @@ pub(crate) struct OutputTask {
|
||||
rgba: Vec<u8>,
|
||||
preserve_display: bool,
|
||||
denoise: bool,
|
||||
coverage_samples: u16,
|
||||
coverage_samples: u32,
|
||||
exr: Option<Vec<u8>>,
|
||||
}
|
||||
impl OutputTask {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn new(
|
||||
pub(crate) fn new_with_quality(
|
||||
width: u32,
|
||||
height: u32,
|
||||
encoded_limit: u64,
|
||||
@@ -63,26 +82,41 @@ impl OutputTask {
|
||||
preserve_display: bool,
|
||||
retain_exr: bool,
|
||||
denoise: bool,
|
||||
coverage_samples: u16,
|
||||
denoise_settings: DenoiseSettings,
|
||||
supersampling: u32,
|
||||
diagnostics: bool,
|
||||
coverage_samples: u32,
|
||||
cancel: CancelToken,
|
||||
charge: &mut impl FnMut(u64) -> Result<()>,
|
||||
) -> Result<Self> {
|
||||
presentation.validate()?;
|
||||
cancel.check()?;
|
||||
if denoise && preserve_display {
|
||||
denoise_settings.validate()?;
|
||||
if (denoise || supersampling != 1) && preserve_display {
|
||||
return Err(invalid("CPU denoising cannot preserve an unfiltered display buffer"));
|
||||
}
|
||||
if coverage_samples == 0 {
|
||||
return Err(invalid("CPU output requires a complete coverage sample count"));
|
||||
}
|
||||
let count = u64::from(width) * u64::from(height);
|
||||
super::linear_exr::encoded_limit(width, height)?;
|
||||
let (trace_width, trace_height) = super::supersampled_dimensions(width, height, supersampling)?;
|
||||
let count = u64::from(trace_width) * u64::from(trace_height);
|
||||
if linear.len() as u64 != count || (preserve_display && rgba.len() as u64 != count * 4) || (!preserve_display && !rgba.is_empty()) {
|
||||
return Err(invalid("CPU output storage does not match its complete pass"));
|
||||
}
|
||||
let mut task = Self {
|
||||
width,
|
||||
height,
|
||||
trace_width,
|
||||
trace_height,
|
||||
supersampling,
|
||||
denoise_settings,
|
||||
retain_exr,
|
||||
original: None,
|
||||
resolved_alpha: Vec::new(),
|
||||
diagnostics: diagnostics.then(|| DenoiseDiagnostics::new(trace_width, trace_height)),
|
||||
mask_rgba: Vec::new(),
|
||||
mask_png: None,
|
||||
encoded_limit,
|
||||
presentation,
|
||||
cancel,
|
||||
@@ -96,14 +130,38 @@ impl OutputTask {
|
||||
coverage_samples,
|
||||
exr: None,
|
||||
};
|
||||
if retain_exr {
|
||||
charge(u64::from(height))?; // bounded offset-table construction
|
||||
task.stage = Some(Stage::Exr(ExrTask::new_cpu(width, height, linear, task.cancel.clone())?));
|
||||
if diagnostics {
|
||||
task.linear = Some(linear);
|
||||
task.stage = Some(Stage::Diagnostics(0));
|
||||
} else {
|
||||
task.prepare_display(linear, charge)?;
|
||||
task.prepare_raw(linear, charge)?;
|
||||
}
|
||||
Ok(task)
|
||||
}
|
||||
pub(crate) fn denoise_diagnostics(&self) -> Option<DenoiseDiagnostics> {
|
||||
self.diagnostics
|
||||
}
|
||||
pub(crate) fn take_denoise_mask(&mut self) -> Option<Vec<u8>> {
|
||||
self.mask_png.take()
|
||||
}
|
||||
|
||||
fn prepare_raw(&mut self, linear: Vec<[f32; 4]>, charge: &mut impl FnMut(u64) -> Result<()>) -> Result<()> {
|
||||
if self.retain_exr {
|
||||
if self.supersampling == 2 {
|
||||
self.stage = Some(Stage::Resolve(Resolve { source: linear, pixels: Vec::new(), raw: true }));
|
||||
} else {
|
||||
self.start_exr(linear, charge)?;
|
||||
}
|
||||
} else {
|
||||
self.prepare_display(linear, charge)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn start_exr(&mut self, linear: Vec<[f32; 4]>, charge: &mut impl FnMut(u64) -> Result<()>) -> Result<()> {
|
||||
charge(u64::from(self.height))?;
|
||||
self.stage = Some(Stage::Exr(ExrTask::new_cpu(self.width, self.height, linear, self.cancel.clone())?));
|
||||
Ok(())
|
||||
}
|
||||
fn pending(&self) -> TaskPoll {
|
||||
TaskPoll::Pending(Progress { phase: Phase::Encoding, done: 0, total: u64::from(self.width) * u64::from(self.height) })
|
||||
}
|
||||
@@ -123,7 +181,20 @@ impl OutputTask {
|
||||
fn prepare_display(&mut self, linear: Vec<[f32; 4]>, charge: &mut impl FnMut(u64) -> Result<()>) -> Result<()> {
|
||||
if self.denoise {
|
||||
charge(1)?; // one fallible, prepaid colour scratch reservation
|
||||
self.stage = Some(Stage::Denoise(DenoiseTask::new(self.width, self.height, linear)?));
|
||||
let task = if self.denoise_settings.is_default() {
|
||||
DenoiseTask::new(self.trace_width, self.trace_height, linear)?
|
||||
} else {
|
||||
DenoiseTask::new_with_settings(self.trace_width, self.trace_height, linear, self.denoise_settings)?
|
||||
};
|
||||
self.stage = Some(Stage::Denoise(task));
|
||||
Ok(())
|
||||
} else {
|
||||
self.prepare_resolve(linear, charge)
|
||||
}
|
||||
}
|
||||
fn prepare_resolve(&mut self, linear: Vec<[f32; 4]>, charge: &mut impl FnMut(u64) -> Result<()>) -> Result<()> {
|
||||
if self.supersampling == 2 {
|
||||
self.stage = Some(Stage::Resolve(Resolve { source: linear, pixels: Vec::new(), raw: false }));
|
||||
Ok(())
|
||||
} else {
|
||||
self.prepare_finishing(linear, charge)
|
||||
@@ -149,16 +220,107 @@ impl OutputTask {
|
||||
guides: Option<&[Guide]>,
|
||||
counts: Option<&CountsPlane>,
|
||||
charge: &mut impl FnMut(u64) -> Result<()>,
|
||||
) -> Result<TaskPoll> {
|
||||
self.step_with_diagnostics(guides, None, counts, charge)
|
||||
}
|
||||
pub(crate) fn step_with_diagnostics(
|
||||
&mut self,
|
||||
guides: Option<&[Guide]>,
|
||||
classes: Option<&[DenoiseGuideClass]>,
|
||||
counts: Option<&CountsPlane>,
|
||||
charge: &mut impl FnMut(u64) -> Result<()>,
|
||||
) -> Result<TaskPoll> {
|
||||
self.cancel.check()?;
|
||||
let stage = self.stage.take().ok_or_else(|| invalid("CPU output stage disappeared"))?;
|
||||
match stage {
|
||||
Stage::Diagnostics(at) => {
|
||||
let count = self.width as usize * self.height as usize;
|
||||
let end = at.saturating_add(256).min(count);
|
||||
charge(((end - at) as u64) * u64::from(self.supersampling).pow(2))?;
|
||||
reserve_exact(&mut self.mask_rgba, count * 4)?;
|
||||
let classes = classes.ok_or_else(|| invalid("CPU diagnostic guide classes disappeared"))?;
|
||||
let linear = self.linear.as_deref().ok_or_else(|| invalid("CPU diagnostic radiance disappeared"))?;
|
||||
let report = self.diagnostics.as_mut().ok_or_else(|| invalid("CPU diagnostic report disappeared"))?;
|
||||
for pixel in at..end {
|
||||
let (indices, samples) = super::quality::supersample_indices(self.width, self.supersampling, pixel)?;
|
||||
let mut resolved = [DenoiseGuideClass::Unavailable; 4];
|
||||
for (slot, index) in resolved.iter_mut().zip(&indices[..samples]) {
|
||||
let mut class = *classes.get(*index).ok_or_else(|| invalid("CPU diagnostic guide index outside grid"))?;
|
||||
let alpha = linear.get(*index).ok_or_else(|| invalid("CPU diagnostic coverage outside grid"))?[3];
|
||||
if class.eligible() && alpha < 0.999 {
|
||||
class = DenoiseGuideClass::PartialCoverage;
|
||||
}
|
||||
report.record(class);
|
||||
*slot = class;
|
||||
}
|
||||
let class = super::resolve_denoise_class(resolved[..samples].iter().copied());
|
||||
self.mask_rgba.extend_from_slice(&class.color());
|
||||
}
|
||||
if end == count {
|
||||
self.png = Some(PngEncoding::new(self.width, self.height, self.encoded_limit, self.cancel.clone())?);
|
||||
self.stage = Some(Stage::MaskPng);
|
||||
} else {
|
||||
self.stage = Some(Stage::Diagnostics(end));
|
||||
}
|
||||
}
|
||||
Stage::MaskPng => {
|
||||
let task = self.png.as_mut().ok_or_else(|| invalid("CPU diagnostic PNG encoder disappeared"))?;
|
||||
charge(if task.row() < self.height as usize { u64::from(self.width) } else { 1 })?;
|
||||
if let Some(png) = task.step(&self.mask_rgba)? {
|
||||
charge(png.len() as u64)?;
|
||||
self.mask_png = Some(png);
|
||||
self.png = None;
|
||||
self.mask_rgba = Vec::new();
|
||||
let linear = self.linear.take().ok_or_else(|| invalid("CPU diagnostic raw pass disappeared"))?;
|
||||
self.prepare_raw(linear, charge)?;
|
||||
} else {
|
||||
self.stage = Some(Stage::MaskPng);
|
||||
}
|
||||
}
|
||||
Stage::Resolve(mut task) => {
|
||||
let count = self.width as usize * self.height as usize;
|
||||
let at = task.pixels.len();
|
||||
let end = at.saturating_add(256).min(count);
|
||||
charge(((end - at) * 4) as u64)?;
|
||||
reserve_exact(&mut task.pixels, count)?;
|
||||
if !task.raw && self.presentation.transparent_background {
|
||||
reserve_exact(&mut self.resolved_alpha, count)?;
|
||||
}
|
||||
for pixel in at..end {
|
||||
task.pixels.push(super::quality::resolve_pixel(self.width, self.supersampling, &task.source, pixel)?);
|
||||
if !task.raw && self.presentation.transparent_background {
|
||||
self.resolved_alpha.push(resolved_coverage_alpha(
|
||||
self.width,
|
||||
self.supersampling,
|
||||
pixel,
|
||||
&task.source,
|
||||
counts,
|
||||
self.coverage_samples,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
if end == count {
|
||||
if task.raw {
|
||||
self.original = Some(task.source);
|
||||
self.start_exr(task.pixels, charge)?;
|
||||
} else {
|
||||
drop(task.source);
|
||||
self.prepare_finishing(task.pixels, charge)?;
|
||||
}
|
||||
} else {
|
||||
self.stage = Some(Stage::Resolve(task));
|
||||
}
|
||||
}
|
||||
Stage::Exr(mut task) => {
|
||||
charge(u64::from(self.width))?;
|
||||
if task.step(1)? {
|
||||
let (bytes, pixels) = task.finish()?;
|
||||
charge(bytes.len() as u64)?; // host's later digest scan
|
||||
self.exr = Some(bytes);
|
||||
let pixels = match self.original.take() {
|
||||
Some(original) => original,
|
||||
None => pixels,
|
||||
};
|
||||
self.prepare_display(pixels, charge)?;
|
||||
} else {
|
||||
self.stage = Some(Stage::Exr(task));
|
||||
@@ -168,7 +330,7 @@ impl OutputTask {
|
||||
charge(task.next_pixels() as u64)?;
|
||||
let guides = guides.ok_or_else(|| invalid("CPU output is missing its admitted geometry guides"))?;
|
||||
if task.step(guides, &self.cancel)? {
|
||||
self.prepare_finishing(task.finish()?, charge)?;
|
||||
self.prepare_resolve(task.finish()?, charge)?;
|
||||
} else {
|
||||
self.stage = Some(Stage::Denoise(task));
|
||||
}
|
||||
@@ -206,15 +368,19 @@ impl OutputTask {
|
||||
// Canonical fully clear pixels cannot carry lifted-black RGB into
|
||||
// edge filtering. Raw linear coverage/radiance remains unchanged.
|
||||
for (offset, (pixel, rgba)) in input.iter().zip(output.as_chunks_mut::<4>().0).enumerate() {
|
||||
rgba[3] = match counts {
|
||||
Some(counts) => {
|
||||
let exact = counts.pixel(at + offset)?;
|
||||
if exact.samples() == 0 || (f64::from(exact.covered()) / f64::from(exact.samples())) as f32 != pixel[3] {
|
||||
return Err(invalid("CPU adaptive coverage disagrees with its complete count plane"));
|
||||
rgba[3] = if self.supersampling == 2 {
|
||||
*self.resolved_alpha.get(at + offset).ok_or_else(|| invalid("CPU resolved coverage disappeared"))?
|
||||
} else {
|
||||
match counts {
|
||||
Some(counts) => {
|
||||
let exact = counts.pixel(at + offset)?;
|
||||
if exact.samples() == 0 || (f64::from(exact.covered()) / f64::from(exact.samples())) as f32 != pixel[3] {
|
||||
return Err(invalid("CPU adaptive coverage disagrees with its complete count plane"));
|
||||
}
|
||||
exact.alpha8()?
|
||||
}
|
||||
exact.alpha8()?
|
||||
None => coverage_alpha(pixel[3], self.coverage_samples)?,
|
||||
}
|
||||
None => coverage_alpha(pixel[3], self.coverage_samples)?,
|
||||
};
|
||||
if rgba[3] == 0 {
|
||||
*rgba = [0; 4];
|
||||
@@ -223,6 +389,7 @@ impl OutputTask {
|
||||
}
|
||||
if end == count {
|
||||
self.glow = None;
|
||||
self.resolved_alpha = Vec::new();
|
||||
if self.presentation.finishing.photographic.active() {
|
||||
let pixels = self.linear.take().ok_or_else(|| invalid("CPU photographic source disappeared"))?;
|
||||
self.stage = Some(Stage::Photographic(PhotographicTask::new(
|
||||
@@ -269,6 +436,9 @@ impl OutputTask {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn stage_name(&self) -> &'static str {
|
||||
match &self.stage {
|
||||
Some(Stage::Diagnostics(_)) => "diagnostics",
|
||||
Some(Stage::MaskPng) => "denoise_mask",
|
||||
Some(Stage::Resolve(_)) => "resolve",
|
||||
Some(Stage::Exr(_)) => "exr",
|
||||
Some(Stage::Denoise(_)) => "denoise",
|
||||
Some(Stage::Bloom(_)) => "bloom",
|
||||
@@ -287,3 +457,42 @@ impl OutputTask {
|
||||
Ok((self.rgba, self.exr))
|
||||
}
|
||||
}
|
||||
|
||||
fn reserve_exact<T>(values: &mut Vec<T>, count: usize) -> Result<()> {
|
||||
if values.capacity() == 0 {
|
||||
values.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "CPU output scratch allocation failed"))?;
|
||||
}
|
||||
if values.capacity() != count {
|
||||
return Err(invalid("CPU output scratch capacity exceeds admission"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reconstruct alpha from exact subpixel coverage ratios, including adaptive
|
||||
/// denominators. Four admitted u32 sample counts fit the u128 rational sum.
|
||||
fn resolved_coverage_alpha(width: u32, scale: u32, pixel: usize, linear: &[[f32; 4]], counts: Option<&CountsPlane>, samples: u32) -> Result<u8> {
|
||||
let (indices, count) = super::quality::supersample_indices(width, scale, pixel)?;
|
||||
let (mut numerator, mut denominator) = (0_u128, 1_u128);
|
||||
for index in &indices[..count] {
|
||||
let alpha = linear.get(*index).ok_or_else(|| invalid("CPU resolved coverage source disappeared"))?[3];
|
||||
let (covered, samples) = match counts {
|
||||
Some(counts) => {
|
||||
let exact = counts.pixel(*index)?;
|
||||
(exact.covered(), exact.samples())
|
||||
}
|
||||
None => {
|
||||
if samples == 0 || samples > super::MAX_SAMPLES_PER_PIXEL || !alpha.is_finite() || !(0.0..=1.0).contains(&alpha) {
|
||||
return Err(invalid("CPU resolved coverage sample count invalid"));
|
||||
}
|
||||
((f64::from(alpha) * f64::from(samples)).round() as u32, samples)
|
||||
}
|
||||
};
|
||||
if samples == 0 || samples > super::MAX_SAMPLES_PER_PIXEL || covered > samples || (f64::from(covered) / f64::from(samples)) as f32 != alpha {
|
||||
return Err(invalid("CPU resolved coverage disagrees with its complete count plane"));
|
||||
}
|
||||
numerator = numerator * u128::from(samples) + u128::from(covered) * denominator;
|
||||
denominator *= u128::from(samples);
|
||||
}
|
||||
denominator *= count as u128;
|
||||
u8::try_from((numerator * 510 + denominator) / (denominator * 2)).map_err(|_| invalid("CPU resolved alpha exceeds one byte"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Bounded emission-source proposals, shared by CPU and GPU photon transport.
|
||||
//! These weights never change source radiometry or camera-path MIS densities.
|
||||
use super::{ErrorCode, JobError, PunctualLightOptions, Result};
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
pub const MAX_PHOTON_SOURCES: usize = super::MAX_DIRECTIONAL_LIGHTS + super::MAX_PUNCTUAL_LIGHTS + super::MAX_AREA_LIGHTS + 1;
|
||||
pub const SOURCE_BINS: u32 = 1 << 24;
|
||||
/// Five percent uniform exploration keeps reciprocal source probability near at most
|
||||
/// 20 times the source count, including dim sources with efficient optical paths.
|
||||
const UNIFORM_SHARE: f64 = 0.05;
|
||||
|
||||
fn invalid(message: &str) -> JobError {
|
||||
JobError::new(ErrorCode::InvalidRequest, message)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PhotonSourceDistribution {
|
||||
cumulative: [u32; MAX_PHOTON_SOURCES],
|
||||
count: usize,
|
||||
}
|
||||
impl PhotonSourceDistribution {
|
||||
pub fn new(powers: &[f64]) -> Result<Self> {
|
||||
if powers.is_empty() || powers.len() > MAX_PHOTON_SOURCES || powers.iter().any(|p| !p.is_finite() || *p < 0.0) {
|
||||
return Err(invalid("photon source powers require one to twenty-one finite nonnegative values"));
|
||||
}
|
||||
let maximum = powers.iter().copied().fold(0.0_f64, f64::max);
|
||||
// Scaling prevents overflow without dropping any source's support.
|
||||
let total = if maximum > 0.0 { powers.iter().map(|p| p / maximum).sum::<f64>() } else { 0.0 };
|
||||
let mut cumulative = [0; MAX_PHOTON_SOURCES];
|
||||
let mut sum = 0.0;
|
||||
let mut previous = 0;
|
||||
for (index, power) in powers.iter().enumerate() {
|
||||
let probability = if total > 0.0 {
|
||||
(1.0 - UNIFORM_SHARE) * (*power / maximum) / total + UNIFORM_SHARE / powers.len() as f64
|
||||
} else {
|
||||
1.0 / powers.len() as f64
|
||||
};
|
||||
sum += probability;
|
||||
let end = if index + 1 == powers.len() { SOURCE_BINS } else { (sum * f64::from(SOURCE_BINS)).round() as u32 };
|
||||
if end <= previous || end > SOURCE_BINS {
|
||||
return Err(invalid("photon source quantization lost support"));
|
||||
}
|
||||
cumulative[index] = end;
|
||||
previous = end;
|
||||
}
|
||||
Ok(Self { cumulative, count: powers.len() })
|
||||
}
|
||||
pub fn cumulative(&self) -> &[u32] {
|
||||
&self.cumulative[..self.count]
|
||||
}
|
||||
/// The GPU draw has exactly 24 uniform bits. CPU quantizes its independent
|
||||
/// draw to the same bins; both divide by the represented bin width.
|
||||
pub fn select(&self, choice: f64) -> Result<(usize, f64)> {
|
||||
if !choice.is_finite() || !(0.0..1.0).contains(&choice) {
|
||||
return Err(invalid("photon source sample must be in [0,1)"));
|
||||
}
|
||||
let bin = (choice * f64::from(SOURCE_BINS)) as u32;
|
||||
let mut previous = 0;
|
||||
for (index, end) in self.cumulative().iter().copied().enumerate() {
|
||||
if bin < end {
|
||||
return Ok((index, f64::from(end - previous) / f64::from(SOURCE_BINS)));
|
||||
}
|
||||
previous = end;
|
||||
}
|
||||
Err(invalid("photon source sample exceeds its distribution"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn luminance(rgb: [f64; 3]) -> Result<f64> {
|
||||
if rgb.into_iter().any(|x| !x.is_finite() || x < 0.0) {
|
||||
return Err(invalid("photon source radiance must be finite and nonnegative"));
|
||||
}
|
||||
checked_power(0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2])
|
||||
}
|
||||
pub fn checked_power(value: f64) -> Result<f64> {
|
||||
if value.is_finite() && value >= 0.0 { Ok(value) } else { Err(invalid("photon source power exceeds finite bounds")) }
|
||||
}
|
||||
pub fn disk_power(radius: f64, integrated_radiance: f64) -> Result<f64> {
|
||||
if !radius.is_finite() || radius < 0.0 || !integrated_radiance.is_finite() || integrated_radiance < 0.0 {
|
||||
return Err(invalid("invalid photon launch disk power"));
|
||||
}
|
||||
checked_power(PI * radius * radius * integrated_radiance)
|
||||
}
|
||||
pub fn rectangle_power(area: f64, radiance: [f64; 3]) -> Result<f64> {
|
||||
if !area.is_finite() || area < 0.0 {
|
||||
return Err(invalid("invalid photon rectangle area"));
|
||||
}
|
||||
checked_power(PI * area * luminance(radiance)?)
|
||||
}
|
||||
pub fn punctual_power(light: &PunctualLightOptions) -> Result<f64> {
|
||||
light.validate()?;
|
||||
let solid_angle = light.cone.map_or(4.0 * PI, |spot| {
|
||||
// Integrate the cubic feather in cos(theta), including its flat core.
|
||||
// sin² forms retain narrow cones without subtracting nearly equal cosines.
|
||||
TAU * ((spot.inner_half_angle_radians * 0.5).sin().powi(2) + (spot.outer_half_angle_radians * 0.5).sin().powi(2))
|
||||
});
|
||||
checked_power(luminance(light.color)? * light.intensity * light.reference_distance_mm.powi(2) * solid_angle)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Shared render-quality controls and scene-linear supersample reconstruction.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{CancelToken, ErrorCode, JobError, Result};
|
||||
|
||||
pub const MAX_UI_SAMPLES: u32 = 16_384;
|
||||
pub const MAX_CPU_THREADS: u16 = 64;
|
||||
pub const MAX_OUTPUT_DIMENSION: u32 = 8192;
|
||||
pub const MAX_OUTPUT_PIXELS: u64 = 33_554_432;
|
||||
pub const MAX_TRACE_DIMENSION: u32 = MAX_OUTPUT_DIMENSION * 2;
|
||||
pub const MAX_TRACE_PIXELS: u64 = MAX_OUTPUT_PIXELS * 4;
|
||||
|
||||
/// Conservative display filtering. Raw radiance and alpha never change.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct DenoiseSettings {
|
||||
pub passes: u32,
|
||||
pub strength: f64,
|
||||
pub firefly_suppression: bool,
|
||||
}
|
||||
|
||||
impl Default for DenoiseSettings {
|
||||
fn default() -> Self {
|
||||
Self { passes: 3, strength: 1.0, firefly_suppression: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl DenoiseSettings {
|
||||
pub fn validate(self) -> Result<()> {
|
||||
if !(1..=3).contains(&self.passes) || !self.strength.is_finite() || !(0.0..=1.0).contains(&self.strength) {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "denoising requires 1..3 passes and strength 0..1"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn single_supersample(scale: &u32) -> bool {
|
||||
*scale == 1
|
||||
}
|
||||
|
||||
/// Output and transport bounds are distinct. A 2x setting changes both dimensions,
|
||||
/// never the selected output size. Byte/work/device admission remains the caller's.
|
||||
pub fn supersampled_dimensions(width: u32, height: u32, scale: u32) -> Result<(u32, u32)> {
|
||||
if !matches!(scale, 1 | 2) {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "supersampling must be 1 or 2"));
|
||||
}
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| width > MAX_OUTPUT_DIMENSION
|
||||
|| height > MAX_OUTPUT_DIMENSION
|
||||
|| u64::from(width) * u64::from(height) > MAX_OUTPUT_PIXELS
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "output requires 1..8192 sides and at most 33554432 pixels"));
|
||||
}
|
||||
let trace_width = width.checked_mul(scale).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "supersampled width overflow"))?;
|
||||
let trace_height = height.checked_mul(scale).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "supersampled height overflow"))?;
|
||||
if trace_width > MAX_TRACE_DIMENSION || trace_height > MAX_TRACE_DIMENSION || u64::from(trace_width) * u64::from(trace_height) > MAX_TRACE_PIXELS
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "supersampled trace grid exceeds its bounded dimensions"));
|
||||
}
|
||||
Ok((trace_width, trace_height))
|
||||
}
|
||||
|
||||
/// Resolve coverage-premultiplied linear RGBA. Dimensions describe the final
|
||||
/// output; the input contains (width*scale)*(height*scale) transport pixels.
|
||||
/// Each channel is averaged in f64 before a single f32 rounding. No exposure,
|
||||
/// unpremultiplication, denoising or tone mapping enters this operation.
|
||||
pub fn resolve_supersampling(width: u32, height: u32, scale: u32, linear: &[[f32; 4]], cancel: &CancelToken) -> Result<Vec<[f32; 4]>> {
|
||||
cancel.check()?;
|
||||
let (trace_width, trace_height) = supersampled_dimensions(width, height, scale)?;
|
||||
let input_count = usize::try_from(u64::from(trace_width) * u64::from(trace_height))
|
||||
.map_err(|_| JobError::new(ErrorCode::LimitExceeded, "supersampling input exceeds address space"))?;
|
||||
let count = usize::try_from(u64::from(width) * u64::from(height))
|
||||
.map_err(|_| JobError::new(ErrorCode::LimitExceeded, "supersampling output exceeds address space"))?;
|
||||
if linear.len() != input_count {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "supersampling requires the complete trace grid"));
|
||||
}
|
||||
let mut result = Vec::new();
|
||||
result.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "supersampling output allocation failed"))?;
|
||||
if result.capacity() != count {
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "supersampling output capacity exceeds admission"));
|
||||
}
|
||||
for y in 0..height as usize {
|
||||
cancel.check()?;
|
||||
for x in 0..width as usize {
|
||||
result.push(resolve_pixel(width, scale, linear, y * width as usize + x)?);
|
||||
}
|
||||
}
|
||||
cancel.check()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Fixed-size subpixel addresses for an already admitted output pixel.
|
||||
pub(crate) fn supersample_indices(width: u32, scale: u32, pixel: usize) -> Result<([usize; 4], usize)> {
|
||||
if width == 0 || !matches!(scale, 1 | 2) {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid supersampling pixel layout"));
|
||||
}
|
||||
let width = width as usize;
|
||||
let scale = scale as usize;
|
||||
let stride = width * scale;
|
||||
let base = (pixel / width) * scale * stride + (pixel % width) * scale;
|
||||
Ok(if scale == 1 { ([base; 4], 1) } else { ([base, base + 1, base + stride, base + stride + 1], 4) })
|
||||
}
|
||||
|
||||
/// One cooperative scene-linear resolve; the caller has admitted the full grid.
|
||||
pub(crate) fn resolve_pixel(width: u32, scale: u32, linear: &[[f32; 4]], pixel: usize) -> Result<[f32; 4]> {
|
||||
let (indices, count) = supersample_indices(width, scale, pixel)?;
|
||||
let mut sum = [0.0_f64; 4];
|
||||
for index in &indices[..count] {
|
||||
let pixel = linear.get(*index).ok_or_else(|| JobError::new(ErrorCode::Internal, "supersampling source pixel disappeared"))?;
|
||||
if pixel.iter().any(|value| !value.is_finite() || *value < 0.0) || pixel[3] > 1.0 || (pixel[3] == 0.0 && pixel[..3] != [0.0; 3]) {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "supersampling input has invalid radiance or coverage"));
|
||||
}
|
||||
for (sum, value) in sum.iter_mut().zip(pixel) {
|
||||
*sum += f64::from(*value);
|
||||
}
|
||||
}
|
||||
Ok(sum.map(|value| (value / count as f64) as f32))
|
||||
}
|
||||
@@ -190,3 +190,103 @@ fn emission_runtime_json_is_bounded_explicit_and_roundtrips() {
|
||||
assert_eq!(serde_json::from_str::<RaytraceOptions>("{}").unwrap().emissions, None);
|
||||
assert_eq!(serde_json::from_str::<RaytraceOptions>("{\"emissions\":[]}").unwrap().emissions, Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_quality_limits_are_explicit_finite_and_keep_transport_defaults() {
|
||||
assert_eq!(JobOptions::default().samples, 64);
|
||||
assert_eq!(JobOptions::default().max_depth, 8);
|
||||
let options = JobOptions {
|
||||
samples: MAX_SAMPLES_PER_PIXEL,
|
||||
max_depth: u32::from(MAX_PATH_DEPTH),
|
||||
caustics: Some(CausticOptions { photons: MAX_CAUSTIC_PHOTONS, max_depth: MAX_PATH_DEPTH, ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
options.validate().unwrap();
|
||||
assert!(JobOptions { samples: MAX_SAMPLES_PER_PIXEL + 1, ..options }.validate().is_err());
|
||||
assert!(JobOptions { max_depth: u32::from(MAX_PATH_DEPTH) + 1, ..options }.validate().is_err());
|
||||
assert!(CpuPathOptions { max_depth: MAX_PATH_DEPTH }.validate().is_ok());
|
||||
assert!(CpuPathOptions { max_depth: MAX_PATH_DEPTH + 1 }.validate().is_err());
|
||||
assert!(
|
||||
CpuBudget::Workstation.validate_image(3840, 2160, Some(CpuProgressiveOptions { samples_per_pixel: MAX_SAMPLES_PER_PIXEL, seed: 81 })).is_ok()
|
||||
);
|
||||
assert!(CpuBudget::Studio.validate_samples(MAX_SAMPLES_PER_PIXEL).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quality_controls_preserve_authored_values_and_validate_finite_envelopes() {
|
||||
let cpu: RaytraceOptions = serde_json::from_value(serde_json::json!({
|
||||
"threads":4,"supersampling":2,"denoise":true,"denoise_diagnostics":true,
|
||||
"denoise_settings":{"passes":2,"strength":0.5,"firefly_suppression":false}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(cpu.threads, 4);
|
||||
assert_eq!(cpu.supersampling, 2);
|
||||
assert!(cpu.denoise_diagnostics);
|
||||
cpu.validate().unwrap();
|
||||
assert_eq!(serde_json::from_value::<RaytraceOptions>(serde_json::to_value(&cpu).unwrap()).unwrap(), cpu);
|
||||
for threads in [0, 1, MAX_CPU_THREADS] {
|
||||
assert!(RaytraceOptions { threads, ..Default::default() }.validate().is_ok());
|
||||
}
|
||||
assert!(RaytraceOptions { threads: MAX_CPU_THREADS + 1, ..Default::default() }.validate().is_err());
|
||||
for supersampling in [0, 3, u32::MAX] {
|
||||
assert!(RaytraceOptions { supersampling, ..Default::default() }.validate().is_err());
|
||||
assert!(JobOptions { supersampling, ..Default::default() }.validate().is_err());
|
||||
}
|
||||
for settings in [
|
||||
DenoiseSettings { passes: 0, ..Default::default() },
|
||||
DenoiseSettings { passes: 4, ..Default::default() },
|
||||
DenoiseSettings { strength: f64::NAN, ..Default::default() },
|
||||
DenoiseSettings { strength: -0.01, ..Default::default() },
|
||||
DenoiseSettings { strength: 1.01, ..Default::default() },
|
||||
] {
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
for options in [
|
||||
JobOptions { supersampling: 2, ..Default::default() },
|
||||
JobOptions { denoise_diagnostics: true, ..Default::default() },
|
||||
JobOptions { denoise_settings: DenoiseSettings { strength: 0.5, ..Default::default() }, ..Default::default() },
|
||||
] {
|
||||
assert!(!options.is_structural_default());
|
||||
assert_eq!(options.retained_bytes().unwrap(), std::mem::size_of::<JobOptions>() as u64);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_and_internal_sampling_bounds_are_checked_without_allocating_images() {
|
||||
assert_eq!(supersampled_dimensions(7680, 4320, 2).unwrap(), (15360, 8640));
|
||||
assert_eq!(supersampled_dimensions(8192, 4096, 2).unwrap(), (16384, 8192));
|
||||
assert!(supersampled_dimensions(8193, 1, 2).is_err());
|
||||
assert!(supersampled_dimensions(8192, 4097, 2).is_err());
|
||||
assert!(supersampled_dimensions(u32::MAX, u32::MAX, 2).is_err());
|
||||
assert!(linear_exr::encoded_limit(7680, 4320).is_ok());
|
||||
assert!(linear_exr::encoded_limit(8192, 4096).is_ok());
|
||||
assert!(linear_exr::encoded_limit(8192, 4097).is_err());
|
||||
assert!(validate_cpu_denoise_image(15360, 8640).is_ok());
|
||||
assert!(validate_cpu_denoise_image(16384, 8192).is_ok());
|
||||
assert!(validate_cpu_denoise_image(16384, 8193).is_err());
|
||||
assert!(CpuBudget::Workstation.validate_image(15360, 8640, Some(CpuProgressiveOptions { samples_per_pixel: 128, seed: 0 })).is_ok());
|
||||
assert!(CpuBudget::Compact.validate_samples(128).is_err(), "Compact keeps its explicit resource envelope");
|
||||
assert!(CpuBudget::Studio.validate_samples(128).is_ok());
|
||||
assert!(CpuBudget::Workstation.validate_image(16384, 1, None).is_ok(), "scale1 native backend dimensions remain compatible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_masks_keep_exact_categories_and_reports_use_readable_keys() {
|
||||
use DenoiseGuideClass::*;
|
||||
assert_eq!(resolve_denoise_class([Surface; 4]), Surface);
|
||||
assert_eq!(resolve_denoise_class([Optical; 4]), Optical);
|
||||
assert_eq!(resolve_denoise_class([Surface, Optical]), Unavailable);
|
||||
assert_eq!(resolve_denoise_class([Surface, Optical, Background, Emitter]), Background);
|
||||
let mut report = DenoiseDiagnostics::new(2, 2);
|
||||
for class in [Surface, Optical, PartialCoverage, Background] {
|
||||
report.record(class);
|
||||
}
|
||||
let json = serde_json::to_value(report).unwrap();
|
||||
assert_eq!(json["counts"]["partial_coverage"], 1);
|
||||
assert_eq!(json["counts"]["surface"], 1);
|
||||
assert_eq!(report.counts.iter().sum::<u64>(), 4);
|
||||
for class in DenoiseGuideClass::ALL {
|
||||
assert_eq!(DenoiseGuideClass::from_code(class.code()), Some(*class));
|
||||
assert_eq!(class.color()[3], 255);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
pub const MAX_TEXTURE_BINDINGS: usize = 128;
|
||||
/// Capture is not residency: each producer still prepays its complete working set.
|
||||
/// Native jobs can retain OPUS's 296 MiB of unique float4 source interpretations.
|
||||
#[cfg(all(not(target_arch = "wasm32"), target_pointer_width = "64"))]
|
||||
pub const MAX_TEXTURE_PIXELS_BYTES: u64 = 512 * 1024 * 1024;
|
||||
#[cfg(any(target_arch = "wasm32", not(target_pointer_width = "64")))]
|
||||
pub const MAX_TEXTURE_PIXELS_BYTES: u64 = 96 * 1024 * 1024;
|
||||
pub const MAX_TEXTURE_SOURCE_BYTES: u64 = 64 * 1024 * 1024;
|
||||
pub const MAX_PLANAR_REPEAT: f64 = 128.0;
|
||||
@@ -28,6 +33,23 @@ fn v(a: [f64; 3]) -> Vec3 {
|
||||
Vec3::new(a[0], a[1], a[2])
|
||||
}
|
||||
|
||||
/// Preserve the tangent direction and make its length meaningful before averaging.
|
||||
/// Invalid/back-facing samples use the same flat fallback as mapped shading.
|
||||
pub fn unit_normal_texel(rgb: [f32; 3]) -> [f32; 3] {
|
||||
let signed = v(rgb.map(|channel| 2.0 * f64::from(channel) - 1.0));
|
||||
if signed.z <= 0.0 {
|
||||
return [0.5, 0.5, 1.0];
|
||||
}
|
||||
let Ok(normal) = unit_direction(signed) else { return [0.5, 0.5, 1.0] };
|
||||
[normal.x, normal.y, normal.z].map(|channel| (0.5 * channel + 0.5) as f32)
|
||||
}
|
||||
|
||||
/// For an average of unit normals, E[|n|²] is one. Its loss of squared
|
||||
/// length measures direction variance without an additional texture plane.
|
||||
pub fn normal_direction_variance(mean_rgb: [f64; 3]) -> f64 {
|
||||
(1.0 - v(mean_rgb.map(|channel| 2.0 * channel - 1.0)).len2()).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// `u` and `v` span one full repeat in world-space millimetres. Image v increases
|
||||
/// downward; normal-map green points along the caller's positive v direction.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -146,7 +168,7 @@ impl TextureMap {
|
||||
pub fn dimensions(&self) -> [u32; 2] {
|
||||
[self.source.info().width, self.source.info().height]
|
||||
}
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
#[cfg(any(test, feature = "raytrace-cpu"))]
|
||||
pub(crate) fn source(&self) -> &TextureSource {
|
||||
&self.source
|
||||
}
|
||||
@@ -154,6 +176,11 @@ impl TextureMap {
|
||||
self.source.info().role
|
||||
}
|
||||
pub fn decode(&self, cancel: &CancelToken) -> Result<Vec<f32>> {
|
||||
self.decode_with_source(cancel).map(|(_, linear)| linear)
|
||||
}
|
||||
/// Decode once and retain exact source bytes for compact GPU upload alongside
|
||||
/// normalized linear pixels for mip generation.
|
||||
pub fn decode_with_source(&self, cancel: &CancelToken) -> Result<(crate::texture_input::DecodedTexture, Vec<f32>)> {
|
||||
cancel.check()?;
|
||||
let decoded = self.source.decode(|| cancel.is_cancelled()).map_err(|e| {
|
||||
if cancel.is_cancelled() { JobError::new(ErrorCode::Cancelled, "texture decoding cancelled") } else { invalid(e.to_string()) }
|
||||
@@ -169,7 +196,7 @@ impl TextureMap {
|
||||
rgba.push(linear as f32);
|
||||
}
|
||||
}
|
||||
Ok(rgba)
|
||||
Ok((decoded, rgba))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,12 +235,20 @@ pub struct RuntimeTextures {
|
||||
bindings: Vec<TextureBinding>,
|
||||
source_bytes: u64,
|
||||
pixel_bytes: u64,
|
||||
mip_pixel_bytes: u64,
|
||||
portable_tail_bytes: u64,
|
||||
filtering: bool,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct TextureSummary {
|
||||
pub bodies: usize,
|
||||
pub encoded_bytes: u64,
|
||||
pub linear_pixel_bytes: u64,
|
||||
/// Complete float4 pyramid, including base pixels.
|
||||
pub mip_pixel_bytes: u64,
|
||||
/// GPU pixels for the selected policy, excluding records and upload scratch.
|
||||
pub gpu_pixel_bytes: u64,
|
||||
pub filtering: bool,
|
||||
}
|
||||
impl RuntimeTextures {
|
||||
pub fn new(mut bindings: Vec<TextureBinding>) -> Result<Self> {
|
||||
@@ -223,6 +258,8 @@ impl RuntimeTextures {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut source_bytes = 0u64;
|
||||
let mut pixel_bytes = 0u64;
|
||||
let mut mip_pixel_bytes = 0u64;
|
||||
let mut portable_tail_bytes = 0u64;
|
||||
let mut shared = std::collections::BTreeMap::<([u8; 32], u8), TextureMap>::new();
|
||||
for binding in &mut bindings {
|
||||
binding.frame.validate()?;
|
||||
@@ -255,22 +292,65 @@ impl RuntimeTextures {
|
||||
source_bytes = source_bytes.checked_add(map.retained_bytes as u64).ok_or_else(|| invalid("texture source accounting overflow"))?;
|
||||
let decoded = map.source.info().rgba_bytes.checked_mul(4).ok_or_else(|| invalid("texture pixel accounting overflow"))?;
|
||||
pixel_bytes = pixel_bytes.checked_add(decoded).ok_or_else(|| invalid("texture pixel accounting overflow"))?;
|
||||
let [mut width, mut height] = map.dimensions();
|
||||
let mip_start = mip_pixel_bytes;
|
||||
loop {
|
||||
mip_pixel_bytes = mip_pixel_bytes
|
||||
.checked_add(u64::from(width) * u64::from(height) * 16)
|
||||
.ok_or_else(|| invalid("texture mip accounting overflow"))?;
|
||||
if width == 1 && height == 1 {
|
||||
break;
|
||||
}
|
||||
width = (width / 2).max(1);
|
||||
height = (height / 2).max(1);
|
||||
}
|
||||
if map.gpu_mip_filtering() {
|
||||
portable_tail_bytes = portable_tail_bytes
|
||||
.checked_add(mip_pixel_bytes - mip_start - decoded)
|
||||
.ok_or_else(|| invalid("portable texture mip accounting overflow"))?;
|
||||
}
|
||||
if source_bytes > MAX_TEXTURE_SOURCE_BYTES || pixel_bytes > MAX_TEXTURE_PIXELS_BYTES {
|
||||
return Err(invalid("unique texture sources exceed 64 MiB encoded or 96 MiB linear pixels"));
|
||||
return Err(invalid(format!(
|
||||
"unique texture sources exceed the host capture limit: {source_bytes}/{} encoded bytes, {pixel_bytes}/{} linear base bytes; select a larger native host or reduce explicitly authored sources",
|
||||
MAX_TEXTURE_SOURCE_BYTES, MAX_TEXTURE_PIXELS_BYTES
|
||||
)));
|
||||
}
|
||||
shared.insert(identity, map.clone());
|
||||
}
|
||||
}
|
||||
if source_bytes > MAX_TEXTURE_SOURCE_BYTES || pixel_bytes > MAX_TEXTURE_PIXELS_BYTES {
|
||||
return Err(invalid("unique texture sources exceed 64 MiB encoded or 96 MiB linear pixel budget"));
|
||||
return Err(invalid("unique texture sources exceed this host capture budget"));
|
||||
}
|
||||
Ok(Self { bindings, source_bytes, pixel_bytes })
|
||||
Ok(Self { bindings, source_bytes, pixel_bytes, mip_pixel_bytes, portable_tail_bytes, filtering: false })
|
||||
}
|
||||
/// Opt in to anisotropic filtering and compact GPU storage. Otherwise the
|
||||
/// producer preserves its existing sampling policy, including portable mips.
|
||||
pub fn with_filtering(mut self, enabled: bool) -> Self {
|
||||
self.filtering = enabled;
|
||||
self
|
||||
}
|
||||
pub fn filtering(&self) -> bool {
|
||||
self.filtering
|
||||
}
|
||||
pub fn bindings(&self) -> &[TextureBinding] {
|
||||
&self.bindings
|
||||
}
|
||||
pub fn summary(&self) -> TextureSummary {
|
||||
TextureSummary { bodies: self.bindings.len(), encoded_bytes: self.source_bytes, linear_pixel_bytes: self.pixel_bytes }
|
||||
TextureSummary {
|
||||
bodies: self.bindings.len(),
|
||||
encoded_bytes: self.source_bytes,
|
||||
linear_pixel_bytes: self.pixel_bytes,
|
||||
mip_pixel_bytes: self.mip_pixel_bytes,
|
||||
gpu_pixel_bytes: self.gpu_pixel_bytes(),
|
||||
filtering: self.filtering,
|
||||
}
|
||||
}
|
||||
fn gpu_pixel_bytes(&self) -> u64 {
|
||||
if self.filtering {
|
||||
self.pixel_bytes / 4 + (self.mip_pixel_bytes - self.pixel_bytes) / 2
|
||||
} else {
|
||||
self.pixel_bytes + self.portable_tail_bytes
|
||||
}
|
||||
}
|
||||
/// Actual/conservative captured source, binding, name and control-header
|
||||
/// backing. Available before a CPU or registered executor consumes maps.
|
||||
@@ -298,7 +378,9 @@ impl RuntimeTextures {
|
||||
/// Source snapshots, codec scratch, decoded bytes, staging and device buffer,
|
||||
/// plus lookup records. Conservatively includes overlapping preparation.
|
||||
pub fn working_bytes(&self) -> u64 {
|
||||
self.source_bytes + self.pixel_bytes * 4 + 64 * 1024 * 1024 + 1024 * 1024
|
||||
// Complete float preparation overlaps one packed host buffer, upload staging
|
||||
// and device residency. The decoder is sequential, never one per source.
|
||||
self.source_bytes + self.mip_pixel_bytes + self.gpu_pixel_bytes() * 3 + 64 * 1024 * 1024 + 8 * 1024 * 1024 + 2 * 1024 * 1024
|
||||
}
|
||||
pub fn validate_scene(&self, scene: &ProductScene) -> Result<()> {
|
||||
for binding in &self.bindings {
|
||||
@@ -340,6 +422,26 @@ pub fn validate_cpu_options(options: &super::RaytraceOptions) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Approximate a secondary ray's pixel cone by projecting two perpendicular
|
||||
/// spans onto the receiver plane. Exact primary ray differentials stay separate.
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
pub(crate) fn secondary_footprint(camera: &crate::Camera, height: u32, distance: f64, direction: Vec3, normal: Vec3) -> Result<Option<[Vec3; 2]>> {
|
||||
let pixel = 2.0 * camera.half_height() / f64::from(height);
|
||||
let diameter = if camera.fov > 0.0 { pixel * distance / camera.distance } else { pixel };
|
||||
if height == 0 || !diameter.is_finite() || diameter < 0.0 {
|
||||
return Err(invalid("invalid texture camera footprint"));
|
||||
}
|
||||
let cosine = normal.dot(direction);
|
||||
if cosine.abs() <= 1e-6 {
|
||||
return Ok(None);
|
||||
}
|
||||
let axis = if direction.z.abs() < 0.9 { Vec3::Z } else { Vec3::X };
|
||||
let tangent = unit_direction(direction.cross(axis)).map_err(|error| invalid(error.to_string()))?;
|
||||
let project = |delta: Vec3| (delta - direction * (normal.dot(delta) / cosine)) * diameter;
|
||||
let footprint = [project(tangent), project(direction.cross(tangent))];
|
||||
Ok(footprint.iter().all(|d| d.is_finite()).then_some(footprint))
|
||||
}
|
||||
|
||||
/// 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()))
|
||||
@@ -504,6 +606,53 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(not(target_arch = "wasm32"), target_pointer_width = "64"))]
|
||||
fn opus_texture_inventory_admits_exact_full_pyramids_without_dropping_sources() {
|
||||
let entries = (0_u8..74)
|
||||
.map(|index| {
|
||||
let mut entry = binding();
|
||||
entry.body_index = usize::from(index);
|
||||
entry.body_name = format!("OPUS material {index}");
|
||||
entry.base_color = None;
|
||||
entry.roughness = Some(TextureMap::new(png(512, 512, &[index, 128, 64, 255].repeat(512 * 512)), MapRole::Roughness).unwrap());
|
||||
entry
|
||||
})
|
||||
.collect();
|
||||
let captured = RuntimeTextures::new(entries).unwrap().with_filtering(true);
|
||||
let summary = captured.summary();
|
||||
assert_eq!(summary.bodies, 74);
|
||||
assert_eq!(summary.linear_pixel_bytes, 310_378_496);
|
||||
assert_eq!(summary.mip_pixel_bytes, 413_837_600);
|
||||
assert_eq!(summary.gpu_pixel_bytes, 129_324_176);
|
||||
assert!(summary.gpu_pixel_bytes < 128 * 1024 * 1024);
|
||||
assert!(summary.filtering);
|
||||
assert!(captured.working_bytes() >= summary.mip_pixel_bytes + 3 * summary.gpu_pixel_bytes + summary.encoded_bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn production_filter_smooths_the_major_axis_and_preserves_minor_axis_detail() {
|
||||
let mut entry = binding();
|
||||
entry.frame = PlanarFrame { origin: [0.0; 3], u: [1.0, 0.0, 0.0], v: [0.0, 1.0, 0.0] };
|
||||
let pixels: Vec<_> =
|
||||
(0..64).flat_map(|index| [if index % 2 == 0 { 0 } else { 255 }, if index / 8 % 2 == 0 { 0 } else { 255 }, 0, 255]).collect();
|
||||
let source = TextureMap::new(png(8, 8, &pixels), MapRole::BaseColor).unwrap();
|
||||
entry.base_color = Some(source.clone());
|
||||
let (prepared, _, _) = prepare_cpu_map(source, 3);
|
||||
assert_eq!(prepared.sample([0.0625, 0.0625], 0.0).unwrap(), [0.0; 3]);
|
||||
for u in [-0.9375, 0.0625, 0.17, 12.0625] {
|
||||
let (rgb, _) = prepared.sample_anisotropic([u, 0.0625], [1.0, 0.0], [0.0, 0.03125]).unwrap();
|
||||
assert!((rgb[0] - 0.5).abs() < 1e-10, "{rgb:?}");
|
||||
assert_eq!(rgb[1], 0.0, "minor-axis texel detail must survive");
|
||||
}
|
||||
let camera = crate::Camera { fov: std::f64::consts::FRAC_PI_2, distance: 10.0, ..Default::default() };
|
||||
let front = secondary_footprint(&camera, 100, 20.0, -Vec3::Z, Vec3::Z).unwrap().unwrap();
|
||||
assert!(front.iter().all(|v| (v.len() - 0.4).abs() < 1e-12));
|
||||
assert_eq!(front, secondary_footprint(&camera, 100, 20.0, -Vec3::Z, -Vec3::Z).unwrap().unwrap());
|
||||
assert!(secondary_footprint(&camera, 100, 20.0, Vec3::X, Vec3::Z).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_image_up_coordinates_do_not_invert_the_normal_basis() {
|
||||
let mut entry = binding();
|
||||
@@ -546,8 +695,13 @@ mod tests {
|
||||
}
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn prepare_cpu_map(map: TextureMap, quantum: usize) -> (cpu_preparation::PreparedMap, u64, u64) {
|
||||
prepare_cpu_map_with_quality(map, quantum, false)
|
||||
}
|
||||
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn prepare_cpu_map_with_quality(map: TextureMap, quantum: usize, quality: bool) -> (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();
|
||||
let mut task = cpu_preparation::MapTask::new_with_quality(map, bytes, CancelToken::default(), quality).unwrap();
|
||||
assert_eq!(task.working_bytes(), bytes);
|
||||
let mut work = 0;
|
||||
for _ in 0..100_000 {
|
||||
@@ -694,6 +848,42 @@ mod tests {
|
||||
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_quality_normal_mips_broaden_only_unresolved_detail() {
|
||||
use crate::{clear_coat::ClearCoat, surface::OpaqueSurface, surface_patterns::ShadingSurface};
|
||||
let roughness = 0.2_f64;
|
||||
let strength = 0.65_f64;
|
||||
let original =
|
||||
OpaqueSurface::new(Vec3::new(0.8, 0.6, 0.4), 0.2, roughness, 1.7).unwrap().with_clear_coat(ClearCoat::new(0.7, 0.2, 1.4).unwrap());
|
||||
let surface = ShadingSurface { surface: original, shading_normal: Vec3::Z };
|
||||
let point = cpu_preparation::MapPoint { position: Vec3::new(1.0, 2.0, 0.0), geometric_normal: Vec3::Z, view_direction: Vec3::Z };
|
||||
// Opposite X directions have signed components (±205, 1, 205)/255.
|
||||
// Their unit mean loses exactly the squared X component.
|
||||
let expected_variance = 205_f64.powi(2) / (2.0 * 205_f64.powi(2) + 1.0);
|
||||
let expected_roughness = (roughness.powi(4) + strength.powi(2) * expected_variance).powf(0.25);
|
||||
for (quality, mixed, normal_strength, lod, expected) in [
|
||||
(true, true, strength, 1.0, expected_roughness),
|
||||
(true, true, strength, 0.0, roughness),
|
||||
(true, false, strength, 1.0, roughness),
|
||||
(true, true, 0.0, 1.0, roughness),
|
||||
(false, true, strength, 1.0, roughness),
|
||||
] {
|
||||
let second = if mixed { 25 } else { 230 };
|
||||
let map = TextureMap::new(png(2, 1, &[230, 128, 230, 0, second, 128, 230, 255]), MapRole::NormalOpenGl).unwrap();
|
||||
let (prepared, _, _) = prepare_cpu_map_with_quality(map.clone(), 3, quality);
|
||||
let mut binding = binding();
|
||||
binding.normal = Some(map);
|
||||
binding.normal_strength = normal_strength;
|
||||
let mapped = prepared.apply(&binding, point, surface, lod).unwrap().surface;
|
||||
assert!((mapped.roughness() - expected).abs() < 1e-6);
|
||||
assert_eq!(mapped.clear_coat(), original.clear_coat());
|
||||
assert_eq!(mapped.base_color(), original.base_color());
|
||||
assert_eq!(mapped.metallic(), original.metallic());
|
||||
assert_eq!(mapped.dielectric_ior(), original.dielectric_ior());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
fn cpu_map_material_roles_preserve_coat_ior_and_apply_normal_frame_and_flip_explicitly() {
|
||||
|
||||
@@ -52,8 +52,8 @@ struct Hit {
|
||||
}
|
||||
pub(crate) struct CpuTextureCache {
|
||||
resources: Arc<RuntimeTextures>,
|
||||
maps: Vec<PreparedMap>,
|
||||
bindings: Vec<Binding>,
|
||||
maps: Arc<Vec<PreparedMap>>,
|
||||
bindings: Arc<Vec<Binding>>,
|
||||
current: Vec<MapTask>,
|
||||
binding: usize,
|
||||
role: usize,
|
||||
@@ -75,6 +75,9 @@ impl CpuTextureCache {
|
||||
/// 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)?;
|
||||
if resources.filtering() != options.texture_filtering {
|
||||
return Err(invalid("captured CPU texture filtering does not match the requested policy"));
|
||||
}
|
||||
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"));
|
||||
@@ -91,10 +94,10 @@ impl CpuTextureCache {
|
||||
let slots = Self::slots(resources) as u64;
|
||||
resources
|
||||
.cpu_source_bytes()?
|
||||
.checked_add(size_of::<Self>() as u64)
|
||||
.checked_add(size_of::<Self>() as u64 + 2 * (size_of::<Vec<u8>>() + 2 * size_of::<usize>()) 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(resources.summary().mip_pixel_bytes))
|
||||
.and_then(|n| n.checked_add(MapTask::row_workspace_bytes(width)))
|
||||
.ok_or_else(limit)
|
||||
}
|
||||
@@ -107,8 +110,8 @@ impl CpuTextureCache {
|
||||
})
|
||||
})?;
|
||||
Ok(Self {
|
||||
maps: reserve(Self::slots(&resources))?,
|
||||
bindings: reserve(resources.bindings().len())?,
|
||||
maps: Arc::new(reserve(Self::slots(&resources))?),
|
||||
bindings: Arc::new(reserve(resources.bindings().len())?),
|
||||
current: reserve(1)?,
|
||||
resources,
|
||||
binding: 0,
|
||||
@@ -120,6 +123,26 @@ impl CpuTextureCache {
|
||||
total_rows,
|
||||
})
|
||||
}
|
||||
/// Prepared texels and binding tables are shared; each worker owns only its hit.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn fork_ready(&self) -> Result<Self> {
|
||||
if !self.ready {
|
||||
return Err(invalid("CPU worker requires completely prepared texture maps"));
|
||||
}
|
||||
Ok(Self {
|
||||
resources: self.resources.clone(),
|
||||
maps: self.maps.clone(),
|
||||
bindings: self.bindings.clone(),
|
||||
current: Vec::new(),
|
||||
binding: self.binding,
|
||||
role: self.role,
|
||||
scan: 0,
|
||||
ready: true,
|
||||
hit: None,
|
||||
prepared_rows: self.prepared_rows,
|
||||
total_rows: self.total_rows,
|
||||
})
|
||||
}
|
||||
pub(crate) fn ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
@@ -142,9 +165,9 @@ impl CpuTextureCache {
|
||||
return Err(limit());
|
||||
}
|
||||
let index = self.maps.len();
|
||||
self.maps.push(prepared);
|
||||
*self
|
||||
.bindings
|
||||
Arc::get_mut(&mut self.maps).ok_or_else(|| invalid("CPU prepared maps are shared before preparation completed"))?.push(prepared);
|
||||
*Arc::get_mut(&mut self.bindings)
|
||||
.ok_or_else(|| invalid("CPU map bindings are shared before preparation completed"))?
|
||||
.get_mut(self.binding)
|
||||
.and_then(|b| b.maps.get_mut(self.role))
|
||||
.ok_or_else(|| invalid("CPU map binding cursor disappeared"))? = Some(index);
|
||||
@@ -157,7 +180,9 @@ impl CpuTextureCache {
|
||||
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);
|
||||
Arc::get_mut(&mut self.bindings)
|
||||
.ok_or_else(|| invalid("CPU map bindings are shared before preparation completed"))?
|
||||
.sort_unstable_by_key(|b| b.body);
|
||||
self.ready = true;
|
||||
return Ok(true);
|
||||
}
|
||||
@@ -166,7 +191,11 @@ impl CpuTextureCache {
|
||||
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] });
|
||||
Arc::get_mut(&mut self.bindings).ok_or_else(|| invalid("CPU map bindings are shared before preparation completed"))?.push(Binding {
|
||||
body: source.body_index,
|
||||
source: self.binding,
|
||||
maps: [None; 4],
|
||||
});
|
||||
return Ok(false);
|
||||
}
|
||||
if self.role == 4 {
|
||||
@@ -185,8 +214,8 @@ impl CpuTextureCache {
|
||||
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
|
||||
*Arc::get_mut(&mut self.bindings)
|
||||
.ok_or_else(|| invalid("CPU map bindings are shared before preparation completed"))?
|
||||
.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);
|
||||
@@ -198,7 +227,7 @@ impl CpuTextureCache {
|
||||
}
|
||||
} else {
|
||||
let bytes = MapTask::required_bytes(source)?;
|
||||
self.current.push(MapTask::new(source.clone(), bytes, cancel.clone())?);
|
||||
self.current.push(MapTask::new_with_quality(source.clone(), bytes, cancel.clone(), self.resources.filtering())?);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
@@ -235,24 +264,25 @@ impl CpuTextureCache {
|
||||
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 {
|
||||
let gradients = hit.footprint.and_then(|[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));
|
||||
dx.iter().chain(&dy).all(|value| value.is_finite() && value.abs() <= crate::texture_lod::MAX_UV_GRADIENT).then_some((dx, dy))
|
||||
});
|
||||
hit.current = match gradients {
|
||||
Some((dx, dy)) if self.resources.filtering() => map.apply_anisotropic(binding, hit.point, hit.current, dx, dy)?,
|
||||
Some((dx, dy)) => {
|
||||
let lod =
|
||||
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
|
||||
}
|
||||
.lod;
|
||||
map.apply(binding, hit.point, hit.current, lod)?
|
||||
}
|
||||
None => map.apply(binding, hit.point, hit.current, map.level_count().saturating_sub(1) as f64)?,
|
||||
};
|
||||
hit.current = map.apply(binding, hit.point, hit.current, lod)?;
|
||||
}
|
||||
hit.role += 1;
|
||||
Ok(hit.role == 4)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! 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 super::{BaseColorMode, TextureBinding, TextureMap, normal_direction_variance, unit_normal_texel};
|
||||
use crate::{
|
||||
job::{CancelToken, ErrorCode, JobError, Result},
|
||||
surface::{MIN_ROUGHNESS, OpaqueSurface, srgb8_to_linear},
|
||||
surface::{MIN_ROUGHNESS, srgb8_to_linear},
|
||||
surface_patterns::ShadingSurface,
|
||||
texture_input::{MapRole, png_metadata::PngMetadata},
|
||||
texture_sampling::{TextureView, mips},
|
||||
@@ -119,6 +119,7 @@ pub struct MapTask {
|
||||
stage: Stage,
|
||||
working_bytes: u64,
|
||||
full_mips: bool,
|
||||
normal_variance: bool,
|
||||
}
|
||||
impl MapTask {
|
||||
/// Source capacity + existing codec allowance + exact base/tail capacities,
|
||||
@@ -159,9 +160,12 @@ impl MapTask {
|
||||
.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)
|
||||
Self::new_with_quality(map, max_bytes, cancel, false)
|
||||
}
|
||||
fn begin(map: TextureMap, max_bytes: u64, cancel: CancelToken, full_mips: bool) -> Result<Self> {
|
||||
pub(crate) fn new_with_quality(map: TextureMap, max_bytes: u64, cancel: CancelToken, quality: bool) -> Result<Self> {
|
||||
Self::begin(map, max_bytes, cancel, true, quality)
|
||||
}
|
||||
fn begin(map: TextureMap, max_bytes: u64, cancel: CancelToken, full_mips: bool, quality: 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 {
|
||||
@@ -201,6 +205,7 @@ impl MapTask {
|
||||
stage: Stage::Metadata,
|
||||
working_bytes,
|
||||
full_mips,
|
||||
normal_variance: quality && role == MapRole::NormalOpenGl,
|
||||
})
|
||||
}
|
||||
pub fn working_bytes(&self) -> u64 {
|
||||
@@ -330,6 +335,10 @@ impl MapTask {
|
||||
f32::from(rgba[index]) / 255.0
|
||||
};
|
||||
}
|
||||
if self.normal_variance {
|
||||
let normal = unit_normal_texel([pixel[0], pixel[1], pixel[2]]);
|
||||
pixel[..3].copy_from_slice(&normal);
|
||||
}
|
||||
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 {
|
||||
@@ -389,7 +398,7 @@ impl MapTask {
|
||||
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 })
|
||||
Ok(PreparedMap { identity: self.map.identity(), role: self.map.role(), levels: self.levels, normal_variance: self.normal_variance })
|
||||
}
|
||||
}
|
||||
fn push_pixel(level: &mut Level, pixel: [f32; 4]) -> Result<()> {
|
||||
@@ -413,13 +422,15 @@ pub struct PreparedMap {
|
||||
identity: ([u8; 32], u8),
|
||||
role: MapRole,
|
||||
levels: Vec<Level>,
|
||||
normal_variance: bool,
|
||||
}
|
||||
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)
|
||||
let recipe = if self.normal_variance { "solidcraft.unit-normal-area-mips/1" } else { mips::MIP_RECIPE_ID };
|
||||
(self.identity, recipe)
|
||||
}
|
||||
pub fn level_count(&self) -> usize {
|
||||
self.levels.len()
|
||||
@@ -457,9 +468,68 @@ impl PreparedMap {
|
||||
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.
|
||||
/// Filter along the major axis of an unwrapped UV footprint with at most
|
||||
/// eight taps. The minor axis sets the trilinear mip level.
|
||||
pub(crate) fn sample_anisotropic(&self, uv: [f64; 2], dx: [f64; 2], dy: [f64; 2]) -> Result<([f64; 3], f64)> {
|
||||
let [width, height] = self.dimensions(0).ok_or_else(|| invalid("CPU map base dimensions missing"))?;
|
||||
let size = [f64::from(width), f64::from(height)];
|
||||
let u = [dx[0] * size[0], dy[0] * size[0]];
|
||||
let v = [dx[1] * size[1], dy[1] * size[1]];
|
||||
let dot = |a: [f64; 2], b: [f64; 2]| a[0] * b[0] + a[1] * b[1];
|
||||
let (a, b, c) = (dot(u, u), dot(u, v), dot(v, v));
|
||||
let separation = (a - c).hypot(2.0 * b);
|
||||
let eigenvalue = (a + c + separation) * 0.5;
|
||||
let major = eigenvalue.max(0.0).sqrt();
|
||||
let minor = ((a + c - separation) * 0.5).max(0.0).sqrt();
|
||||
let filter_width = minor.max(major / 8.0).max(1.0);
|
||||
let last = (self.level_count() - 1) as f64;
|
||||
let lod = filter_width.log2().clamp(0.0, last);
|
||||
let minification = if last == 0.0 { 0.0 } else { lod.max(major - 1.0).clamp(0.0, 1.0) };
|
||||
if lod == last || major <= 1.0 {
|
||||
return Ok((self.sample(uv, lod)?, minification));
|
||||
}
|
||||
let eigen = [b, eigenvalue - a];
|
||||
let eigen_length = eigen[0].hypot(eigen[1]);
|
||||
let axis = if eigen_length > 1e-12 {
|
||||
[eigen[0] / eigen_length, eigen[1] / eigen_length]
|
||||
} else if a >= c {
|
||||
[1.0, 0.0]
|
||||
} else {
|
||||
[0.0, 1.0]
|
||||
};
|
||||
let taps = (major / filter_width).ceil().clamp(1.0, 8.0) as usize;
|
||||
let mut result = [0.0; 3];
|
||||
for tap in 0..taps {
|
||||
let offset = ((tap as f64 + 0.5) / taps as f64 - 0.5) * major;
|
||||
let sample = self.sample([uv[0] + axis[0] * offset / size[0], uv[1] + axis[1] * offset / size[1]], lod)?;
|
||||
for channel in 0..3 {
|
||||
result[channel] += sample[channel] / taps as f64;
|
||||
}
|
||||
}
|
||||
Ok((result, minification))
|
||||
}
|
||||
pub fn apply(&self, binding: &TextureBinding, point: MapPoint, original: ShadingSurface, lod: f64) -> Result<ShadingSurface> {
|
||||
self.apply_sample(binding, point, original, self.sample(binding.uv(point.position)?, lod)?, lod.clamp(0.0, 1.0))
|
||||
}
|
||||
pub(crate) fn apply_anisotropic(
|
||||
&self,
|
||||
binding: &TextureBinding,
|
||||
point: MapPoint,
|
||||
original: ShadingSurface,
|
||||
dx: [f64; 2],
|
||||
dy: [f64; 2],
|
||||
) -> Result<ShadingSurface> {
|
||||
let (rgb, minification) = self.sample_anisotropic(binding.uv(point.position)?, dx, dy)?;
|
||||
self.apply_sample(binding, point, original, rgb, minification)
|
||||
}
|
||||
fn apply_sample(
|
||||
&self,
|
||||
binding: &TextureBinding,
|
||||
point: MapPoint,
|
||||
original: ShadingSurface,
|
||||
rgb: [f64; 3],
|
||||
minification: 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()))?;
|
||||
@@ -478,10 +548,21 @@ impl PreparedMap {
|
||||
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 mut surface = original.surface;
|
||||
if self.normal_variance && binding.normal_strength > 0.0 && minification > 0.0 {
|
||||
let variance = normal_direction_variance(rgb) * minification;
|
||||
// Constant directions can lose a few bits during float reconstruction.
|
||||
if variance > 1e-6 {
|
||||
let alpha = surface.roughness() * surface.roughness();
|
||||
let broadened = (alpha * alpha + variance * binding.normal_strength.powi(2)).min(1.0).sqrt().sqrt();
|
||||
surface = surface
|
||||
.with_authored_finish(surface.base_color(), surface.metallic(), broadened)
|
||||
.map_err(|error| invalid(error.to_string()))?;
|
||||
}
|
||||
}
|
||||
return Ok(ShadingSurface { surface, shading_normal: if mapped.dot(view) > 0.0 { mapped } else { normal } });
|
||||
}
|
||||
let surface = original.surface;
|
||||
let base = if self.role == MapRole::BaseColor {
|
||||
@@ -496,7 +577,7 @@ impl PreparedMap {
|
||||
};
|
||||
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 })
|
||||
let changed = surface.with_authored_finish(base, metallic, roughness).map_err(|error| invalid(error.to_string()))?;
|
||||
Ok(ShadingSurface { surface: changed, shading_normal: normal })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod hatch;
|
||||
pub mod job;
|
||||
pub mod lighting;
|
||||
pub mod mapping;
|
||||
pub mod optical_boundary;
|
||||
mod product;
|
||||
mod raster;
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
|
||||
@@ -199,69 +199,200 @@ pub struct PreparedEnvironment {
|
||||
width: u32,
|
||||
height: u32,
|
||||
texels: Vec<EnvironmentTexel>,
|
||||
preserves_positive_support: bool,
|
||||
luminance_integral: f64,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum BuildStage {
|
||||
Masses,
|
||||
Black,
|
||||
Entries,
|
||||
Alias,
|
||||
Ready,
|
||||
}
|
||||
|
||||
/// One bounded alias-table operation per step; source pixels remain owned by the caller.
|
||||
/// CPU jobs charge these steps to the same ledger as decoding and transport.
|
||||
pub(crate) struct EnvironmentBuilder {
|
||||
width: u32,
|
||||
height: u32,
|
||||
count: usize,
|
||||
cursor: usize,
|
||||
total: f64,
|
||||
preserves_positive_support: bool,
|
||||
luminance_integral: f64,
|
||||
stage: BuildStage,
|
||||
masses: Vec<f64>,
|
||||
small: Vec<usize>,
|
||||
large: Vec<usize>,
|
||||
texels: Vec<EnvironmentTexel>,
|
||||
}
|
||||
impl EnvironmentBuilder {
|
||||
fn count(width: u32, height: u32) -> Result<usize> {
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| width > super::MAX_HDR_WIDTH
|
||||
|| height > super::MAX_HDR_HEIGHT
|
||||
|| height.checked_mul(2) != Some(width)
|
||||
|| u64::from(width) * u64::from(height) > super::MAX_HDR_PIXELS
|
||||
{
|
||||
return Err(invalid("environment table requires bounded 2:1 dimensions"));
|
||||
}
|
||||
usize::try_from(u64::from(width) * u64::from(height)).map_err(|_| invalid("environment table size overflow"))
|
||||
}
|
||||
pub(crate) fn admitted_bytes(width: u32, height: u32) -> Result<u64> {
|
||||
let count = Self::count(width, height)? as u64;
|
||||
count
|
||||
.checked_mul((std::mem::size_of::<EnvironmentTexel>() + std::mem::size_of::<f64>() + 2 * std::mem::size_of::<usize>()) as u64)
|
||||
.ok_or_else(|| invalid("environment table admission overflow"))
|
||||
}
|
||||
pub(crate) fn new(width: u32, height: u32) -> Result<Self> {
|
||||
let count = Self::count(width, height)?;
|
||||
let masses = reserve(count)?;
|
||||
let small = reserve(count)?;
|
||||
let large = reserve(count)?;
|
||||
let texels = reserve(count)?;
|
||||
if [masses.capacity(), small.capacity(), large.capacity(), texels.capacity()].into_iter().any(|capacity| capacity != count) {
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "environment table capacity exceeds admission"));
|
||||
}
|
||||
Ok(Self {
|
||||
width,
|
||||
height,
|
||||
count,
|
||||
cursor: 0,
|
||||
total: 0.0,
|
||||
preserves_positive_support: true,
|
||||
luminance_integral: 0.0,
|
||||
stage: BuildStage::Masses,
|
||||
masses,
|
||||
small,
|
||||
large,
|
||||
texels,
|
||||
})
|
||||
}
|
||||
pub(crate) fn ready(&self) -> bool {
|
||||
self.stage == BuildStage::Ready
|
||||
}
|
||||
pub(crate) fn step(&mut self, pixels: &[[f32; 3]]) -> Result<()> {
|
||||
if pixels.len() != self.count {
|
||||
return Err(invalid("invalid environment storage"));
|
||||
}
|
||||
match self.stage {
|
||||
BuildStage::Masses => {
|
||||
let rgb = pixels.get(self.cursor).ok_or_else(|| invalid("environment mass index"))?;
|
||||
if rgb.iter().any(|v| !v.is_finite() || !(0.0..=1e8).contains(v)) {
|
||||
return Err(invalid("invalid environment radiance"));
|
||||
}
|
||||
let mass = texel_luminance_mass(self.width, self.height, self.cursor, *rgb);
|
||||
self.total += mass;
|
||||
self.masses.push(mass);
|
||||
self.cursor += 1;
|
||||
if self.cursor == self.count {
|
||||
self.cursor = 0;
|
||||
self.luminance_integral = self.total;
|
||||
self.stage = if self.total == 0.0 {
|
||||
self.total = 4.0 * PI;
|
||||
BuildStage::Black
|
||||
} else {
|
||||
BuildStage::Entries
|
||||
};
|
||||
}
|
||||
}
|
||||
BuildStage::Black => {
|
||||
*self.masses.get_mut(self.cursor).ok_or_else(|| invalid("environment black mass index"))? =
|
||||
row_solid_angle(self.width, self.height, self.cursor / self.width as usize);
|
||||
self.cursor += 1;
|
||||
if self.cursor == self.count {
|
||||
self.cursor = 0;
|
||||
self.stage = BuildStage::Entries;
|
||||
}
|
||||
}
|
||||
BuildStage::Entries => {
|
||||
let mass = self.masses.get_mut(self.cursor).ok_or_else(|| invalid("environment entry mass index"))?;
|
||||
let probability = *mass / self.total;
|
||||
let rgb = *pixels.get(self.cursor).ok_or_else(|| invalid("environment entry pixel index"))?;
|
||||
let pdf = (probability / row_solid_angle(self.width, self.height, self.cursor / self.width as usize)) as f32;
|
||||
// Subnormal packed probabilities lose relative precision. Keep
|
||||
// GPU table bytes unchanged and let the CPU use its full-support
|
||||
// legacy proposal for such extreme dynamic ranges.
|
||||
if !pdf.is_normal() && rgb.into_iter().any(|value| value > 0.0) {
|
||||
self.preserves_positive_support = false;
|
||||
}
|
||||
self.texels.push(EnvironmentTexel { rgb, pdf, threshold: 1.0, alias: self.cursor as u32 });
|
||||
*mass = probability * self.count as f64;
|
||||
if *mass < 1.0 {
|
||||
self.small.push(self.cursor);
|
||||
} else {
|
||||
self.large.push(self.cursor);
|
||||
}
|
||||
self.cursor += 1;
|
||||
if self.cursor == self.count {
|
||||
self.stage = BuildStage::Alias;
|
||||
}
|
||||
}
|
||||
BuildStage::Alias => {
|
||||
if self.small.is_empty() || self.large.is_empty() {
|
||||
self.stage = BuildStage::Ready;
|
||||
return Ok(());
|
||||
}
|
||||
let low = self.small.pop().ok_or_else(|| invalid("invalid alias table"))?;
|
||||
let high = self.large.pop().ok_or_else(|| invalid("invalid alias table"))?;
|
||||
let value = *self.masses.get(low).ok_or_else(|| invalid("invalid alias mass"))?;
|
||||
let entry = self.texels.get_mut(low).ok_or_else(|| invalid("invalid alias index"))?;
|
||||
entry.threshold = value as f32;
|
||||
if value > 0.0 && !entry.threshold.is_normal() {
|
||||
self.preserves_positive_support = false;
|
||||
}
|
||||
entry.alias = high as u32;
|
||||
let remaining = self.masses.get_mut(high).ok_or_else(|| invalid("invalid alias mass"))?;
|
||||
*remaining = (*remaining + value - 1.0).max(0.0);
|
||||
if *remaining < 1.0 {
|
||||
self.small.push(high);
|
||||
} else {
|
||||
self.large.push(high);
|
||||
}
|
||||
}
|
||||
BuildStage::Ready => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn finish(self) -> Result<PreparedEnvironment> {
|
||||
if !self.ready() {
|
||||
return Err(invalid("environment table is incomplete"));
|
||||
}
|
||||
Ok(PreparedEnvironment {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
texels: self.texels,
|
||||
preserves_positive_support: self.preserves_positive_support,
|
||||
luminance_integral: self.luminance_integral,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl PreparedEnvironment {
|
||||
fn new(width: u32, height: u32, pixels: Vec<[f32; 3]>, cancel: &CancelToken) -> Result<Self> {
|
||||
let count = (width * height) as usize;
|
||||
if count != pixels.len() || count == 0 {
|
||||
return Err(invalid("invalid environment storage"));
|
||||
}
|
||||
let mut masses = reserve(count)?;
|
||||
let mut total = 0.0;
|
||||
for (i, rgb) in pixels.iter().enumerate() {
|
||||
if i % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
let mass = (0.2126 * f64::from(rgb[0]) + 0.7152 * f64::from(rgb[1]) + 0.0722 * f64::from(rgb[2]))
|
||||
* row_solid_angle(width, height, i / width as usize);
|
||||
total += mass;
|
||||
masses.push(mass);
|
||||
}
|
||||
if total == 0.0 {
|
||||
for (i, mass) in masses.iter_mut().enumerate() {
|
||||
*mass = row_solid_angle(width, height, i / width as usize);
|
||||
}
|
||||
total = 4.0 * PI;
|
||||
}
|
||||
let mut small = reserve(count)?;
|
||||
let mut large = reserve(count)?;
|
||||
let mut texels = reserve(count)?;
|
||||
for (i, (rgb, mass)) in pixels.into_iter().zip(masses.iter_mut()).enumerate() {
|
||||
if i % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
let probability = *mass / total;
|
||||
texels.push(EnvironmentTexel {
|
||||
rgb,
|
||||
pdf: (probability / row_solid_angle(width, height, i / width as usize)) as f32,
|
||||
threshold: 1.0,
|
||||
alias: i as u32,
|
||||
});
|
||||
*mass = probability * count as f64;
|
||||
if *mass < 1.0 {
|
||||
small.push(i);
|
||||
} else {
|
||||
large.push(i);
|
||||
}
|
||||
}
|
||||
while !small.is_empty() && !large.is_empty() {
|
||||
cancel.check()?;
|
||||
let mut builder = EnvironmentBuilder::new(width, height)?;
|
||||
while !builder.ready() {
|
||||
cancel.check()?;
|
||||
let low = small.pop().ok_or_else(|| invalid("invalid alias table"))?;
|
||||
let high = large.pop().ok_or_else(|| invalid("invalid alias table"))?;
|
||||
let value = *masses.get(low).ok_or_else(|| invalid("invalid alias mass"))?;
|
||||
let entry = texels.get_mut(low).ok_or_else(|| invalid("invalid alias index"))?;
|
||||
entry.threshold = value as f32;
|
||||
entry.alias = high as u32;
|
||||
let remaining = masses.get_mut(high).ok_or_else(|| invalid("invalid alias mass"))?;
|
||||
*remaining = (*remaining + value - 1.0).max(0.0);
|
||||
if *remaining < 1.0 {
|
||||
small.push(high);
|
||||
} else {
|
||||
large.push(high);
|
||||
for _ in 0..1024 {
|
||||
if builder.ready() {
|
||||
break;
|
||||
}
|
||||
builder.step(&pixels)?;
|
||||
}
|
||||
}
|
||||
cancel.check()?;
|
||||
Ok(Self { width, height, texels })
|
||||
builder.finish()
|
||||
}
|
||||
#[cfg(any(feature = "raytrace-cpu", test))]
|
||||
pub(crate) fn preserves_positive_support(&self) -> bool {
|
||||
self.preserves_positive_support
|
||||
}
|
||||
/// Physical source integral, before intensity; black stays zero even when
|
||||
/// the sampling table uses its uniform fallback.
|
||||
pub fn luminance_integral(&self) -> f64 {
|
||||
self.luminance_integral
|
||||
}
|
||||
pub fn dimensions(&self) -> [u32; 2] {
|
||||
[self.width, self.height]
|
||||
@@ -288,6 +419,53 @@ impl PreparedEnvironment {
|
||||
let radius = (1.0 - z * z).max(0.0).sqrt();
|
||||
Ok((Vec3::new(radius * phi.cos(), radius * phi.sin(), z), selected.rgb, selected.pdf))
|
||||
}
|
||||
/// Two independent variates cover alias selection and the selected texel.
|
||||
/// The branch-conditional coin residual is uniform, including alias targets.
|
||||
pub(crate) fn sample_pair(&self, [choice, azimuth]: [f64; 2]) -> Result<(Vec3, [f32; 3], f32)> {
|
||||
if [choice, azimuth].into_iter().any(|v| !v.is_finite() || !(0.0..1.0).contains(&v)) {
|
||||
return Err(invalid("environment samples must be in [0,1)"));
|
||||
}
|
||||
if !self.preserves_positive_support {
|
||||
let z = 1.0 - 2.0 * choice;
|
||||
let radius = ((1.0 - z) * (1.0 + z)).max(0.0).sqrt();
|
||||
let phi = TAU * azimuth;
|
||||
let direction = Vec3::new(radius * phi.cos(), radius * phi.sin(), z);
|
||||
return Ok((direction, self.evaluate(direction)?.0, (1.0 / (4.0 * PI)) as f32));
|
||||
}
|
||||
let scaled = choice * self.texels.len() as f64;
|
||||
let column = scaled.floor() as usize;
|
||||
let coin = scaled - column as f64;
|
||||
let entry = self.texels.get(column).ok_or_else(|| invalid("environment sample index"))?;
|
||||
let threshold = f64::from(entry.threshold);
|
||||
let vertical = if coin < threshold { coin / threshold } else { (coin - threshold) / (1.0 - threshold) };
|
||||
let index = if coin < threshold { column } else { entry.alias as usize };
|
||||
let selected = self.texels.get(index).ok_or_else(|| invalid("environment alias index"))?;
|
||||
let row = index / self.width as usize;
|
||||
let column = index % self.width as usize;
|
||||
let top = (PI * row as f64 / f64::from(self.height)).cos();
|
||||
let bottom = (PI * (row + 1) as f64 / f64::from(self.height)).cos();
|
||||
// Keep finite-precision boundary samples inside their selected cell.
|
||||
// The margin covers normalization and the rotation/inverse-rotation
|
||||
// round trip, which otherwise can send a polar sample to a zero-PDF cell.
|
||||
let margin = 8.0 * f64::EPSILON;
|
||||
let z = (top + (bottom - top) * vertical).clamp(bottom + margin, top - margin);
|
||||
let left = TAU * column as f64 / f64::from(self.width);
|
||||
let right = TAU * (column + 1) as f64 / f64::from(self.width);
|
||||
let phi = (TAU * (column as f64 + azimuth) / f64::from(self.width)).clamp(left + margin * TAU, right - margin * TAU);
|
||||
let radius = ((1.0 - z) * (1.0 + z)).max(0.0).sqrt();
|
||||
Ok((Vec3::new(radius * phi.cos(), radius * phi.sin(), z), selected.rgb, selected.pdf))
|
||||
}
|
||||
pub(crate) fn pdf_at_uv(&self, [u, v]: [f64; 2]) -> Result<f32> {
|
||||
if [u, v].into_iter().any(|value| !value.is_finite() || !(0.0..=1.0).contains(&value)) {
|
||||
return Err(invalid("invalid environment coordinates"));
|
||||
}
|
||||
if !self.preserves_positive_support {
|
||||
return Ok((1.0 / (4.0 * PI)) as f32);
|
||||
}
|
||||
let column = ((u * f64::from(self.width)) as usize).min(self.width as usize - 1);
|
||||
let row = ((v * f64::from(self.height)) as usize).min(self.height as usize - 1);
|
||||
self.texels.get(row * self.width as usize + column).map(|texel| texel.pdf).ok_or_else(|| invalid("environment PDF index"))
|
||||
}
|
||||
pub fn evaluate(&self, direction: Vec3) -> Result<([f32; 3], f32)> {
|
||||
let d = crate::surface::unit_direction(direction).map_err(|_| invalid("invalid environment direction"))?;
|
||||
let column = ((d.y.atan2(d.x).rem_euclid(TAU) / TAU * f64::from(self.width)) as usize).min(self.width as usize - 1);
|
||||
@@ -296,6 +474,9 @@ impl PreparedEnvironment {
|
||||
Ok((texel.rgb, texel.pdf))
|
||||
}
|
||||
}
|
||||
pub(crate) fn texel_luminance_mass(width: u32, height: u32, index: usize, rgb: [f32; 3]) -> f64 {
|
||||
(0.2126 * f64::from(rgb[0]) + 0.7152 * f64::from(rgb[1]) + 0.0722 * f64::from(rgb[2])) * row_solid_angle(width, height, index / width as usize)
|
||||
}
|
||||
fn row_solid_angle(width: u32, height: u32, row: usize) -> f64 {
|
||||
// Stable difference of cosines at polar rows.
|
||||
let half = PI / (2.0 * f64::from(height));
|
||||
|
||||
@@ -108,3 +108,93 @@ fn hdr_admission_counts_retained_capacity_not_just_file_length() {
|
||||
assert_eq!(source.info().encoded_bytes, bytes.len() as u64);
|
||||
assert_eq!(source.admitted_bytes(), retained + 8 * 160 + 65536);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cooperative_alias_builder_preserves_original_gpu_table_bits() {
|
||||
let colors = [0.0, 0.0, 1.0, 4.0, 9.0, 0.0, 2.0, 8.0];
|
||||
let pixels: Vec<_> = colors.into_iter().map(|v| [v; 3]).collect();
|
||||
// Pre-refactor table retained as a bitwise compatibility oracle.
|
||||
let expected = [
|
||||
(0x00000000, 0x00000000, 4),
|
||||
(0x00000000, 0x00000000, 4),
|
||||
(0x3cd94caf, 0x3eaaaaab, 7),
|
||||
(0x3dd94caf, 0x3f800000, 3),
|
||||
(0x3e747645, 0x3f2aaaab, 3),
|
||||
(0x00000000, 0x00000000, 7),
|
||||
(0x3d594caf, 0x3f2aaaab, 7),
|
||||
(0x3e594caf, 0x3f2aaaab, 4),
|
||||
];
|
||||
let mut builder = EnvironmentBuilder::new(4, 2).unwrap();
|
||||
assert!(EnvironmentBuilder::new(4, 2).unwrap().finish().is_err());
|
||||
let mut steps = 0;
|
||||
while !builder.ready() {
|
||||
builder.step(&pixels).unwrap();
|
||||
steps += 1;
|
||||
}
|
||||
assert!((16..=25).contains(&steps), "bounded mass/entry/alias operations: {steps}");
|
||||
let map = builder.finish().unwrap();
|
||||
for ((entry, color), (pdf, threshold, alias)) in map.texels().iter().zip(colors).zip(expected) {
|
||||
assert_eq!(entry.rgb, [color; 3]);
|
||||
assert_eq!((entry.pdf.to_bits(), entry.threshold.to_bits(), entry.alias), (pdf, threshold, alias));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_pair_has_independent_spherical_cell_and_within_cell_distribution() {
|
||||
let colors = [0.0, 0.0, 1.0, 4.0, 9.0, 0.0, 2.0, 8.0];
|
||||
let map = PreparedEnvironment::new(4, 2, colors.into_iter().map(|v| [v; 3]).collect(), &CancelToken::default()).unwrap();
|
||||
let mut counts = [[0usize; 16]; 8];
|
||||
for i in 0..4096 {
|
||||
for j in 0..16 {
|
||||
let (d, rgb, pdf) = map.sample_pair([(f64::from(i) + 0.5) / 4096.0, (f64::from(j) + 0.5) / 16.0]).unwrap();
|
||||
let index = colors.iter().position(|value| [*value; 3] == rgb).unwrap();
|
||||
assert!(rgb[0] > 0.0);
|
||||
assert_eq!(map.evaluate(d).unwrap(), (rgb, pdf));
|
||||
let vertical = if index < 4 { 1.0 - d.z } else { -d.z };
|
||||
let azimuth = d.y.atan2(d.x).rem_euclid(TAU) / (TAU / 4.0) - (index % 4) as f64;
|
||||
counts[index][((vertical * 4.0) as usize).min(3) * 4 + ((azimuth * 4.0) as usize).min(3)] += 1;
|
||||
}
|
||||
}
|
||||
for (index, bins) in counts.into_iter().enumerate().filter(|(i, _)| colors[*i] > 0.0) {
|
||||
let expected = f64::from(colors[index]) / 24.0 / 16.0;
|
||||
for count in bins {
|
||||
assert!((count as f64 / 65536.0 - expected).abs() < 0.0003);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn underflowing_positive_hdr_mass_uses_cpu_uniform_support_without_changing_gpu_entries() {
|
||||
let mut pixels = vec![[1e-38; 3]; 8];
|
||||
pixels[0] = [1e8; 3];
|
||||
let map = PreparedEnvironment::new(4, 2, pixels, &CancelToken::default()).unwrap();
|
||||
assert_eq!(map.texels()[7].pdf, 0.0);
|
||||
assert!(!map.preserves_positive_support);
|
||||
let (d, rgb, pdf) = map.sample_pair([0.75, 0.875]).unwrap();
|
||||
assert_eq!(rgb, [1e-38; 3]);
|
||||
assert_eq!(pdf, (1.0 / (4.0 * PI)) as f32);
|
||||
let u = d.y.atan2(d.x).rem_euclid(TAU) / TAU;
|
||||
let v = d.z.acos() / PI;
|
||||
assert_eq!(map.pdf_at_uv([u, v]).unwrap(), pdf);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_builder_rejects_hostile_dimensions_before_admission_or_allocation() {
|
||||
for (width, height) in [(0, 1), (2, 0), (1, 2_097_152), (2_097_152, 1), (2049, 1), (2, 1025), (4, 4), (u32::MAX, u32::MAX)] {
|
||||
assert!(EnvironmentBuilder::admitted_bytes(width, height).is_err());
|
||||
assert!(EnvironmentBuilder::new(width, height).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subnormal_positive_alias_probabilities_keep_gpu_bytes_and_use_cpu_uniform_support() {
|
||||
let mut pixels = vec![[1e-39; 3]; 8];
|
||||
pixels[0] = [1.0; 3];
|
||||
let map = PreparedEnvironment::new(4, 2, pixels, &CancelToken::default()).unwrap();
|
||||
assert!(map.texels()[7].pdf.is_subnormal());
|
||||
assert!(map.texels()[7].threshold.is_subnormal());
|
||||
assert!(!map.preserves_positive_support);
|
||||
let (_, rgb, pdf) = map.sample_pair([0.75, 0.875]).unwrap();
|
||||
assert_eq!(rgb, [1e-39; 3]);
|
||||
assert_eq!(pdf, (1.0 / (4.0 * PI)) as f32);
|
||||
}
|
||||
|
||||
@@ -53,16 +53,27 @@ impl EnvironmentInput {
|
||||
/// Source capacity + exact decoded allocation + scanline + conservative task state.
|
||||
/// The host adds this BEFORE reserving; this is no separate memory pool.
|
||||
pub fn required_bytes(&self) -> Result<u64> {
|
||||
self.required_bytes_with_importance(false)
|
||||
}
|
||||
/// Include the optional table and its peak builder scratch in the same lease.
|
||||
pub fn required_bytes_with_importance(&self, importance: bool) -> Result<u64> {
|
||||
let table = if importance {
|
||||
super::environment::EnvironmentBuilder::admitted_bytes(self.source.info().width, self.source.info().height)
|
||||
.map_err(|e| HdrError::new(HdrErrorCode::LimitExceeded, e.to_string()))?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.source
|
||||
.retained_encoded_bytes()
|
||||
.checked_add(self.source.info().decoded_rgb_bytes)
|
||||
.and_then(|v| v.checked_add(u64::from(self.source.info().width) * 4))
|
||||
.and_then(|v| v.checked_add(8192))
|
||||
.and_then(|v| v.checked_add(table))
|
||||
.ok_or_else(|| HdrError::new(HdrErrorCode::LimitExceeded, "environment admission overflow"))
|
||||
}
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
pub(crate) fn begin(self) -> Result<EnvironmentState> {
|
||||
if std::mem::size_of::<HdrDecodeTask>() > 4096 {
|
||||
pub(crate) fn begin(self, importance: bool, power: bool) -> Result<EnvironmentState> {
|
||||
if std::mem::size_of::<HdrDecodeTask>() > 4096 || std::mem::size_of::<HdrDecodeTask>() + std::mem::size_of::<EnvironmentState>() > 8192 {
|
||||
return Err(HdrError::new(HdrErrorCode::LimitExceeded, "HDR decoder state exceeds admission"));
|
||||
}
|
||||
let mut decoder = Vec::new();
|
||||
@@ -74,6 +85,12 @@ impl EnvironmentInput {
|
||||
Ok(EnvironmentState {
|
||||
decoder,
|
||||
decoded: None,
|
||||
importance_enabled: importance,
|
||||
energy_ready: !power,
|
||||
energy_cursor: 0,
|
||||
energy_sum: 0.0,
|
||||
importance_builder: None,
|
||||
importance: None,
|
||||
rotation: self.info.rotation_radians,
|
||||
intensity: self.info.intensity,
|
||||
samples: self.info.samples,
|
||||
@@ -85,6 +102,12 @@ impl EnvironmentInput {
|
||||
pub(crate) struct EnvironmentState {
|
||||
decoder: Vec<HdrDecodeTask>,
|
||||
decoded: Option<DecodedHdr>,
|
||||
importance_enabled: bool,
|
||||
energy_ready: bool,
|
||||
energy_cursor: usize,
|
||||
energy_sum: f64,
|
||||
importance_builder: Option<super::environment::EnvironmentBuilder>,
|
||||
importance: Option<super::environment::PreparedEnvironment>,
|
||||
rotation: f64,
|
||||
intensity: f64,
|
||||
pub samples: u32,
|
||||
@@ -92,10 +115,39 @@ pub(crate) struct EnvironmentState {
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
impl EnvironmentState {
|
||||
pub fn ready(&self) -> bool {
|
||||
self.decoded.is_some()
|
||||
self.decoded.is_some() && self.energy_ready && (!self.importance_enabled || self.importance.is_some())
|
||||
}
|
||||
/// Caller debits one shared unit before each invocation; decoder also observes cancellation.
|
||||
pub fn prepare_one(&mut self, cancelled: impl Fn() -> bool) -> Result<()> {
|
||||
super::check_cancelled(&cancelled)?;
|
||||
if let Some(builder) = self.importance_builder.as_mut() {
|
||||
let decoded = self.decoded.as_ref().ok_or_else(|| HdrError::new(HdrErrorCode::Consumed, "importance source missing"))?;
|
||||
builder.step(decoded.pixels()).map_err(|e| HdrError::new(HdrErrorCode::InvalidInput, e.to_string()))?;
|
||||
if builder.ready() {
|
||||
self.importance = Some(
|
||||
self.importance_builder
|
||||
.take()
|
||||
.ok_or_else(|| HdrError::new(HdrErrorCode::Consumed, "importance builder missing"))?
|
||||
.finish()
|
||||
.map_err(|e| HdrError::new(HdrErrorCode::InvalidInput, e.to_string()))?,
|
||||
);
|
||||
}
|
||||
if let Some(table) = &self.importance {
|
||||
self.energy_sum = table.luminance_integral();
|
||||
self.energy_ready = true;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(decoded) = &self.decoded
|
||||
&& !self.energy_ready
|
||||
{
|
||||
let pixel =
|
||||
*decoded.pixels().get(self.energy_cursor).ok_or_else(|| HdrError::new(HdrErrorCode::Consumed, "environment power pixel missing"))?;
|
||||
self.energy_sum += super::environment::texel_luminance_mass(decoded.info().width, decoded.info().height, self.energy_cursor, pixel);
|
||||
self.energy_cursor += 1;
|
||||
self.energy_ready = self.energy_cursor == decoded.pixels().len();
|
||||
return Ok(());
|
||||
}
|
||||
let decoder = self.decoder.first_mut().ok_or_else(|| HdrError::new(HdrErrorCode::Consumed, "environment decoder consumed"))?;
|
||||
if let HdrTaskPoll::Complete(decoded) = decoder.step(1, cancelled)? {
|
||||
let expected = decoded.info().decoded_rgb_bytes;
|
||||
@@ -103,13 +155,28 @@ impl EnvironmentState {
|
||||
return Err(HdrError::new(HdrErrorCode::LimitExceeded, "environment decoded capacity exceeds admission"));
|
||||
}
|
||||
Environment::from_decoder_output(&decoded, self.rotation, self.intensity)?.require_linear_srgb()?;
|
||||
if self.importance_enabled {
|
||||
self.importance_builder = Some(
|
||||
super::environment::EnvironmentBuilder::new(decoded.info().width, decoded.info().height)
|
||||
.map_err(|e| HdrError::new(HdrErrorCode::AllocationFailed, e.to_string()))?,
|
||||
);
|
||||
}
|
||||
self.decoded = Some(decoded);
|
||||
self.decoder = Vec::new();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn luminance_integral(&self) -> Result<f64> {
|
||||
if !self.ready() {
|
||||
return Err(HdrError::new(HdrErrorCode::Consumed, "environment power not ready"));
|
||||
}
|
||||
Ok(self.energy_sum * self.intensity)
|
||||
}
|
||||
pub fn view(&self) -> Result<Environment<'_>> {
|
||||
let decoded = self.decoded.as_ref().ok_or_else(|| HdrError::new(HdrErrorCode::Consumed, "environment not ready"))?;
|
||||
Environment::from_decoder_output(decoded, self.rotation, self.intensity)
|
||||
if !self.ready() {
|
||||
return Err(HdrError::new(HdrErrorCode::Consumed, "environment importance not ready"));
|
||||
}
|
||||
Environment::from_decoder_output(decoded, self.rotation, self.intensity)?.with_importance(self.importance.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Original Z-up equirectangular sampling; RGB remains in the decoded SOURCE space.
|
||||
//! Uniform solid-angle sampling deliberately has no luminance CDF or extra pixel copy.
|
||||
//! Z-up equirectangular lookup; RGB remains in the decoded SOURCE space.
|
||||
//! The captured view borrows either the legacy uniform proposal or an optional
|
||||
//! exact-solid-angle importance table prepared and admitted by its owner.
|
||||
|
||||
use super::{DecodedHdr, HdrError, HdrErrorCode, MAX_HDR_HEIGHT, MAX_HDR_PIXELS, MAX_HDR_WIDTH, Result};
|
||||
use solidcore_record::Vec3;
|
||||
@@ -14,6 +15,7 @@ fn invalid(message: &str) -> HdrError {
|
||||
/// This is not a declaration that source RGB is linear sRGB.
|
||||
pub struct Environment<'a> {
|
||||
decoded: &'a DecodedHdr,
|
||||
importance: Option<&'a super::environment::PreparedEnvironment>,
|
||||
rotation: f64,
|
||||
intensity: f64,
|
||||
}
|
||||
@@ -58,7 +60,7 @@ impl<'a> EnvironmentPreparation<'a> {
|
||||
if !rotation_radians.is_finite() || !intensity.is_finite() || !(0.0..=64.0).contains(&intensity) {
|
||||
return Err(invalid("environment rotation must be finite and intensity within 0..64"));
|
||||
}
|
||||
Ok(Self { environment: Some(Environment { decoded, rotation: rotation_radians.rem_euclid(TAU), intensity }), inspected: 0 })
|
||||
Ok(Self { environment: Some(Environment { decoded, importance: None, rotation: rotation_radians.rem_euclid(TAU), intensity }), inspected: 0 })
|
||||
}
|
||||
|
||||
/// Returns true when validated. Any error consumes preparation; no partial view escapes.
|
||||
@@ -98,7 +100,15 @@ impl<'a> EnvironmentPreparation<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
impl Environment<'_> {
|
||||
impl<'a> Environment<'a> {
|
||||
#[cfg(any(feature = "raytrace-cpu", test))]
|
||||
pub(crate) fn with_importance(mut self, importance: Option<&'a super::environment::PreparedEnvironment>) -> Result<Self> {
|
||||
if importance.is_some_and(|table| table.dimensions() != [self.decoded.info().width, self.decoded.info().height]) {
|
||||
return Err(invalid("environment importance dimensions differ from decoded input"));
|
||||
}
|
||||
self.importance = importance.filter(|table| table.preserves_positive_support());
|
||||
Ok(self)
|
||||
}
|
||||
/// Only decoder-owned output can reach this internal consumer fast path.
|
||||
/// HdrDecodeTask already validates every corrected texel incrementally.
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
@@ -173,6 +183,10 @@ impl Environment<'_> {
|
||||
}
|
||||
|
||||
pub fn pdf_solid_angle(&self, direction: Vec3) -> Result<f64> {
|
||||
if let Some(table) = self.importance {
|
||||
let [u, v] = self.uv(direction)?;
|
||||
return table.pdf_at_uv([u, v]).map(f64::from).map_err(|e| invalid(&e.to_string()));
|
||||
}
|
||||
normalized(direction)?;
|
||||
Ok(1.0 / (4.0 * PI))
|
||||
}
|
||||
@@ -184,6 +198,16 @@ impl Environment<'_> {
|
||||
if u.iter().any(|v| !v.is_finite() || *v <= 0.0 || *v >= 1.0) {
|
||||
return Err(invalid("environment variates must be finite and strictly between zero and one"));
|
||||
}
|
||||
if let Some(table) = self.importance {
|
||||
let (local, rgb, pdf) = table.sample_pair(u).map_err(|e| invalid(&e.to_string()))?;
|
||||
let (sine, cosine) = self.rotation.sin_cos();
|
||||
let direction = Vec3::new(cosine * local.x - sine * local.y, sine * local.x + cosine * local.y, local.z);
|
||||
return Ok(EnvironmentSample {
|
||||
direction,
|
||||
radiance_source_rgb: Vec3::new(f64::from(rgb[0]), f64::from(rgb[1]), f64::from(rgb[2])) * self.intensity,
|
||||
pdf_solid_angle: f64::from(pdf),
|
||||
});
|
||||
}
|
||||
let z = 1.0 - 2.0 * u[0];
|
||||
let radius = ((1.0 - z) * (1.0 + z)).max(0.0).sqrt();
|
||||
let phi = TAU * u[1];
|
||||
|
||||
@@ -155,3 +155,168 @@ fn direct_conductor_estimates_average_once_and_do_not_cross_geometry() {
|
||||
let unknown = decoded([[128, 0, 0, 129]; 8], "");
|
||||
assert!(prepared(&unknown, 0.0, 1.0).direct_sample_linear_srgb([0.25, 0.375], surface, Vec3::Z, Vec3::Z, Vec3::Z).is_err());
|
||||
}
|
||||
|
||||
fn importance(decoded: &DecodedHdr) -> super::super::environment::PreparedEnvironment {
|
||||
let mut builder = super::super::environment::EnvironmentBuilder::new(decoded.info().width, decoded.info().height).unwrap();
|
||||
while !builder.ready() {
|
||||
builder.step(decoded.pixels()).unwrap();
|
||||
}
|
||||
builder.finish().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_importance_preserves_analytic_energy_and_reduces_bright_cell_variance() {
|
||||
let mut pixels = [[0; 4]; 8];
|
||||
pixels[2] = [255, 255, 255, 140];
|
||||
let data = decoded(pixels, "");
|
||||
let table = importance(&data);
|
||||
let legacy = prepared(&data, 0.0, 1.0);
|
||||
let guided = prepared(&data, 0.0, 1.0).with_importance(Some(&table)).unwrap();
|
||||
// One cell is pi/2 sr and RGBE(255,140) is 4080 in each source channel.
|
||||
let expected = 4080.0 * PI / 2.0;
|
||||
let mut legacy_mse = 0.0;
|
||||
let mut guided_mse = 0.0;
|
||||
let mut guided_mean = 0.0;
|
||||
let count = 16384;
|
||||
let mut rng = 1349u64;
|
||||
let mut random = || {
|
||||
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
((rng >> 11) as f64 + 0.5) / 9007199254740992.0
|
||||
};
|
||||
for _ in 0..count {
|
||||
let u = [random().min(1.0_f64.next_down()), random().min(1.0_f64.next_down())];
|
||||
let baseline = legacy.sample(u).unwrap();
|
||||
let sample = guided.sample(u).unwrap();
|
||||
assert_eq!(guided.radiance_source_rgb(sample.direction).unwrap(), sample.radiance_source_rgb);
|
||||
assert_eq!(guided.pdf_solid_angle(sample.direction).unwrap(), sample.pdf_solid_angle);
|
||||
let value = sample.radiance_source_rgb.x / sample.pdf_solid_angle;
|
||||
guided_mean += value / f64::from(count);
|
||||
guided_mse += (value - expected).powi(2) / f64::from(count);
|
||||
legacy_mse += (baseline.radiance_source_rgb.x / baseline.pdf_solid_angle - expected).powi(2) / f64::from(count);
|
||||
}
|
||||
assert!((guided_mean - expected).abs() < expected * 1e-6);
|
||||
assert!(legacy_mse > expected * expected);
|
||||
assert!(guided_mse < expected * expected * 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_importance_rotation_seams_poles_black_and_constant_cells_keep_matching_densities() {
|
||||
for pixels in [[[128, 64, 32, 129]; 8], [[0; 4]; 8], [[0; 4], [0; 4], [128, 64, 32, 129], [0; 4], [0; 4], [0; 4], [0; 4], [0; 4]]] {
|
||||
let data = decoded(pixels, "");
|
||||
let table = importance(&data);
|
||||
for rotation in [0.0, 0.55, PI / 2.0, TAU.next_down(), -0.3] {
|
||||
let env = prepared(&data, rotation, 2.0).with_importance(Some(&table)).unwrap();
|
||||
for u in [1.0 / 9007199254740992.0, 0.125, 0.25, 0.5, 0.875, 1.0_f64.next_down()] {
|
||||
for v in [1.0 / 9007199254740992.0, 0.25, 0.5, 0.875, 1.0_f64.next_down()] {
|
||||
let sample = env.sample([u, v]).unwrap();
|
||||
assert!(sample.pdf_solid_angle > 0.0 && sample.pdf_solid_angle.is_finite());
|
||||
assert_eq!(env.radiance_source_rgb(sample.direction).unwrap(), sample.radiance_source_rgb, "rotation={rotation}, u={u}, v={v}");
|
||||
assert_eq!(env.pdf_solid_angle(sample.direction).unwrap(), sample.pdf_solid_angle, "rotation={rotation}, u={u}, v={v}");
|
||||
}
|
||||
}
|
||||
for direction in [Vec3::Z, -Vec3::Z, Vec3::X, Vec3::new(1.0, -1e-12, 0.0)] {
|
||||
assert!(env.pdf_solid_angle(direction).unwrap().is_finite());
|
||||
assert!(env.radiance_source_rgb(direction).unwrap().is_finite());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
#[test]
|
||||
fn cpu_hdr_importance_preparation_is_admitted_cooperative_and_cancellable() {
|
||||
use crate::lighting::environment_input::EnvironmentInput;
|
||||
let input = || {
|
||||
let mut bytes = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\nPRIMARIES=0.64 0.33 0.30 0.60 0.15 0.06 0.3127 0.3290\n\n-Y 2 +X 4\n".to_vec();
|
||||
for _ in 0..8 {
|
||||
bytes.extend_from_slice(&[128, 64, 32, 129]);
|
||||
}
|
||||
EnvironmentInput::new(HdrSource::new(bytes).unwrap(), 0.55, 2.0, 1).unwrap()
|
||||
};
|
||||
let bytes = input().required_bytes().unwrap();
|
||||
let table_bytes = super::super::environment::EnvironmentBuilder::admitted_bytes(4, 2).unwrap();
|
||||
assert_eq!(input().required_bytes_with_importance(true).unwrap(), bytes + table_bytes);
|
||||
let finish = |importance| {
|
||||
let mut state = input().begin(importance, false).unwrap();
|
||||
let mut polls = 0;
|
||||
while !state.ready() {
|
||||
assert!(state.view().is_err());
|
||||
state.prepare_one(|| false).unwrap();
|
||||
polls += 1;
|
||||
assert!(polls < 100);
|
||||
}
|
||||
(state, polls)
|
||||
};
|
||||
let (legacy, old_polls) = finish(false);
|
||||
let (guided, new_polls) = finish(true);
|
||||
assert!(new_polls >= old_polls + 16);
|
||||
let d = Vec3::new(0.3, 0.2, 0.8);
|
||||
assert_eq!(legacy.view().unwrap().radiance_linear_srgb(d).unwrap(), guided.view().unwrap().radiance_linear_srgb(d).unwrap());
|
||||
let mut cancelled = input().begin(true, false).unwrap();
|
||||
for _ in 0..old_polls {
|
||||
cancelled.prepare_one(|| false).unwrap();
|
||||
}
|
||||
assert!(!cancelled.ready());
|
||||
assert_eq!(cancelled.prepare_one(|| true).unwrap_err().code, HdrErrorCode::Cancelled);
|
||||
assert!(cancelled.view().is_err());
|
||||
let legacy_options = crate::job::RaytraceOptions::default();
|
||||
assert!(!legacy_options.environment_importance_sampling);
|
||||
assert!(serde_json::to_value(&legacy_options).unwrap().get("environment_importance_sampling").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_importance_extreme_dynamic_range_retains_the_exact_legacy_support() {
|
||||
let mut pixels = [[1, 1, 2, 1]; 8];
|
||||
pixels[0] = [128, 128, 128, 154];
|
||||
let data = decoded(pixels, "");
|
||||
let table = importance(&data);
|
||||
assert!(!table.preserves_positive_support());
|
||||
for rotation in [0.0, 0.55, -0.3] {
|
||||
let legacy = prepared(&data, rotation, 0.58);
|
||||
let fallback = prepared(&data, rotation, 0.58).with_importance(Some(&table)).unwrap();
|
||||
for u in [[0.125, 0.25], [0.5, 0.5], [0.75, 0.99], [1.0 / 9007199254740992.0, 1.0_f64.next_down()]] {
|
||||
let a = legacy.sample(u).unwrap();
|
||||
let b = fallback.sample(u).unwrap();
|
||||
assert_eq!(a.direction, b.direction);
|
||||
assert_eq!(a.radiance_source_rgb, b.radiance_source_rgb);
|
||||
assert_eq!(a.pdf_solid_angle, b.pdf_solid_angle);
|
||||
assert_eq!(fallback.pdf_solid_angle(b.direction).unwrap(), b.pdf_solid_angle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "raytrace-cpu")]
|
||||
#[test]
|
||||
fn photon_power_scan_is_cooperative_and_does_not_enable_hdr_importance() {
|
||||
use crate::lighting::environment_input::EnvironmentInput;
|
||||
let input = || {
|
||||
let mut bytes = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\nPRIMARIES=0.64 0.33 0.30 0.60 0.15 0.06 0.3127 0.3290\n\n-Y 2 +X 4\n".to_vec();
|
||||
for _ in 0..8 {
|
||||
bytes.extend_from_slice(&[128, 128, 128, 129]);
|
||||
}
|
||||
EnvironmentInput::new(HdrSource::new(bytes).unwrap(), 0.55, 2.0, 1).unwrap()
|
||||
};
|
||||
let mut legacy = input().begin(false, false).unwrap();
|
||||
let mut weighted = input().begin(false, true).unwrap();
|
||||
let mut importance_state = input().begin(true, true).unwrap();
|
||||
let mut ticks = [0; 3];
|
||||
for (index, state) in [&mut legacy, &mut weighted, &mut importance_state].into_iter().enumerate() {
|
||||
while !state.ready() {
|
||||
state.prepare_one(|| false).unwrap();
|
||||
ticks[index] += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(ticks[1], ticks[0] + 8);
|
||||
assert!((weighted.luminance_integral().unwrap() - 8.0 * PI).abs() < 1e-12);
|
||||
assert_eq!(weighted.luminance_integral().unwrap(), importance_state.luminance_integral().unwrap());
|
||||
for sample in [[0.125, 0.25], [0.4, 0.7], [0.9, 0.1]] {
|
||||
let a = legacy.view().unwrap().sample(sample).unwrap();
|
||||
let b = weighted.view().unwrap().sample(sample).unwrap();
|
||||
assert_eq!(a.direction, b.direction);
|
||||
assert_eq!(a.radiance_source_rgb, b.radiance_source_rgb);
|
||||
assert_eq!(a.pdf_solid_angle, b.pdf_solid_angle);
|
||||
}
|
||||
let black = decoded([[0; 4]; 8], "");
|
||||
let table = importance(&black);
|
||||
assert_eq!(table.luminance_integral(), 0.0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
//! Exact optical boundary predicates and a cooperative conservative contact hierarchy.
|
||||
use crate::job;
|
||||
use robust::{Coord, Coord3D, orient2d, orient3d};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
fn invalid(message: &str) -> job::JobError {
|
||||
job::JobError::new(job::ErrorCode::InvalidScene, message)
|
||||
}
|
||||
pub(crate) fn predicate_domain(points: &[Vec3]) -> job::Result<()> {
|
||||
let minimum = f64::from_bits((1023 - 128) << 52);
|
||||
if points.iter().flat_map(|p| [p.x, p.y, p.z]).any(|v| !v.is_finite() || v.abs() > 1e12 || (v != 0.0 && v.abs() < minimum)) {
|
||||
return Err(invalid("CPU exact glass certificate requires zero or coordinate magnitudes at least 2^-128 mm within the existing world bound"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn point(p: Vec3) -> Coord3D<f64> {
|
||||
Coord3D { x: p.x, y: p.y, z: p.z }
|
||||
}
|
||||
pub(crate) fn side(t: [Vec3; 3], p: Vec3) -> f64 {
|
||||
orient3d(point(t[0]), point(t[1]), point(t[2]), point(p))
|
||||
}
|
||||
pub(crate) fn key(p: Vec3) -> [u64; 3] {
|
||||
[p.x, p.y, p.z].map(|v| if v == 0.0 { 0 } else { v.to_bits() })
|
||||
}
|
||||
|
||||
pub(crate) fn same(a: Vec3, b: Vec3) -> bool {
|
||||
key(a) == key(b)
|
||||
}
|
||||
pub(crate) fn projection(t: [Vec3; 3]) -> usize {
|
||||
for drop in 0..3 {
|
||||
let q = t.map(|p| project(p, drop));
|
||||
if orient2d(q[0], q[1], q[2]) != 0.0 {
|
||||
return drop;
|
||||
}
|
||||
}
|
||||
2
|
||||
}
|
||||
pub(crate) fn project(p: Vec3, drop: usize) -> Coord<f64> {
|
||||
match drop {
|
||||
0 => Coord { x: p.y, y: p.z },
|
||||
1 => Coord { x: p.z, y: p.x },
|
||||
_ => Coord { x: p.x, y: p.y },
|
||||
}
|
||||
}
|
||||
pub(crate) fn in_triangle(t: [Vec3; 3], p: Vec3, drop: usize) -> bool {
|
||||
let q = t.map(|p| project(p, drop));
|
||||
let p = project(p, drop);
|
||||
let s = [orient2d(q[0], q[1], p), orient2d(q[1], q[2], p), orient2d(q[2], q[0], p)];
|
||||
!s.iter().any(|v| *v < 0.0) || !s.iter().any(|v| *v > 0.0)
|
||||
}
|
||||
pub(crate) fn on_segment(a: Coord<f64>, b: Coord<f64>, p: Coord<f64>) -> bool {
|
||||
orient2d(a, b, p) == 0.0 && p.x >= a.x.min(b.x) && p.x <= a.x.max(b.x) && p.y >= a.y.min(b.y) && p.y <= a.y.max(b.y)
|
||||
}
|
||||
pub(crate) fn opposite(a: f64, b: f64) -> bool {
|
||||
(a < 0.0 && b > 0.0) || (a > 0.0 && b < 0.0)
|
||||
}
|
||||
pub(crate) fn shared_vertex(p: Vec3, a: [Vec3; 3], b: [Vec3; 3]) -> bool {
|
||||
a.into_iter().any(|x| same(x, p)) && b.into_iter().any(|x| same(x, p))
|
||||
}
|
||||
pub(crate) fn coplanar_edge_bad(edges: [[Vec3; 2]; 2], triangles: [[Vec3; 3]; 2], allow_shared: bool, drop: usize) -> bool {
|
||||
let [[a, b], [c, d]] = edges;
|
||||
let [ta, tb] = triangles;
|
||||
let [pa, pb, pc, pd] = [a, b, c, d].map(|p| project(p, drop));
|
||||
let s = [orient2d(pa, pb, pc), orient2d(pa, pb, pd), orient2d(pc, pd, pa), orient2d(pc, pd, pb)];
|
||||
if opposite(s[0], s[1]) && opposite(s[2], s[3]) {
|
||||
return true;
|
||||
}
|
||||
for (p, q, r) in [(a, pc, pd), (b, pc, pd), (c, pa, pb), (d, pa, pb)] {
|
||||
if on_segment(q, r, project(p, drop)) && (!allow_shared || !shared_vertex(p, ta, tb)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// If collinear overlap has positive length, it is legal only for the exact
|
||||
// shared topological edge, not two incident edges extending the same way.
|
||||
if s.into_iter().all(|v| v == 0.0) {
|
||||
let axis_x = (pa.x - pb.x).abs() >= (pa.y - pb.y).abs();
|
||||
let value = |p: Coord<f64>| if axis_x { p.x } else { p.y };
|
||||
let lo = value(pa).min(value(pb)).max(value(pc).min(value(pd)));
|
||||
let hi = value(pa).max(value(pb)).min(value(pc).max(value(pd)));
|
||||
if lo < hi && !(allow_shared && ((same(a, c) && same(b, d)) || (same(a, d) && same(b, c)))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
/// True if the two exact triangles meet beyond their permitted shared simplex.
|
||||
/// This uses signs of adaptive exact predicates; no distance/angle tolerance.
|
||||
pub fn forbidden_contact(a: [Vec3; 3], b: [Vec3; 3], allow_shared: bool) -> bool {
|
||||
let sa = a.map(|p| side(b, p));
|
||||
let sb = b.map(|p| side(a, p));
|
||||
if sa.iter().all(|v| *v > 0.0) || sa.iter().all(|v| *v < 0.0) || sb.iter().all(|v| *v > 0.0) || sb.iter().all(|v| *v < 0.0) {
|
||||
return false;
|
||||
}
|
||||
let common = a.iter().filter(|p| b.iter().any(|q| same(**p, *q))).count();
|
||||
if common == 3 {
|
||||
return true;
|
||||
}
|
||||
for (t, u, s) in [(a, b, sa), (b, a, sb)] {
|
||||
let drop = projection(u);
|
||||
for (p, sign) in t.into_iter().zip(s) {
|
||||
if sign == 0.0 && in_triangle(u, p, drop) && (!allow_shared || !shared_vertex(p, a, b)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (i, j) in [(0, 1), (1, 2), (2, 0)] {
|
||||
let p = t[i];
|
||||
let q = t[j];
|
||||
if opposite(s[i], s[j]) {
|
||||
let w = [side([p, q, u[0]], u[1]), side([p, q, u[1]], u[2]), side([p, q, u[2]], u[0])];
|
||||
if !w.iter().any(|v| *v < 0.0) || !w.iter().any(|v| *v > 0.0) {
|
||||
return true;
|
||||
}
|
||||
} else if s[i] == 0.0 && s[j] == 0.0 {
|
||||
for (k, l) in [(0, 1), (1, 2), (2, 0)] {
|
||||
if coplanar_edge_bad([[p, q], [u[k], u[l]]], [a, b], allow_shared, drop) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
pub fn origin_crossing(t: [Vec3; 3], origin: Vec3) -> job::Result<i8> {
|
||||
predicate_domain(&[origin])?;
|
||||
let sign = side(t, origin);
|
||||
if sign == 0.0 && in_triangle(t, origin, projection(t)) {
|
||||
return Err(invalid("CPU camera or pupil origin lies exactly on a Glass boundary"));
|
||||
}
|
||||
let mut q = t.map(|p| project(p, 0));
|
||||
let p = project(origin, 0);
|
||||
let area = orient2d(q[0], q[1], q[2]);
|
||||
if area == 0.0 || sign == 0.0 || opposite(sign, area) {
|
||||
return Ok(0);
|
||||
}
|
||||
let contribution = if area > 0.0 { 1 } else { -1 };
|
||||
if area < 0.0 {
|
||||
q.swap(1, 2);
|
||||
}
|
||||
for (a, b) in [(0, 1), (1, 2), (2, 0)] {
|
||||
let a = q[a];
|
||||
let b = q[b];
|
||||
let edge = orient2d(a, b, p);
|
||||
let owns = b.y > a.y || (b.y == a.y && b.x < a.x);
|
||||
if edge < 0.0 || (edge == 0.0 && !owns) {
|
||||
return Ok(0);
|
||||
}
|
||||
}
|
||||
Ok(contribution)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Box3 {
|
||||
lo: Vec3,
|
||||
hi: Vec3,
|
||||
}
|
||||
impl Default for Box3 {
|
||||
fn default() -> Self {
|
||||
Self { lo: Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY), hi: Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY) }
|
||||
}
|
||||
}
|
||||
impl Box3 {
|
||||
fn add(&mut self, other: Self) {
|
||||
self.lo = self.lo.min(other.lo);
|
||||
self.hi = self.hi.max(other.hi);
|
||||
}
|
||||
fn overlaps(self, other: Self) -> bool {
|
||||
self.lo.x <= other.hi.x
|
||||
&& other.lo.x <= self.hi.x
|
||||
&& self.lo.y <= other.hi.y
|
||||
&& other.lo.y <= self.hi.y
|
||||
&& self.lo.z <= other.hi.z
|
||||
&& other.lo.z <= self.hi.z
|
||||
}
|
||||
fn triangle(p: [Vec3; 3]) -> Self {
|
||||
let mut b = Self::default();
|
||||
for p in p {
|
||||
b.lo = b.lo.min(p);
|
||||
b.hi = b.hi.max(p);
|
||||
}
|
||||
b
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Key {
|
||||
code: u64,
|
||||
ordinal: usize,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum Build {
|
||||
Bounds(usize),
|
||||
Keys(usize),
|
||||
Sort,
|
||||
Leaves(usize),
|
||||
Branches(usize),
|
||||
Ready,
|
||||
}
|
||||
/// A bounded Morton hierarchy for exact boundary contact candidates. The hierarchy
|
||||
/// never modifies the points used by predicates. Each build/query step processes
|
||||
/// one record, merge item, node or primitive and checks cancellation.
|
||||
pub struct ContactIndex {
|
||||
nodes: Vec<Box3>,
|
||||
order: Vec<Key>,
|
||||
scratch: Vec<Key>,
|
||||
leaves: usize,
|
||||
bounds: Box3,
|
||||
stage: Build,
|
||||
width: usize,
|
||||
begin: usize,
|
||||
left: usize,
|
||||
right: usize,
|
||||
end: usize,
|
||||
out: usize,
|
||||
}
|
||||
fn allocation() -> job::JobError {
|
||||
job::JobError::new(job::ErrorCode::AllocationFailed, "optical contact index allocation failed")
|
||||
}
|
||||
fn capacity() -> job::JobError {
|
||||
job::JobError::new(job::ErrorCode::LimitExceeded, "optical contact index exceeds its bounded representation")
|
||||
}
|
||||
fn vector<T: Clone>(count: usize, initial: T) -> job::Result<Vec<T>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(count).map_err(|_| allocation())?;
|
||||
if v.capacity() != count {
|
||||
return Err(capacity());
|
||||
}
|
||||
v.resize(count, initial);
|
||||
Ok(v)
|
||||
}
|
||||
impl ContactIndex {
|
||||
pub fn required_bytes(count: usize) -> job::Result<u64> {
|
||||
if count > job::MAX_CPU_TRIANGLES {
|
||||
return Err(capacity());
|
||||
}
|
||||
let leaves = count.max(1).checked_next_power_of_two().ok_or_else(capacity)?;
|
||||
leaves
|
||||
.checked_mul(2)
|
||||
.and_then(|n| n.checked_mul(std::mem::size_of::<Box3>()))
|
||||
.and_then(|n| count.checked_mul(2 * std::mem::size_of::<Key>()).and_then(|m| n.checked_add(m)))
|
||||
.and_then(|n| n.checked_add(std::mem::size_of::<Self>() + std::mem::size_of::<ContactQuery>()))
|
||||
.and_then(|n| u64::try_from(n).ok())
|
||||
.ok_or_else(capacity)
|
||||
}
|
||||
pub fn new(count: usize) -> job::Result<Self> {
|
||||
Self::required_bytes(count)?;
|
||||
let leaves = count.max(1).checked_next_power_of_two().ok_or_else(capacity)?;
|
||||
Ok(Self {
|
||||
nodes: vector(leaves * 2, Box3::default())?,
|
||||
order: vector(count, Key::default())?,
|
||||
scratch: vector(count, Key::default())?,
|
||||
leaves,
|
||||
bounds: Box3::default(),
|
||||
stage: Build::Bounds(0),
|
||||
width: 1,
|
||||
begin: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
end: 0,
|
||||
out: 0,
|
||||
})
|
||||
}
|
||||
pub fn step(&mut self, triangle: impl Fn(usize) -> Option<[Vec3; 3]>, cancel: &job::CancelToken) -> job::Result<bool> {
|
||||
cancel.check()?;
|
||||
match self.stage {
|
||||
Build::Bounds(i) => {
|
||||
if i == self.order.len() {
|
||||
self.stage = Build::Keys(0);
|
||||
} else {
|
||||
let p = triangle(i).ok_or_else(|| invalid("missing optical contact triangle"))?;
|
||||
predicate_domain(&p)?;
|
||||
self.bounds.add(Box3::triangle(p));
|
||||
self.stage = Build::Bounds(i + 1);
|
||||
}
|
||||
}
|
||||
Build::Keys(i) => {
|
||||
if i == self.order.len() {
|
||||
self.stage = Build::Sort;
|
||||
} else {
|
||||
let b = Box3::triangle(triangle(i).ok_or_else(|| invalid("missing optical contact triangle"))?);
|
||||
let c = (b.lo + b.hi) * 0.5;
|
||||
let span = self.bounds.hi - self.bounds.lo;
|
||||
let coordinate = |p: f64, lo: f64, s: f64| if s > 0.0 { (((p - lo) / s).clamp(0.0, 1.0) * 2097151.0) as u64 } else { 0 };
|
||||
let q = [
|
||||
coordinate(c.x, self.bounds.lo.x, span.x),
|
||||
coordinate(c.y, self.bounds.lo.y, span.y),
|
||||
coordinate(c.z, self.bounds.lo.z, span.z),
|
||||
];
|
||||
let mut code = 0;
|
||||
for bit in 0..21 {
|
||||
for (axis, value) in q.iter().enumerate() {
|
||||
code |= ((value >> bit) & 1) << (3 * bit + axis);
|
||||
}
|
||||
}
|
||||
self.order[i] = Key { code, ordinal: i };
|
||||
self.stage = Build::Keys(i + 1);
|
||||
}
|
||||
}
|
||||
Build::Sort => {
|
||||
let n = self.order.len();
|
||||
if self.width >= n {
|
||||
self.stage = Build::Leaves(0);
|
||||
} else if self.begin >= n {
|
||||
std::mem::swap(&mut self.order, &mut self.scratch);
|
||||
self.width = self.width.saturating_mul(2);
|
||||
self.begin = 0;
|
||||
self.out = 0;
|
||||
} else {
|
||||
if self.out == self.begin {
|
||||
self.left = self.begin;
|
||||
self.right = (self.begin + self.width).min(n);
|
||||
self.end = (self.begin + self.width.saturating_mul(2)).min(n);
|
||||
}
|
||||
let middle = (self.begin + self.width).min(n);
|
||||
let take_left = self.left < middle
|
||||
&& (self.right >= self.end
|
||||
|| (self.order[self.left].code, self.order[self.left].ordinal)
|
||||
<= (self.order[self.right].code, self.order[self.right].ordinal));
|
||||
self.scratch[self.out] = if take_left {
|
||||
let v = self.order[self.left];
|
||||
self.left += 1;
|
||||
v
|
||||
} else {
|
||||
let v = self.order[self.right];
|
||||
self.right += 1;
|
||||
v
|
||||
};
|
||||
self.out += 1;
|
||||
if self.out == self.end {
|
||||
self.begin = self.end;
|
||||
}
|
||||
}
|
||||
}
|
||||
Build::Leaves(i) => {
|
||||
if i == self.order.len() {
|
||||
self.stage = Build::Branches(self.leaves - 1);
|
||||
} else {
|
||||
self.nodes[self.leaves + i] =
|
||||
Box3::triangle(triangle(self.order[i].ordinal).ok_or_else(|| invalid("missing optical contact leaf"))?);
|
||||
self.stage = Build::Leaves(i + 1);
|
||||
}
|
||||
}
|
||||
Build::Branches(i) => {
|
||||
if i == 0 {
|
||||
self.stage = Build::Ready;
|
||||
} else {
|
||||
let mut b = self.nodes[2 * i];
|
||||
b.add(self.nodes[2 * i + 1]);
|
||||
self.nodes[i] = b;
|
||||
self.stage = Build::Branches(i - 1);
|
||||
}
|
||||
}
|
||||
Build::Ready => return Ok(true),
|
||||
}
|
||||
Ok(matches!(self.stage, Build::Ready))
|
||||
}
|
||||
/// A fresh traversal of all triangles whose exact bounds overlap the query.
|
||||
pub fn query(&self, triangle: [Vec3; 3]) -> job::Result<ContactQuery> {
|
||||
if !matches!(self.stage, Build::Ready) {
|
||||
return Err(invalid("optical contact index is incomplete"));
|
||||
}
|
||||
Ok(ContactQuery { bounds: Box3::triangle(triangle), stack: [0; 32], length: 1 })
|
||||
}
|
||||
/// None means the traversal is complete. Some(None) consumed one branch;
|
||||
/// Some(Some(index)) is one conservative candidate, never a contact decision.
|
||||
pub fn query_step(&self, query: &mut ContactQuery, cancel: &job::CancelToken) -> job::Result<Option<Option<usize>>> {
|
||||
cancel.check()?;
|
||||
if query.length == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
query.length -= 1;
|
||||
let at = query.stack[query.length];
|
||||
let at = if at == 0 { 1 } else { at };
|
||||
let node = *self.nodes.get(at).ok_or_else(|| invalid("optical contact node"))?;
|
||||
if !node.overlaps(query.bounds) {
|
||||
return Ok(Some(None));
|
||||
}
|
||||
if at >= self.leaves {
|
||||
return Ok(Some(self.order.get(at - self.leaves).map(|k| k.ordinal)));
|
||||
}
|
||||
for child in [2 * at + 1, 2 * at] {
|
||||
*query.stack.get_mut(query.length).ok_or_else(capacity)? = child;
|
||||
query.length += 1;
|
||||
}
|
||||
Ok(Some(None))
|
||||
}
|
||||
}
|
||||
/// Fixed traversal storage, included in contact admission; no per-pair allocation.
|
||||
pub struct ContactQuery {
|
||||
bounds: Box3,
|
||||
stack: [usize; 32],
|
||||
length: usize,
|
||||
}
|
||||
@@ -53,6 +53,7 @@ fn request(orientation: AnisotropicOrientation, reflect: bool) -> ImageRequest {
|
||||
roughness_y: 0.55,
|
||||
};
|
||||
let options = RaytraceOptions {
|
||||
threads: 1,
|
||||
anisotropic_faces: [Some(target), None],
|
||||
reflections: reflect.then(|| ReflectionOptions {
|
||||
bodies: vec![ReflectionBody { body: "explicit planar reflector".into(), mode: ReflectionMode::RoughSpecular }],
|
||||
|
||||
@@ -1,49 +1,272 @@
|
||||
//! Deterministic first-hit CPU guides, generated once without touching the sampling stream.
|
||||
use super::bvh::TraceCursor;
|
||||
use super::image::{error, invalid, primary_ray};
|
||||
use super::prepared::PreparedScene;
|
||||
use super::{Hit, Ray, WorkBudget};
|
||||
use crate::job::{CancelToken, ImageRequest, RaytraceOptions, Result, denoise::Guide};
|
||||
use super::prepared::{PreparedScene, SharedHit};
|
||||
use super::{Hit, Ray, WorkBudget, dielectric, glass_mesh, reflection};
|
||||
use crate::job::{
|
||||
CancelToken, DenoiseGuideClass as GuideClass, DielectricBoundary, DielectricOptions, ImageRequest, RaytraceOptions, ReflectionMode, Result,
|
||||
denoise::{Guide, SurfaceGuide},
|
||||
};
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::prelude::*;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum GlassCertification<'a> {
|
||||
Path(&'a glass_mesh::Task),
|
||||
Legacy(&'a [Option<dielectric::Certificate>]),
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum CertifiedBoundary {
|
||||
Path(glass_mesh::Region),
|
||||
Legacy(dielectric::Certificate),
|
||||
}
|
||||
impl GlassCertification<'_> {
|
||||
fn boundary(self, index: usize) -> Option<CertifiedBoundary> {
|
||||
match self {
|
||||
Self::Path(task) => task.region(index).ok().map(CertifiedBoundary::Path),
|
||||
Self::Legacy(certificates) => certificates.get(index).copied().flatten().map(CertifiedBoundary::Legacy),
|
||||
}
|
||||
}
|
||||
fn outside(self, options: &RaytraceOptions, request: &ImageRequest, ray: Ray) -> bool {
|
||||
let eye = request.camera.eye();
|
||||
match self {
|
||||
Self::Path(task) => {
|
||||
let origin = if request.camera.fov > 0.0 { eye } else { ray.origin() };
|
||||
super::origin::Task::required(task, &options.dielectrics, origin, ray.origin()).is_ok_and(|required| !required)
|
||||
}
|
||||
Self::Legacy(certificates) => certificates.iter().all(|certificate| {
|
||||
!matches!(certificate, Some(dielectric::Certificate::ClosedBox(bounds))
|
||||
if dielectric::contains(*bounds, eye) || dielectric::contains(*bounds, ray.origin()))
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl CertifiedBoundary {
|
||||
fn launch(self, hit: SharedHit<'_>, direction: Vec3) -> Option<Launch> {
|
||||
match self {
|
||||
Self::Path(region) => {
|
||||
let ray = Ray::new(hit.position, direction, 0.0_f64.next_up(), super::MAX_DISTANCE_MM).ok()?;
|
||||
Some(Launch { ray, plane: Some((region.primitive_begin, region.primitive_end, hit.vertices)) })
|
||||
}
|
||||
Self::Legacy(_) => Some(Launch::ordinary(hit.spawn(direction).ok()?)),
|
||||
}
|
||||
}
|
||||
fn unambiguous(self, hit: SharedHit<'_>) -> bool {
|
||||
match self {
|
||||
Self::Path(region) => region.parent.is_none(),
|
||||
Self::Legacy(dielectric::Certificate::ThinSheet) => true,
|
||||
Self::Legacy(dielectric::Certificate::ClosedBox(bounds)) => {
|
||||
(0..3)
|
||||
.filter(|&axis| {
|
||||
let value = super::component(hit.position, axis);
|
||||
value == super::component(bounds.min(), axis) || value == super::component(bounds.max(), axis)
|
||||
})
|
||||
.count()
|
||||
<= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Launch {
|
||||
ray: Ray,
|
||||
plane: Option<(usize, usize, [Vec3; 3])>,
|
||||
}
|
||||
impl Launch {
|
||||
fn ordinary(ray: Ray) -> Self {
|
||||
Self { ray, plane: None }
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum Segment {
|
||||
Reflected,
|
||||
GlassExit(usize),
|
||||
Transmitted,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Forward {
|
||||
launch: Launch,
|
||||
segment: Segment,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct OpticalWalk {
|
||||
primary: u32,
|
||||
reflected: SurfaceGuide,
|
||||
footprint: f64,
|
||||
segment: Segment,
|
||||
forward: Option<Forward>,
|
||||
}
|
||||
|
||||
fn body_id(hit: SharedHit<'_>) -> Result<u32> {
|
||||
u32::try_from(hit.body_index).ok().and_then(|body| body.checked_add(1)).ok_or_else(|| invalid("CPU denoising body identity overflow"))
|
||||
}
|
||||
fn guide_geometry(request: &ImageRequest, position: Vec3) -> (Vec3, f64, f64) {
|
||||
let camera = &request.camera;
|
||||
let relative = position - camera.eye();
|
||||
let depth = relative.dot(-camera.back());
|
||||
let width = 2.0 * camera.half_height() / f64::from(request.height);
|
||||
let footprint = if camera.fov == 0.0 { width } else { width * depth.max(0.0) / camera.distance };
|
||||
(relative, depth, footprint)
|
||||
}
|
||||
fn is_emitter(options: &RaytraceOptions, hit: SharedHit<'_>) -> bool {
|
||||
options.emissions.iter().flatten().any(|value| value.body_index == hit.body_index && value.strength > 0.0 && value.color.iter().any(|v| *v > 0.0))
|
||||
}
|
||||
fn lens_active(options: &RaytraceOptions) -> bool {
|
||||
options.lens.is_some_and(|lens| lens.aperture_radius_mm > 0.0)
|
||||
}
|
||||
fn glass_interface(options: &RaytraceOptions, ray: Ray, hit: SharedHit<'_>, ni: f64, nt: f64) -> Option<(dielectric::Interface, Vec3)> {
|
||||
let direction = ray.direction();
|
||||
let geometric = hit.oriented_geometric_normal();
|
||||
if options.path.is_none()
|
||||
|| options.caustics.is_some()
|
||||
|| options.dielectrics.iter().any(|input| input.model.is_some_and(|model| !model.is_identity()))
|
||||
{
|
||||
Some((dielectric::interface(direction, geometric, ni, nt).ok()?, geometric))
|
||||
} else {
|
||||
dielectric::shaded_interface(direction, geometric, hit.shading_normal, ni, nt).ok()
|
||||
}
|
||||
}
|
||||
fn glass_branches(
|
||||
options: &RaytraceOptions,
|
||||
index: usize,
|
||||
input: &DielectricOptions,
|
||||
boundary: CertifiedBoundary,
|
||||
ray: Ray,
|
||||
hit: SharedHit<'_>,
|
||||
) -> std::result::Result<(Option<Launch>, Option<Forward>), GuideClass> {
|
||||
let thin = input.boundary == DielectricBoundary::ThinSheet;
|
||||
let needed = if thin { 2 } else { 3 };
|
||||
if lens_active(options) {
|
||||
return Err(GuideClass::DepthOfField);
|
||||
}
|
||||
if options.path.is_some_and(|path| path.max_depth < needed) {
|
||||
return Err(GuideClass::PathDepth);
|
||||
}
|
||||
if input.model.is_some_and(|model| !model.is_identity()) {
|
||||
return Err(GuideClass::UnsupportedMaterial);
|
||||
}
|
||||
if input.subsurface.is_some_and(|medium| medium.active()) {
|
||||
return Err(GuideClass::Volume);
|
||||
}
|
||||
if !boundary.unambiguous(hit) {
|
||||
return Err(match boundary {
|
||||
CertifiedBoundary::Path(_) => GuideClass::NestedMedium,
|
||||
CertifiedBoundary::Legacy(_) => GuideClass::DegeneratePath,
|
||||
});
|
||||
}
|
||||
if !thin && !hit.front_face {
|
||||
return Err(GuideClass::NestedMedium);
|
||||
}
|
||||
let (interface, normal) = glass_interface(options, ray, hit, 1.0, input.ior).ok_or(GuideClass::DegeneratePath)?;
|
||||
let (interface, reflects, transmits) = if thin {
|
||||
let (sheet, reflected, transmitted) = dielectric::thin_sheet(ray.direction(), normal, input).map_err(|_| GuideClass::DegeneratePath)?;
|
||||
(sheet, reflected != Vec3::ZERO, transmitted != Vec3::ZERO)
|
||||
} else {
|
||||
(interface, interface.reflectance > 0.0, interface.reflectance < 1.0)
|
||||
};
|
||||
let geometric = hit.oriented_geometric_normal();
|
||||
let reflected = if reflects {
|
||||
if interface.reflected.dot(geometric) <= 0.0 {
|
||||
return Err(GuideClass::DegeneratePath);
|
||||
}
|
||||
Some(boundary.launch(hit, interface.reflected).ok_or(GuideClass::DegeneratePath)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let forward = if transmits {
|
||||
let direction = interface.transmitted.ok_or(GuideClass::TotalInternalReflection)?;
|
||||
if direction.dot(geometric) >= 0.0 {
|
||||
return Err(GuideClass::DegeneratePath);
|
||||
}
|
||||
let segment = if thin { Segment::Transmitted } else { Segment::GlassExit(index) };
|
||||
Some(Forward { launch: boundary.launch(hit, direction).ok_or(GuideClass::DegeneratePath)?, segment })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((reflected, forward))
|
||||
}
|
||||
fn glass_exit(
|
||||
options: &RaytraceOptions,
|
||||
index: usize,
|
||||
certification: Option<GlassCertification<'_>>,
|
||||
ray: Ray,
|
||||
hit: SharedHit<'_>,
|
||||
) -> std::result::Result<Launch, GuideClass> {
|
||||
let input = options.dielectrics.get(index).ok_or(GuideClass::Unavailable)?;
|
||||
let boundary = certification.and_then(|value| value.boundary(index)).ok_or(GuideClass::Unavailable)?;
|
||||
if hit.body_index != input.body_index || hit.front_face || !boundary.unambiguous(hit) {
|
||||
return Err(GuideClass::IncompatibleExit);
|
||||
}
|
||||
let (interface, _) = glass_interface(options, ray, hit, input.ior, 1.0).ok_or(GuideClass::DegeneratePath)?;
|
||||
let direction = interface.transmitted.ok_or(GuideClass::TotalInternalReflection)?;
|
||||
if direction.dot(hit.oriented_geometric_normal()) >= 0.0
|
||||
|| dielectric::attenuation(input.absorption_per_mm, ray.origin().dist(hit.position)).map_err(|_| GuideClass::DegeneratePath)? == Vec3::ZERO
|
||||
{
|
||||
return Err(GuideClass::DegeneratePath);
|
||||
}
|
||||
boundary.launch(hit, direction).ok_or(GuideClass::DegeneratePath)
|
||||
}
|
||||
|
||||
pub(super) struct Guides {
|
||||
pixels: Vec<Guide>,
|
||||
classes: Option<Vec<GuideClass>>,
|
||||
count: usize,
|
||||
cursor: Option<TraceCursor>,
|
||||
ray: Option<Ray>,
|
||||
hit: Option<Hit>,
|
||||
emitter_cursor: usize,
|
||||
analytic_emitter: bool,
|
||||
classified: bool,
|
||||
effect_cursor: usize,
|
||||
effect: Option<usize>,
|
||||
pending: Option<Guide>,
|
||||
pending: Option<(Guide, GuideClass)>,
|
||||
optical: Option<OpticalWalk>,
|
||||
}
|
||||
impl Guides {
|
||||
pub(super) fn required_bytes(width: u32, height: u32) -> Result<u64> {
|
||||
Self::required_bytes_with_diagnostics(width, height, false)
|
||||
}
|
||||
pub(super) fn required_bytes_with_diagnostics(width: u32, height: u32, diagnostics: bool) -> Result<u64> {
|
||||
let class_bytes = if diagnostics { std::mem::size_of::<GuideClass>() as u64 } else { 0 };
|
||||
(u64::from(width) * u64::from(height))
|
||||
.checked_mul(crate::job::denoise::GUIDE_BYTES)
|
||||
.checked_mul(crate::job::denoise::GUIDE_BYTES + class_bytes)
|
||||
.and_then(|v| v.checked_add(std::mem::size_of::<Self>() as u64))
|
||||
.ok_or_else(|| invalid("CPU denoising guide size overflow"))
|
||||
}
|
||||
pub(super) fn new(width: u32, height: u32) -> Result<Self> {
|
||||
Self::new_with_diagnostics(width, height, false)
|
||||
}
|
||||
pub(super) fn new_with_diagnostics(width: u32, height: u32, diagnostics: bool) -> Result<Self> {
|
||||
let count = usize::try_from(u64::from(width) * u64::from(height)).map_err(|_| invalid("CPU guide dimensions overflow"))?;
|
||||
let mut pixels = Vec::new();
|
||||
pixels.try_reserve_exact(count).map_err(|_| error(super::TraceError::AllocationFailed))?;
|
||||
if count == 0 || pixels.capacity() != count {
|
||||
return Err(invalid("CPU guide capacity exceeds admission"));
|
||||
}
|
||||
let classes = if diagnostics {
|
||||
let mut classes = Vec::new();
|
||||
classes.try_reserve_exact(count).map_err(|_| error(super::TraceError::AllocationFailed))?;
|
||||
if classes.capacity() != count {
|
||||
return Err(invalid("CPU guide diagnostic capacity exceeds admission"));
|
||||
}
|
||||
Some(classes)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Self {
|
||||
pixels,
|
||||
classes,
|
||||
count,
|
||||
cursor: None,
|
||||
ray: None,
|
||||
hit: None,
|
||||
emitter_cursor: 0,
|
||||
analytic_emitter: false,
|
||||
classified: false,
|
||||
effect_cursor: 0,
|
||||
effect: None,
|
||||
pending: None,
|
||||
optical: None,
|
||||
})
|
||||
}
|
||||
#[cfg(test)]
|
||||
@@ -67,11 +290,27 @@ impl Guides {
|
||||
}
|
||||
Ok(&self.pixels)
|
||||
}
|
||||
fn append(&mut self, guide: Guide, work: &mut WorkBudget, cancel: &CancelToken) -> Result<()> {
|
||||
pub(super) fn classes(&self) -> Result<&[GuideClass]> {
|
||||
let classes = self.classes.as_deref().ok_or_else(|| invalid("CPU guide diagnostics were not requested"))?;
|
||||
if !self.ready() || classes.len() != self.count {
|
||||
return Err(invalid("CPU guide diagnostics are incomplete"));
|
||||
}
|
||||
Ok(classes)
|
||||
}
|
||||
fn bypass(&mut self, reason: GuideClass) {
|
||||
self.pending = Some((Guide::default(), reason));
|
||||
}
|
||||
fn append(&mut self, guide: Guide, class: GuideClass, work: &mut WorkBudget, cancel: &CancelToken) -> Result<()> {
|
||||
work.tick(cancel).map_err(error)?;
|
||||
guide.validate()?;
|
||||
if self.ready() {
|
||||
return Err(invalid("CPU denoising guide cursor exceeds admission"));
|
||||
if self.ready() || class.eligible() != (guide.body != 0) {
|
||||
return Err(invalid("CPU denoising guide cursor or classification is invalid"));
|
||||
}
|
||||
if let Some(classes) = &mut self.classes {
|
||||
if classes.len() != self.pixels.len() || classes.len() >= classes.capacity() {
|
||||
return Err(invalid("CPU guide diagnostic cursor exceeds admission"));
|
||||
}
|
||||
classes.push(class);
|
||||
}
|
||||
self.pixels.push(guide);
|
||||
self.ray = None;
|
||||
@@ -79,11 +318,65 @@ impl Guides {
|
||||
self.hit = None;
|
||||
self.emitter_cursor = 0;
|
||||
self.analytic_emitter = false;
|
||||
self.classified = false;
|
||||
self.effect_cursor = 0;
|
||||
self.effect = None;
|
||||
self.pending = None;
|
||||
self.optical = None;
|
||||
Ok(())
|
||||
}
|
||||
fn launch(&mut self, launch: Launch) {
|
||||
let ray = launch.ray;
|
||||
self.ray = Some(ray);
|
||||
self.cursor = Some(match launch.plane {
|
||||
Some((begin, end, vertices)) => TraceCursor::from_plane(ray, begin..end, vertices),
|
||||
None => TraceCursor::new(ray, false),
|
||||
});
|
||||
self.hit = None;
|
||||
self.emitter_cursor = 0;
|
||||
self.analytic_emitter = false;
|
||||
self.classified = false;
|
||||
self.effect_cursor = 0;
|
||||
self.effect = None;
|
||||
}
|
||||
fn begin_optical(&mut self, primary: u32, footprint: f64, reflected: Option<Launch>, forward: Option<Forward>) {
|
||||
if let Some(launch) = reflected {
|
||||
self.optical = Some(OpticalWalk { primary, reflected: SurfaceGuide::default(), footprint, segment: Segment::Reflected, forward });
|
||||
self.launch(launch);
|
||||
} else if let Some(forward) = forward {
|
||||
self.optical = Some(OpticalWalk { primary, reflected: SurfaceGuide::default(), footprint, segment: forward.segment, forward: None });
|
||||
self.launch(forward.launch);
|
||||
} else {
|
||||
self.bypass(GuideClass::DegeneratePath);
|
||||
}
|
||||
}
|
||||
fn endpoint(&mut self, mut guide: Guide) {
|
||||
if let Some(mut optical) = self.optical {
|
||||
let body = guide.body;
|
||||
let transmitted = if matches!(optical.segment, Segment::Reflected) {
|
||||
optical.reflected = guide.surface();
|
||||
if let Some(forward) = optical.forward.take() {
|
||||
optical.segment = forward.segment;
|
||||
self.optical = Some(optical);
|
||||
self.launch(forward.launch);
|
||||
return;
|
||||
}
|
||||
0
|
||||
} else {
|
||||
body
|
||||
};
|
||||
let Some(identity) = Guide::optical_body(optical.primary, optical.reflected.body, transmitted) else {
|
||||
self.bypass(GuideClass::IdentityLimit);
|
||||
return;
|
||||
};
|
||||
if transmitted != 0 {
|
||||
guide.reflected = optical.reflected;
|
||||
}
|
||||
guide.body = identity;
|
||||
}
|
||||
let class = if self.optical.is_some() { GuideClass::Optical } else { GuideClass::Surface };
|
||||
self.pending = Some((guide, class));
|
||||
}
|
||||
/// A continuation creates one ray, visits one BVH node/triangle, scans one
|
||||
/// bounded emitter/effect row, or resolves/stores one guide. The normal image budget is shared.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -92,10 +385,11 @@ impl Guides {
|
||||
scene: PreparedScene<'_>,
|
||||
request: &ImageRequest,
|
||||
options: &RaytraceOptions,
|
||||
glass: Option<GlassCertification<'_>>,
|
||||
clip: (f64, f64),
|
||||
effects: &[(usize, crate::surface_patterns::PreparedEffect)],
|
||||
mut maps: Option<&mut crate::job::textures::cpu_cache::CpuTextureCache>,
|
||||
mut aging: Option<&mut super::aging::Task>,
|
||||
maps: Option<&mut crate::job::textures::cpu_cache::CpuTextureCache>,
|
||||
aging: Option<&mut super::aging::Task>,
|
||||
work: &mut WorkBudget,
|
||||
cancel: &CancelToken,
|
||||
) -> Result<()> {
|
||||
@@ -103,28 +397,45 @@ impl Guides {
|
||||
if self.ready() {
|
||||
return Err(invalid("CPU denoising guides are already complete"));
|
||||
}
|
||||
if let Some(guide) = self.pending {
|
||||
return self.append(guide, work, cancel);
|
||||
if let Some((guide, class)) = self.pending {
|
||||
return self.append(guide, class, work, cancel);
|
||||
}
|
||||
if self.ray.is_none() {
|
||||
work.tick(cancel).map_err(error)?;
|
||||
let pixel = self.pixels.len();
|
||||
let width = request.width as usize;
|
||||
let ray = primary_ray(request, (pixel % width) as f64 + 0.5, (pixel / width) as f64 + 0.5, clip).map_err(error)?;
|
||||
if let Some(maps) = &mut maps {
|
||||
maps.reset_hit();
|
||||
}
|
||||
if let Some(aging) = &mut aging {
|
||||
aging.reset();
|
||||
}
|
||||
self.ray = Some(ray);
|
||||
self.cursor = Some(TraceCursor::new(ray, false));
|
||||
self.launch(Launch::ordinary(ray));
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(cursor) = &mut self.cursor {
|
||||
if let Some(hit) = cursor.step(scene.bvh, work, cancel).map_err(error)? {
|
||||
let complete = if matches!(glass, Some(GlassCertification::Path(_))) {
|
||||
cursor.step_ordered(scene.bvh, work, cancel, |new, old| {
|
||||
let is_glass = |hit: Hit| {
|
||||
scene
|
||||
.primitives
|
||||
.get(hit.primitive_index)
|
||||
.is_some_and(|look| options.dielectrics.iter().any(|input| input.body_index == look.body_index))
|
||||
};
|
||||
match (is_glass(new), is_glass(old)) {
|
||||
(false, true) => true,
|
||||
(true, false) => false,
|
||||
_ => new.primitive_index < old.primitive_index,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
cursor.step(scene.bvh, work, cancel)
|
||||
}
|
||||
.map_err(error)?;
|
||||
if let Some(hit) = complete {
|
||||
self.cursor = None;
|
||||
self.hit = hit;
|
||||
if let Some(maps) = maps {
|
||||
maps.reset_hit();
|
||||
}
|
||||
if let Some(aging) = aging {
|
||||
aging.reset();
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
@@ -146,13 +457,59 @@ impl Guides {
|
||||
return Ok(());
|
||||
}
|
||||
if self.analytic_emitter {
|
||||
return self.append(Guide::default(), work, cancel);
|
||||
let class = if self.optical.is_some() { GuideClass::Unavailable } else { GuideClass::Emitter };
|
||||
return self.append(Guide::default(), class, work, cancel);
|
||||
}
|
||||
let Some(hit) = self.hit else {
|
||||
return self.append(Guide::default(), work, cancel);
|
||||
let class = if self.optical.is_some() { GuideClass::Unavailable } else { GuideClass::Background };
|
||||
return self.append(Guide::default(), class, work, cancel);
|
||||
};
|
||||
work.tick(cancel).map_err(error)?;
|
||||
let mut shared = scene.resolve_hit(ray, hit).map_err(error)?;
|
||||
if !self.classified {
|
||||
self.classified = true;
|
||||
if is_emitter(options, shared) {
|
||||
self.bypass(if self.optical.is_some() { GuideClass::Unavailable } else { GuideClass::Emitter });
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(mut walk) = self.optical
|
||||
&& let Segment::GlassExit(index) = walk.segment
|
||||
{
|
||||
match glass_exit(options, index, glass, ray, shared) {
|
||||
Ok(launch) => {
|
||||
walk.segment = Segment::Transmitted;
|
||||
self.optical = Some(walk);
|
||||
self.launch(launch);
|
||||
}
|
||||
Err(reason) => self.bypass(reason),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((index, input)) = options.dielectrics.iter().enumerate().find(|(_, input)| input.body_index == shared.body_index) {
|
||||
let branches = if self.optical.is_some() {
|
||||
Err(GuideClass::FurtherTransmission)
|
||||
} else if let Some(glass) = glass {
|
||||
if glass.outside(options, request, ray) {
|
||||
glass
|
||||
.boundary(index)
|
||||
.ok_or(GuideClass::Unavailable)
|
||||
.and_then(|boundary| glass_branches(options, index, input, boundary, ray, shared))
|
||||
} else {
|
||||
Err(GuideClass::NestedMedium)
|
||||
}
|
||||
} else {
|
||||
Err(GuideClass::Unavailable)
|
||||
};
|
||||
match branches {
|
||||
Ok((reflected, forward)) => {
|
||||
let (_, _, footprint) = guide_geometry(request, shared.position);
|
||||
self.begin_optical(body_id(shared)?, footprint, reflected, forward);
|
||||
}
|
||||
Err(reason) => self.bypass(reason),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if let Some((body, _)) = effects.get(self.effect_cursor) {
|
||||
if *body == shared.body_index {
|
||||
self.effect = Some(self.effect_cursor);
|
||||
@@ -174,15 +531,19 @@ impl Guides {
|
||||
let has_face_override = scene.primitives.get(shared.primitive_index).is_none_or(|look| look.has_face_override);
|
||||
let pixel = self.pixels.len();
|
||||
let width = request.width as usize;
|
||||
let footprint = super::image::camera_footprint(
|
||||
request,
|
||||
(pixel % width) as f64 + 0.5,
|
||||
(pixel / width) as f64 + 0.5,
|
||||
1.0,
|
||||
clip,
|
||||
shared.position,
|
||||
shared.oriented_geometric_normal(),
|
||||
)?;
|
||||
let footprint = if self.optical.is_none() {
|
||||
super::image::camera_footprint(
|
||||
request,
|
||||
(pixel % width) as f64 + 0.5,
|
||||
(pixel / width) as f64 + 0.5,
|
||||
1.0,
|
||||
clip,
|
||||
shared.position,
|
||||
shared.oriented_geometric_normal(),
|
||||
)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
maps.begin_hit(
|
||||
crate::surface_patterns::ShadingSurface { surface: shared.surface, shading_normal: shared.shading_normal },
|
||||
(!has_face_override).then_some(shared.body_index),
|
||||
@@ -226,47 +587,193 @@ impl Guides {
|
||||
shared.surface = changed.surface;
|
||||
shared.shading_normal = changed.shading_normal;
|
||||
}
|
||||
// Match the GPU's first-hit policy. The explicit CPU Mirror mode is the
|
||||
// corresponding sharp-reflection override even on a dielectric base colour.
|
||||
let glass = options.dielectrics.iter().any(|value| value.body_index == shared.body_index);
|
||||
let mirror = options.reflections.as_ref().is_some_and(|settings| {
|
||||
settings.bodies.iter().any(|value| value.body == shared.body_name && value.mode == crate::job::ReflectionMode::Mirror)
|
||||
});
|
||||
let emitter = options
|
||||
.emissions
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|value| value.body_index == shared.body_index && value.strength > 0.0 && value.color.iter().any(|v| *v > 0.0));
|
||||
let reflection = options
|
||||
.reflections
|
||||
.as_ref()
|
||||
.filter(|settings| settings.max_bounces > 0)
|
||||
.and_then(|settings| settings.bodies.iter().find(|value| value.body == shared.body_name).map(|value| value.mode));
|
||||
let mirror = reflection == Some(ReflectionMode::Mirror);
|
||||
let near_perfect = shared.surface.metallic() >= 0.99 && shared.surface.roughness() <= 0.05;
|
||||
let smooth_metal = shared.surface.metallic() > 0.5 && shared.surface.roughness() < 0.25;
|
||||
let active_coat = shared.surface.clear_coat().is_some_and(|coat| coat.weight() > 0.0);
|
||||
let sharp_coat = shared.surface.clear_coat().is_some_and(|coat| coat.weight() > 0.0 && coat.roughness() < 0.05);
|
||||
if glass || mirror || emitter || smooth_metal || sharp_coat {
|
||||
self.pending = Some(Guide::default());
|
||||
let geometric = shared.oriented_geometric_normal();
|
||||
let (relative, depth, surface_footprint) = guide_geometry(request, shared.position);
|
||||
let body = body_id(shared)?;
|
||||
if self.optical.is_none() && (mirror || (near_perfect && !active_coat && (options.path.is_some() || reflection.is_some()))) {
|
||||
if lens_active(options) {
|
||||
self.bypass(GuideClass::DepthOfField);
|
||||
return Ok(());
|
||||
}
|
||||
if options.path.is_some_and(|path| path.max_depth < 2) {
|
||||
self.bypass(GuideClass::PathDepth);
|
||||
return Ok(());
|
||||
}
|
||||
if glass.is_some_and(|glass| !glass.outside(options, request, ray)) {
|
||||
self.bypass(GuideClass::NestedMedium);
|
||||
return Ok(());
|
||||
}
|
||||
let reflected = reflection::mirror(shared.surface, shared.shading_normal, -ray.direction())
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|sample| sample.weight != Vec3::ZERO && sample.direction.dot(geometric) > 0.0)
|
||||
.and_then(|sample| shared.spawn(sample.direction).ok())
|
||||
.map(Launch::ordinary);
|
||||
self.begin_optical(body, surface_footprint, reflected, None);
|
||||
return Ok(());
|
||||
}
|
||||
let geometric = shared.oriented_geometric_normal();
|
||||
let normal = if shared.shading_normal.dot(-ray.direction()) > 0.0 { shared.shading_normal } else { geometric };
|
||||
let camera = &request.camera;
|
||||
let relative = shared.position - camera.eye();
|
||||
let depth = relative.dot(-camera.back());
|
||||
let mut footprint = 2.0 * camera.half_height() / f64::from(request.height);
|
||||
if camera.fov != 0.0 {
|
||||
footprint *= depth.max(0.0) / camera.distance;
|
||||
if mirror || smooth_metal || sharp_coat {
|
||||
self.bypass(if self.optical.is_some() { GuideClass::ReflectiveEndpoint } else { GuideClass::UnsupportedMaterial });
|
||||
return Ok(());
|
||||
}
|
||||
let normal = if shared.shading_normal.dot(-ray.direction()) > 0.0 { shared.shading_normal } else { geometric };
|
||||
let array = |v: Vec3| [v.x as f32, v.y as f32, v.z as f32];
|
||||
let body = u32::try_from(shared.body_index)
|
||||
.ok()
|
||||
.and_then(|body| body.checked_add(1))
|
||||
.ok_or_else(|| invalid("CPU denoising body identity overflow"))?;
|
||||
let guide = Guide {
|
||||
albedo: array(shared.surface.base_color()),
|
||||
body,
|
||||
normal: array(normal),
|
||||
depth: depth as f32,
|
||||
position: array(relative),
|
||||
footprint: footprint.max(0.000001) as f32,
|
||||
footprint: surface_footprint.max(self.optical.map_or(0.0, |walk| walk.footprint)).max(0.000001) as f32,
|
||||
reflected: SurfaceGuide::default(),
|
||||
};
|
||||
guide.validate()?;
|
||||
self.pending = Some(guide);
|
||||
self.endpoint(guide);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn hit() -> SharedHit<'static> {
|
||||
SharedHit {
|
||||
body_index: 0,
|
||||
body_name: "glass",
|
||||
primitive_index: 0,
|
||||
distance_mm: 1.0,
|
||||
position: Vec3::ZERO,
|
||||
geometric_normal: Vec3::new(0.0, 0.0, 1.0),
|
||||
front_face: true,
|
||||
shading_normal: Vec3::new(0.0, 0.0, 1.0),
|
||||
surface: crate::surface::OpaqueSurface::new(Vec3::new(0.5, 0.5, 0.5), 0.0, 0.5, 1.5).unwrap(),
|
||||
vertices: [Vec3::new(-1.0, -1.0, 0.0), Vec3::new(1.0, -1.0, 0.0), Vec3::new(0.0, 2.0, 0.0)],
|
||||
barycentric: [1.0 / 3.0; 3],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optical_branch_diagnostics_explain_actual_policy_bypasses() {
|
||||
let mut options = RaytraceOptions { path: Some(crate::job::CpuPathOptions { max_depth: 3 }), ..Default::default() };
|
||||
let mut input = dielectric::tests::input(DielectricBoundary::ThinSheet);
|
||||
let boundary = CertifiedBoundary::Legacy(dielectric::Certificate::ThinSheet);
|
||||
let ray = Ray::new(Vec3::new(0.0, 0.0, 1.0), Vec3::new(0.0, 0.0, -1.0), 0.0, 10.0).unwrap();
|
||||
assert!(glass_branches(&options, 0, &input, boundary, ray, hit()).is_ok());
|
||||
options.lens = Some(crate::job::ThinLens { aperture_radius_mm: 0.1, focus_distance_mm: 1.0 });
|
||||
assert_eq!(glass_branches(&options, 0, &input, boundary, ray, hit()).err(), Some(GuideClass::DepthOfField));
|
||||
options.lens = None;
|
||||
options.path.as_mut().unwrap().max_depth = 1;
|
||||
assert_eq!(glass_branches(&options, 0, &input, boundary, ray, hit()).err(), Some(GuideClass::PathDepth));
|
||||
options.path.as_mut().unwrap().max_depth = 3;
|
||||
input.model = Some(crate::glass_model::GlassModel { roughness: 0.2, ..Default::default() });
|
||||
assert_eq!(glass_branches(&options, 0, &input, boundary, ray, hit()).err(), Some(GuideClass::UnsupportedMaterial));
|
||||
input.model = None;
|
||||
assert!(glass_branches(&options, 0, &input, boundary, ray, hit()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optical_endpoint_misses_and_emitters_do_not_classify_the_primary_as_background() {
|
||||
let cancel = CancelToken::default();
|
||||
let bvh = super::super::bvh::BvhBuilder::new(Vec::new(), Default::default(), &cancel).unwrap().finish().unwrap();
|
||||
let source = crate::ProductScene { meshes: Vec::new(), bounds: solidcraft_geom::Aabb3::EMPTY };
|
||||
let request = ImageRequest {
|
||||
studio_weights: None,
|
||||
integrator: Default::default(),
|
||||
raytrace: None,
|
||||
camera: crate::Camera::default(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
background: [0; 3],
|
||||
transparent_background: false,
|
||||
environment: None,
|
||||
textures: None,
|
||||
};
|
||||
let ray = Ray::new(Vec3::new(0.0, 0.0, 1.0), Vec3::new(0.0, 0.0, -1.0), 0.0, 10.0).unwrap();
|
||||
for optical in [false, true] {
|
||||
for emitter in [false, true] {
|
||||
let mut options = RaytraceOptions { path: Some(crate::job::CpuPathOptions { max_depth: 3 }), ..Default::default() };
|
||||
if emitter {
|
||||
options.area_lights = Some(vec![crate::job::RectangleLightOptions {
|
||||
center_mm: [0.0; 3],
|
||||
edge_u_mm: [2.0, 0.0, 0.0],
|
||||
edge_v_mm: [0.0, 2.0, 0.0],
|
||||
color: [1.0; 3],
|
||||
intensity: 1.0,
|
||||
}]);
|
||||
}
|
||||
let mut guides = Guides::new_with_diagnostics(1, 1, true).unwrap();
|
||||
if optical {
|
||||
guides.begin_optical(1, 1.0, Some(Launch::ordinary(ray)), None);
|
||||
} else {
|
||||
guides.launch(Launch::ordinary(ray));
|
||||
}
|
||||
let mut work = WorkBudget::new(100).unwrap();
|
||||
for _ in 0..16 {
|
||||
if guides.ready() {
|
||||
break;
|
||||
}
|
||||
guides
|
||||
.step(
|
||||
PreparedScene { bvh: &bvh, primitives: &[], source: &source },
|
||||
&request,
|
||||
&options,
|
||||
None,
|
||||
(0.0, 10.0),
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
&mut work,
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert!(guides.ready(), "optical={optical} emitter={emitter}");
|
||||
let expected = if optical {
|
||||
GuideClass::Unavailable
|
||||
} else if emitter {
|
||||
GuideClass::Emitter
|
||||
} else {
|
||||
GuideClass::Background
|
||||
};
|
||||
assert_eq!(guides.classes().unwrap(), &[expected], "optical={optical} emitter={emitter}");
|
||||
assert_eq!(guides.pixels().unwrap()[0].body, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_plane_is_optional_bounded_and_cancellation_safe() {
|
||||
assert_eq!(std::mem::size_of::<Guide>(), 96);
|
||||
assert_eq!(Guides::required_bytes_with_diagnostics(3, 1, true).unwrap() - Guides::required_bytes(3, 1).unwrap(), 3);
|
||||
let mut disabled = Guides::new(1, 1).unwrap();
|
||||
assert!(disabled.classes.is_none());
|
||||
let cancel = CancelToken::default();
|
||||
let mut work = WorkBudget::new(100).unwrap();
|
||||
disabled.append(Guide::default(), GuideClass::Background, &mut work, &cancel).unwrap();
|
||||
assert!(disabled.ready());
|
||||
assert!(disabled.classes().is_err());
|
||||
let mut guides = Guides::new_with_diagnostics(3, 1, true).unwrap();
|
||||
assert!(guides.classes().is_err());
|
||||
for class in [GuideClass::Background, GuideClass::Emitter, GuideClass::NestedMedium] {
|
||||
guides.append(Guide::default(), class, &mut work, &cancel).unwrap();
|
||||
}
|
||||
assert_eq!(guides.classes().unwrap(), &[GuideClass::Background, GuideClass::Emitter, GuideClass::NestedMedium]);
|
||||
assert_eq!(guides.classes.as_ref().unwrap().capacity(), 3);
|
||||
let mut interrupted = Guides::new_with_diagnostics(1, 1, true).unwrap();
|
||||
cancel.cancel();
|
||||
assert!(interrupted.append(Guide::default(), GuideClass::Emitter, &mut work, &cancel).is_err());
|
||||
assert!(interrupted.pixels.is_empty());
|
||||
assert!(interrupted.classes.as_ref().unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user