mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-05 11:35:55 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b55174743 | |||
| 5821ca2b5e | |||
| 8829c1c3c5 |
Generated
+2
@@ -4286,9 +4286,11 @@ dependencies = [
|
||||
name = "solidcraft-render"
|
||||
version = "0.40.0-rc.2"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"image",
|
||||
"moxcms",
|
||||
"png",
|
||||
"robust",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
|
||||
+13
-13
@@ -2789,9 +2789,9 @@
|
||||
"render.adaptive_absolute": "الحد الأدنى المطلق لخطأ RGB",
|
||||
"render.adaptive_coverage": "هدف خطأ التغطية",
|
||||
"render.adaptive_enable": "توزيع العيّنات باستخدام عيّنات استكشافية مستقلة",
|
||||
"render.adaptive_help": "على GPU فقط. تُستبعد العيّنات الاستكشافية المستقلة، ثم تُوزَّع 16 عيّنة إنتاجية أو أكثر لكل بكسل حتى قيمة «عيّنات لكل بكسل». يوجّه تباين RGB والتغطية هذا التوزيع؛ قد تفوت المسارات النادرة وقد تبقى البكسلات المحدودة مشوشة. يحصي التقرير النهائي العمل الفعلي. لا ضمان معتمد للتشويش أو للزمن.",
|
||||
"render.adaptive_help": "تُهمل العينات الاستطلاعية المستقلة، ثم يُحدد لكل بكسل 16 عينة إنتاج على الأقل حتى قيمة العينات لكل بكسل. تشمل حدود موارد CPU المرحلتين. يوجه تباين RGB والتغطية التوزيع؛ وقد تبقى ضوضاء في البكسلات التي بلغت الحد. يحصي التقرير النهائي العينات الفعلية.",
|
||||
"render.adaptive_minimum": "الحد الأدنى للعيّنات الإنتاجية",
|
||||
"render.adaptive_pending": "التوزيع التكيّفي على GPU بعيّنات استكشافية مستقلة وطبقات فرينل الشرطية متاحة. التقارب المعتمد وضمانات الأحداث النادرة والنقل التكيّفي على CPU غير متاحة بعد.",
|
||||
"render.adaptive_pending": "يتوفر التوزيع التكيفي بعينات استطلاعية مستقلة على CPU وGPU. تتطلب طبقات فرينل الشرطية GPU. لا يضمن التوزيع الإحصائي التقارب.",
|
||||
"render.adaptive_pilots": "العيّنات الاستكشافية لكل بكسل",
|
||||
"render.adaptive_relative": "هدف خطأ RGB النسبي",
|
||||
"render.add_ground": "إضافة أرضية ملائمة إلى الإعداد",
|
||||
@@ -2852,7 +2852,7 @@
|
||||
"render.appearance_editor.replace": "استبدال الخصائص البصرية المحفوظة لهذا الجسم",
|
||||
"render.appearance_editor.revalidate": "إعادة التحقق من هذا الجسم",
|
||||
"render.appearance_editor.save": "حفظ هذه الخصائص البصرية",
|
||||
"render.appearance_editor.save_reason": "يتطلب حفظ هذه الخصائص البصرية زجاجًا فيزيائيًا على جسم حالي في بطاقة الرسومات. تبقى الطبقة الشفافة وخرائط الصور المحلية مؤقتة.",
|
||||
"render.appearance_editor.save_reason": "لحفظ الزجاج، فعّله على جسم حالي يستخدم CPU أو GPU. للطلاء الشفاف استخدم حفظ الطلاء مع اللقطة. تبقى ملفات الصور المحلية مؤقتة.",
|
||||
"render.appearance_editor.saved": "محفوظ مع هذه اللقطة",
|
||||
"render.appearance_editor.saved_state": "هذه الخصائص البصرية محفوظة مع هذه اللقطة.",
|
||||
"render.appearance_editor.scatter_reason": "التشتت الداخلي يتطلب حدًّا لجسم مصمت مغلق.",
|
||||
@@ -2948,7 +2948,7 @@
|
||||
"render.children.copy_request": "نسخ JSON الطلب",
|
||||
"render.children.coverage_error": "خطأ التغطية",
|
||||
"render.children.cpu": "تتبّع الأشعة على المعالج",
|
||||
"render.children.denoise": "تقليل التشويش في الصورة النهائية",
|
||||
"render.children.denoise": "تقليل تشويش الصورة",
|
||||
"render.children.depth": "عمق المسار",
|
||||
"render.children.distortion": "التشويه",
|
||||
"render.children.edge_samples": "عينات الحواف",
|
||||
@@ -3080,7 +3080,7 @@
|
||||
"render.cpu_point_help": "حتى ثمانية أضواء نقطية تُضاف إلى الإضاءة الاتجاهية المختارة. المسافات بالمليمتر؛ ويجب ألا يتجاوز الحد الأدنى المسافة المرجعية.",
|
||||
"render.cpu_primary_samples": "عيّنات الحواف لكل بكسل",
|
||||
"render.cpu_progressive": "CPU تدريجي",
|
||||
"render.cpu_progressive_help": "يُراكم مُكامل CPU الحالي على تمريرات من عيّنة أساسية واحدة. الانعكاسات المختارة تتطلب عيّنة واحدة؛ والأضواء المساحية والعوازل وتباين الخواص تتطلب «صورة واحدة».",
|
||||
"render.cpu_progressive_help": "تُجمع عينات كاميرا CPU الجديدة ويُعرض كل تمرير مكتمل. تستخدم الانعكاسات المحددة محاولة واحدة لكل عينة كاميرا؛ ويحتفظ الزجاج المعتمد والتباين الاتجاهي المستوي بحدودهما الحالية. تتشارك العينات الاستطلاعية التكيفية والإنتاج ميزانية ملف CPU.",
|
||||
"render.cpu_progressive_limits": "بحد أقصى 1,048,576 بكسل و16,777,216 عملية تقييم بكسل × عيّنة. تغيير العيّنات لا يغيّر حجم الصورة تلقائيًا أبدًا.",
|
||||
"render.cpu_progressive_seed": "بذرة عيّنات CPU",
|
||||
"render.cpu_progressive_spp": "عيّنات لكل بكسل",
|
||||
@@ -3256,7 +3256,7 @@
|
||||
"render.export_display": "تصدير: PNG للعرض",
|
||||
"render.export_image_label": "تصدير الصورة",
|
||||
"render.export_linear": "تصدير: EXR خطي خام",
|
||||
"render.exr_pending": "يستطيع GPU الاحتفاظ بـ EXR خطي خام. الضغط والصور متعددة الطبقات وتمريرات التصيير المساعدة غير متاحة بعد.",
|
||||
"render.exr_pending": "يمكن لتتبع الأشعة على CPU وتتبع المسارات على GPU الاحتفاظ بـ EXR خطي خام. الضغط والصور متعددة الطبقات وتمريرات التصيير المساعدة غير متاحة بعد.",
|
||||
"render.film_grain": "حبيبات فوتوغرافية",
|
||||
"render.fit": "ملاءمة المنتج",
|
||||
"render.fixed_camera": "الكاميرا",
|
||||
@@ -3619,7 +3619,7 @@
|
||||
"render.method_cpu": "تتبع الأشعة على CPU",
|
||||
"render.method_gpu": "تتبع المسارات على GPU",
|
||||
"render.method_limit_raster": "التنقيط يستخدم معاينة الاستوديو المعتمة الثابتة. اختر طريقة تتبع أشعة صريحة لعناصر التحكم البصرية المدعومة.",
|
||||
"render.method_raster": "تنقيط على CPU",
|
||||
"render.method_raster": "تصيير نقطي على CPU (تخطيطي)",
|
||||
"render.navigation_help": "تتحرك الكاميرا كما في التصميم (التفضيلات › التنقل). «حفظ الإعداد» يسجّل الكاميرا الحالية.",
|
||||
"render.nested_enable": "عوازل متداخلة معتمدة",
|
||||
"render.nested_help": "حتى 16 طبقة حاوية؛ يجب أن يكون كل أصل محدبًا وكل فرع داخله تمامًا. التقاطعات والأسطح المتلامسة والصفائح الرقيقة ونفاذ الظل المستقيم تُرفض. حالة وجود الكاميرا في الداخل تتبع التسلسل المعتمد. لا يُنشئ سطح سائل ولا يدمج حدود CAD المتلامسة.",
|
||||
@@ -3647,7 +3647,7 @@
|
||||
"render.output_exr_unavailable": "إعداد مخرجات EXR غير متاح هنا. تصدير EXR الخطي يتطلب صورة GPU مكتملة بنسخة رئيسية خطية محتفظ بها.",
|
||||
"render.output_fit": "ملاءمة",
|
||||
"render.output_format": "الصيغة",
|
||||
"render.output_format_status": "PNG هو صورة العرض المكتملة بالضبط. EXR الاختياري على GPU يحتفظ بالإشعاع الخام الخطي للمشهد بصيغة FLOAT32 قبل مرشحات العرض. التصدير لا يستبدل أبدًا ذاكرة المعاينة المؤقتة أو مهمة أخرى.",
|
||||
"render.output_format_status": "PNG هو صورة العرض المكتملة بالضبط. يحتفظ EXR الاختياري على CPU أو GPU بالإشعاع الخام الخطي للمشهد بصيغة FLOAT32 قبل مرشحات العرض. لا يستبدل التصدير أبدًا الصورة بذاكرة المعاينة المؤقتة أو بمهمة مختلفة.",
|
||||
"render.output_grading": "تدرّج الألوان",
|
||||
"render.output_image": "الصورة",
|
||||
"render.output_largest": "الأقصى {width} × {height}",
|
||||
@@ -3732,7 +3732,7 @@
|
||||
"render.quality_custom": "مخصص",
|
||||
"render.quality_draft": "مسودة",
|
||||
"render.quality_high": "عالية",
|
||||
"render.quality_projection_help": "إعدادات Q الجاهزة تتضمن متطلبات مستقبلية. هذا الإجراء يُسقط فقط ميزانية PNG مدعومة بعيّنات ثابتة، ويُغفل صراحةً الإيقاف التكيّفي والإخراج الخطي الخام وحدود النفاذ المستقلة.",
|
||||
"render.quality_projection_help": "تغيّر هذه المراجعة الدقة وميزانية العينات وعمق المسار والبذرة وتقليل التشويش فقط. تُحفظ الإعدادات الأخرى؛ ولا تثبت الميزانية تقارب الصورة.",
|
||||
"render.quality_review": "مراجعة ميزانية العيّنات",
|
||||
"render.queue_pending": "لا توجد في هذه الواجهة قائمة إنتاج تلقائية ولا مُصيّر للرسوم المتحركة. تبقى الصور الثابتة المكتملة قابلة للتصدير كلٌّ على حدة.",
|
||||
"render.radial_assign": "تعيين مظهر",
|
||||
@@ -3763,7 +3763,7 @@
|
||||
"render.result_navigation": "الإزاحة والتكبير يتبعان تفضيلات التنقل. Space مع السحب يُزيح العرض.",
|
||||
"render.result_open": "فتح الصورة المكتملة في اللوحة الرئيسية.",
|
||||
"render.result_preview": "المعاينة الحية",
|
||||
"render.result_provisional": "صورة CPU مؤقتة",
|
||||
"render.result_provisional": "صورة مؤقتة",
|
||||
"render.result_provisional_help": "نقطة التحقق هذه غير قابلة للتصدير. يحتفظ التصدير بآخر نتيجة مكتملة؛ والإلغاء يعود إلى تلك النتيجة.",
|
||||
"render.result_reduced_display": "قُلّصت معاينة العرض إلى حد الجهاز أو الذاكرة المؤقتة لمنطقة العرض. يحتفظ PNG المحفوظ والتصدير بالدقة الكاملة؛ والفحص الأصلي بنسبة 100% غير متاح لهذا العرض المقلّص.",
|
||||
"render.result_retained": "تُعرض آخر صورة مكتملة؛ ولم يستبدلها تصيير أحدث.",
|
||||
@@ -3927,7 +3927,7 @@
|
||||
"render.stage_panels.prepare_plan": "تجهيز خطة الإنشاء",
|
||||
"render.stage_panels.projects": "ما يُسقط",
|
||||
"render.stage_panels.quality_ack": "أقرّ بالميزانية الثابتة وما تُغفله",
|
||||
"render.stage_panels.quality_omissions": "التوقف التكيّفي وأهداف الخطأ، وعمق نفاذ منفصل، وقواعد الروليت الروسية، والإخراج الخطي الخام، ونسخة EXR الأصلية، وشهادة التقارب.",
|
||||
"render.stage_panels.quality_omissions": "لا تضبط هذه الميزانية أهداف الخطأ التكيفي أو حدود النفاذ المستقلة أو قواعد الروليت الروسي أو الاحتفاظ بالأصل الخام، ولا تضمن تقارب الصورة.",
|
||||
"render.stage_panels.reference": "مرجع",
|
||||
"render.stage_panels.references": "مراجع",
|
||||
"render.stage_panels.remove_optics": "إزالة البصريات",
|
||||
@@ -3971,7 +3971,7 @@
|
||||
"render.stage_panels.subtitle_environment": "بيئة HDR أصلية · المشهد بأكمله",
|
||||
"render.stage_panels.subtitle_maps": "خرائط مكانية مُعدّة · معاينة مؤقتة",
|
||||
"render.stage_panels.subtitle_physical": "خطة للقراءة فقط · لا تُنشأ مادة ولا تُعيَّن",
|
||||
"render.stage_panels.subtitle_quality": "ميزانية GPU ثابتة · تُغفل المتطلبات المستقبلية",
|
||||
"render.stage_panels.subtitle_quality": "ميزانية عينات GPU · الحقول المدرجة فقط",
|
||||
"render.stage_panels.subtitle_rig": "طقم إضاءة مُعدّ · باعثات وبطاقات سلبية",
|
||||
"render.stage_panels.subtitle_saved_maps": "خرائط مُعدّة · محفوظة مع هذه اللقطة",
|
||||
"render.stage_panels.subtitle_stage": "أرضية ومنصة فيزيائيتان · ملائمتان للمنتج",
|
||||
@@ -3996,7 +3996,7 @@
|
||||
"render.studio_area_help": "حتى أربعة مستطيلات أحادية الجانب. نصفا الضلعين المتعامدين U وV يحددان الحجم؛ وحاصل U × V يواجه الجهة المضاءة. الإحداثيات بمليمترات فضاء العالم.",
|
||||
"render.studio_center": "المركز X, Y, Z (mm)",
|
||||
"render.studio_denoise": "تقليل التشويش في الصورة النهائية",
|
||||
"render.studio_denoise_help": "مرشح نهائي يراعي الحواف؛ وقد تلين التفاصيل الدقيقة. الزجاج والأسطح المرآوية الحادة لا يمر عليها المرشح.",
|
||||
"render.studio_denoise_help": "تستخدم الصور التدريجية والنهائية مرشحًا يحافظ على الحواف؛ وقد تصبح التفاصيل الدقيقة أنعم. لا يُطبّق المرشح على الزجاج والأسطح ذات الانعكاس الحاد.",
|
||||
"render.studio_direction": "الاتجاه نحو الشمس (X, Y, Z)",
|
||||
"render.studio_environment": "البيئة",
|
||||
"render.studio_environment_bounds": "يجب أن يكون دوران البيئة قيمة محدودة ضمن ±1,000,000 راديان والشدة بين 0 و100.",
|
||||
|
||||
+13
-13
@@ -2751,9 +2751,9 @@
|
||||
"render.adaptive_absolute": "Absolute RGB-Fehleruntergrenze",
|
||||
"render.adaptive_coverage": "Zielwert für den Abdeckungsfehler",
|
||||
"render.adaptive_enable": "Samples mithilfe unabhängiger Pilot-Samples zuteilen",
|
||||
"render.adaptive_help": "Nur GPU. Unabhängige Pilot-Samples werden verworfen. Anschließend werden pro Pixel 16 oder mehr Produktions-Samples zugeteilt, bis zu den Samples pro Pixel. Die Zuteilung richtet sich nach der RGB- und Abdeckungsvarianz; seltene Pfade können übersehen werden, und Pixel, die ihr Limit erreicht haben, können verrauscht bleiben. Der Abschlussbericht zählt den tatsächlichen Aufwand. Keine zertifizierte Garantie für ein bestimmtes Rauschniveau oder eine bestimmte Renderzeit.",
|
||||
"render.adaptive_help": "Unabhängige Pilotsamples werden verworfen und bestimmen dann mindestens 16 Produktionssamples pro Pixel bis zum Wert Samples pro Pixel. CPU-Ressourcengrenzen umfassen beide Phasen. RGB- und Abdeckungsvarianz steuern die Verteilung; Pixel am Limit können verrauscht bleiben. Der Abschlussbericht zählt die tatsächlichen Samples.",
|
||||
"render.adaptive_minimum": "Minimale Produktions-Samples",
|
||||
"render.adaptive_pending": "Die adaptive Zuteilung mit unabhängigen GPU-Pilot-Samples und bedingte Fresnel-Schichten sind verfügbar. Zertifizierte Konvergenz, Garantien für seltene Ereignisse und adaptiver Lichttransport auf der CPU bleiben nicht verfügbar.",
|
||||
"render.adaptive_pending": "Adaptive Verteilung mit unabhängigen Pilotsamples ist für CPU und GPU verfügbar. Bedingte Fresnel-Schichten erfordern die GPU. Die statistische Verteilung bestätigt keine Konvergenz.",
|
||||
"render.adaptive_pilots": "Pilot-Samples pro Pixel",
|
||||
"render.adaptive_relative": "Zielwert für den relativen RGB-Fehler",
|
||||
"render.add_ground": "Add fitted ground to setup",
|
||||
@@ -2814,7 +2814,7 @@
|
||||
"render.appearance_editor.replace": "Gespeicherte Optik dieses Körpers ersetzen",
|
||||
"render.appearance_editor.revalidate": "Diesen Körper erneut prüfen",
|
||||
"render.appearance_editor.save": "Diese Optik speichern",
|
||||
"render.appearance_editor.save_reason": "Zum Speichern wird physikalisches Glas auf einem aktuellen GPU-Körper benötigt. Klarlack und lokale Bildtexturen bleiben vorübergehend.",
|
||||
"render.appearance_editor.save_reason": "Zum Speichern Glas an einem aktuellen CPU- oder GPU-Körper aktivieren. Für Klarlack „Klarlack in Aufnahme speichern“ verwenden. Lokale Bilddateien bleiben temporär.",
|
||||
"render.appearance_editor.saved": "Mit dieser Aufnahme gespeichert",
|
||||
"render.appearance_editor.saved_state": "Diese Optik ist mit dieser Aufnahme gespeichert.",
|
||||
"render.appearance_editor.scatter_reason": "Innere Streuung benötigt einen geschlossenen Körper.",
|
||||
@@ -2910,7 +2910,7 @@
|
||||
"render.children.copy_request": "Anfrage-JSON kopieren",
|
||||
"render.children.coverage_error": "Abdeckungsfehler",
|
||||
"render.children.cpu": "CPU-Raytracing",
|
||||
"render.children.denoise": "Rauschen im fertigen Bild reduzieren",
|
||||
"render.children.denoise": "Bildrauschen reduzieren",
|
||||
"render.children.depth": "Pfadtiefe",
|
||||
"render.children.distortion": "Verzeichnung",
|
||||
"render.children.edge_samples": "Kanten-Samples",
|
||||
@@ -3042,7 +3042,7 @@
|
||||
"render.cpu_point_help": "Bis zu acht Punktlichter, zusätzlich zur gewählten Richtungsbeleuchtung. Abstände in mm; der Mindestabstand darf den Referenzabstand nicht überschreiten.",
|
||||
"render.cpu_primary_samples": "Kanten-Samples pro Pixel",
|
||||
"render.cpu_progressive": "CPU progressiv",
|
||||
"render.cpu_progressive_help": "Den vorhandenen CPU-Integrator in Durchgängen mit je einem Primär-Sample akkumulieren. Ausgewählte Reflexionen erfordern ein Sample; Flächenlichter, Dielektrika und Anisotropie erfordern „Einzelbild“.",
|
||||
"render.cpu_progressive_help": "Neue CPU-Kamerasamples sammeln und jeden vollständigen Durchgang zeigen. Ausgewählte Reflexionen verwenden einen Versuch pro Kamerasample; zertifiziertes Glas und planare Anisotropie behalten ihre bisherigen Grenzen. Adaptive Pilot- und Produktionssamples teilen sich das CPU-Profilbudget.",
|
||||
"render.cpu_progressive_limits": "Höchstens 1.048.576 Pixel und 16.777.216 Auswertungen von Pixel × Sample. Das Ändern der Samples passt die Bildgröße nie automatisch an.",
|
||||
"render.cpu_progressive_seed": "Startwert für das CPU-Sampling",
|
||||
"render.cpu_progressive_spp": "Samples pro Pixel",
|
||||
@@ -3218,7 +3218,7 @@
|
||||
"render.export_display": "Export: Anzeige-PNG",
|
||||
"render.export_image_label": "Bild exportieren",
|
||||
"render.export_linear": "Export: lineares Roh-EXR",
|
||||
"render.exr_pending": "Die GPU kann rohe lineare EXR-Dateien behalten. Kompression, mehrschichtige Bilder und zusätzliche Renderpässe bleiben nicht verfügbar.",
|
||||
"render.exr_pending": "CPU-Raytracing und GPU-Pathtracing können rohe lineare EXR-Dateien behalten. Kompression, mehrschichtige Bilder und zusätzliche Renderpässe bleiben nicht verfügbar.",
|
||||
"render.film_grain": "Fotografisches Korn",
|
||||
"render.fit": "Produkt einpassen",
|
||||
"render.fixed_camera": "Kamera",
|
||||
@@ -3581,7 +3581,7 @@
|
||||
"render.method_cpu": "CPU-Raytracing",
|
||||
"render.method_gpu": "GPU-Pathtracing",
|
||||
"render.method_limit_raster": "Die Rasterung verwendet die feste undurchsichtige Studiovorschau. Wählen Sie ausdrücklich eine Raytracing-Methode für die unterstützten optischen Einstellungen.",
|
||||
"render.method_raster": "CPU-Rasterung",
|
||||
"render.method_raster": "CPU-Rasterung (schematisch)",
|
||||
"render.navigation_help": "Die Kamera bewegt sich wie im Konstruieren (Einstellungen › Navigation). Setup speichern übernimmt die aktuelle Kamera.",
|
||||
"render.nested_enable": "Zertifizierte verschachtelte Dielektrika",
|
||||
"render.nested_help": "Bis zu 16 umschließende Schichten; jedes übergeordnete Element muss konvex und jedes untergeordnete strikt darin enthalten sein. Überschneidungen, sich berührende Flächen, dünne Schichten und die Transmission gerader Schattenstrahlen werden abgelehnt. Der Zustand „Kamera im Inneren“ folgt der zertifizierten Hierarchie. Erzeugt keine Flüssigkeitsoberfläche und verschmilzt keine sich berührenden CAD-Grenzflächen.",
|
||||
@@ -3609,7 +3609,7 @@
|
||||
"render.output_exr_unavailable": "EXR-Ausgabeeinstellungen sind hier nicht verfügbar. Linearer EXR-Export erfordert ein fertiges GPU-Bild mit gespeichertem linearem Original.",
|
||||
"render.output_fit": "Einpassen",
|
||||
"render.output_format": "Format",
|
||||
"render.output_format_status": "PNG enthält das exakte fertige Anzeigebild. Optionales GPU-EXR bewahrt rohe lineare FLOAT32-Strahldichte vor Filtern. Export ersetzt nie durch Vorschaucache oder anderen Auftrag.",
|
||||
"render.output_format_status": "PNG enthält das exakte fertige Anzeigebild. Optionales CPU/GPU-EXR bewahrt rohe lineare FLOAT32-Strahldichte vor Anzeigefiltern. Der Export verwendet nie den Vorschaucache oder einen anderen Auftrag.",
|
||||
"render.output_grading": "Farbkorrektur",
|
||||
"render.output_image": "Bild",
|
||||
"render.output_largest": "{width} × {height} max.",
|
||||
@@ -3694,7 +3694,7 @@
|
||||
"render.quality_custom": "Benutzerdefiniert",
|
||||
"render.quality_draft": "Entwurf",
|
||||
"render.quality_high": "Hoch",
|
||||
"render.quality_projection_help": "Q-Vorgaben enthalten künftige Anforderungen. Diese Aktion projiziert nur ein unterstütztes PNG-Budget mit festen Samples und lässt adaptives Stoppen, rohe lineare Ausgabe und unabhängige Transmissionslimits ausdrücklich aus.",
|
||||
"render.quality_projection_help": "Diese Prüfung ändert nur Auflösung, Abtastbudget, Pfadtiefe, Startwert und Rauschreduzierung. Andere Einstellungen bleiben erhalten; das Budget garantiert keine Konvergenz.",
|
||||
"render.quality_review": "Sampling-Budget prüfen",
|
||||
"render.queue_pending": "In dieser Oberfläche gibt es keine automatische Produktionswarteschlange und keinen Animations-Renderer. Fertige Einzelbilder bleiben separat exportierbar.",
|
||||
"render.radial_assign": "Erscheinungsbild zuweisen",
|
||||
@@ -3725,7 +3725,7 @@
|
||||
"render.result_navigation": "Verschieben und Zoomen folgen den Navigationseinstellungen. Leertaste + Ziehen verschiebt.",
|
||||
"render.result_open": "Das fertige Bild in der Hauptansicht öffnen.",
|
||||
"render.result_preview": "Live-Vorschau",
|
||||
"render.result_provisional": "Vorläufiges CPU-Bild",
|
||||
"render.result_provisional": "Vorläufiges Bild",
|
||||
"render.result_provisional_help": "Dieser Zwischenstand ist nicht exportierbar. Der Export behält das letzte fertige Ergebnis; ein Abbruch kehrt zu diesem Ergebnis zurück.",
|
||||
"render.result_reduced_display": "Anzeigevorschau auf die Geräte-/Cachegrenze reduziert. PNG und Export behalten die volle Auflösung; native 100%-Ansicht ist hier nicht verfügbar.",
|
||||
"render.result_retained": "Das zuletzt fertiggestellte Bild wird angezeigt; ein neueres Rendering hat es noch nicht ersetzt.",
|
||||
@@ -3889,7 +3889,7 @@
|
||||
"render.stage_panels.prepare_plan": "Erstellungsplan vorbereiten",
|
||||
"render.stage_panels.projects": "Projiziert",
|
||||
"render.stage_panels.quality_ack": "Ich bestätige das feste Budget und seine Auslassungen",
|
||||
"render.stage_panels.quality_omissions": "Adaptives Stoppen und Fehlerziele, separate Transmissionstiefe, Russian-Roulette-Regeln, lineare Rohausgabe, EXR-Master und Konvergenznachweis.",
|
||||
"render.stage_panels.quality_omissions": "Dieses Budget legt keine adaptiven Fehlerziele, eigenen Transmissionsgrenzen, Russian-Roulette-Regeln oder Speicherung des linearen Masters fest und garantiert keine Konvergenz.",
|
||||
"render.stage_panels.reference": "Referenz",
|
||||
"render.stage_panels.references": "Referenzen",
|
||||
"render.stage_panels.remove_optics": "Optik entfernen",
|
||||
@@ -3933,7 +3933,7 @@
|
||||
"render.stage_panels.subtitle_environment": "Originale HDR-Umgebung · gesamte Szene",
|
||||
"render.stage_panels.subtitle_maps": "Erstellte räumliche Texturen · temporäre Vorschau",
|
||||
"render.stage_panels.subtitle_physical": "Schreibgeschützter Plan · kein Material erstellt oder zugewiesen",
|
||||
"render.stage_panels.subtitle_quality": "Festes GPU-Budget · zukünftige Anforderungen ausgelassen",
|
||||
"render.stage_panels.subtitle_quality": "GPU-Abtastbudget · nur aufgeführte Einstellungen",
|
||||
"render.stage_panels.subtitle_rig": "Erstellter Lichtaufbau · Lichtquellen und passive Flächen",
|
||||
"render.stage_panels.subtitle_saved_maps": "Erstellte Texturen · mit dieser Aufnahme gespeichert",
|
||||
"render.stage_panels.subtitle_stage": "Physischer Boden und Bühne · an das Produkt angepasst",
|
||||
@@ -3958,7 +3958,7 @@
|
||||
"render.studio_area_help": "Bis zu vier einseitige Rechtecke. Orthogonale Halbkanten U und V bestimmen die Größe; U × V zeigt zur beleuchteten Seite. Die Koordinaten sind Weltmillimeter.",
|
||||
"render.studio_center": "Mittelpunkt X, Y, Z (mm)",
|
||||
"render.studio_denoise": "Rauschen im fertigen Bild reduzieren",
|
||||
"render.studio_denoise_help": "Abschließender kantenerhaltender Filter; feine Details können weicher werden. Glas und scharfe spiegelnde Oberflächen umgehen den Filter.",
|
||||
"render.studio_denoise_help": "Kantenerhaltender Filter für progressive und fertige Bilder; feine Details können weicher werden. Glas und scharfe spiegelnde Oberflächen umgehen den Filter.",
|
||||
"render.studio_direction": "Richtung zur Sonne (X, Y, Z)",
|
||||
"render.studio_environment": "Umgebung",
|
||||
"render.studio_environment_bounds": "Die Umgebungsdrehung muss endlich und innerhalb von ±1.000.000 Radiant liegen, die Intensität zwischen 0 und 100.",
|
||||
|
||||
+13
-13
@@ -2789,9 +2789,9 @@
|
||||
"render.adaptive_absolute": "Absolute RGB error floor",
|
||||
"render.adaptive_coverage": "Coverage error target",
|
||||
"render.adaptive_enable": "Allocate samples with independent pilots",
|
||||
"render.adaptive_help": "GPU only. Independent pilot samples are discarded, then allocate 16 or more production samples per pixel up to Samples per pixel. RGB and coverage variance guide allocation; rare paths may be missed and capped pixels may remain noisy. The final report counts actual work. No certified noise or time guarantee.",
|
||||
"render.adaptive_help": "Independent pilot samples are discarded, then choose 16 or more production samples per pixel up to Samples per pixel. CPU resource limits include both stages. RGB and coverage variance guide allocation; capped pixels can remain noisy. The final report counts actual samples.",
|
||||
"render.adaptive_minimum": "Minimum production samples",
|
||||
"render.adaptive_pending": "GPU independent-pilot adaptive allocation and conditional Fresnel strata are available. Certified convergence, rare-event guarantees and CPU adaptive transport remain unavailable.",
|
||||
"render.adaptive_pending": "Independent-pilot adaptive allocation is available for CPU and GPU. Conditional Fresnel strata require GPU. Statistical allocation does not certify convergence.",
|
||||
"render.adaptive_pilots": "Pilot samples per pixel",
|
||||
"render.adaptive_relative": "Relative RGB error target",
|
||||
"render.add_ground": "Add fitted ground to setup",
|
||||
@@ -2852,7 +2852,7 @@
|
||||
"render.appearance_editor.replace": "Replace this body's saved optics",
|
||||
"render.appearance_editor.revalidate": "Revalidate this body",
|
||||
"render.appearance_editor.save": "Save these optics",
|
||||
"render.appearance_editor.save_reason": "Saving these optics needs physical glass on a current GPU body. Coat and local image maps remain temporary.",
|
||||
"render.appearance_editor.save_reason": "To save Glass, enable it on a current CPU or GPU body. Use Save coat to shot for clear coat. Local image files remain temporary.",
|
||||
"render.appearance_editor.saved": "Saved with this shot",
|
||||
"render.appearance_editor.saved_state": "These optics are saved with this shot.",
|
||||
"render.appearance_editor.scatter_reason": "Interior scattering needs a closed solid boundary.",
|
||||
@@ -2948,7 +2948,7 @@
|
||||
"render.children.copy_request": "Copy request JSON",
|
||||
"render.children.coverage_error": "Coverage error",
|
||||
"render.children.cpu": "CPU ray tracing",
|
||||
"render.children.denoise": "Reduce noise in the finished image",
|
||||
"render.children.denoise": "Reduce image noise",
|
||||
"render.children.depth": "Path depth",
|
||||
"render.children.distortion": "Distortion",
|
||||
"render.children.edge_samples": "Edge samples",
|
||||
@@ -3080,7 +3080,7 @@
|
||||
"render.cpu_point_help": "Up to eight point lights, added to the selected directional lighting. Distances are in mm; the minimum must not exceed the reference distance.",
|
||||
"render.cpu_primary_samples": "Edge samples per pixel",
|
||||
"render.cpu_progressive": "CPU progressive",
|
||||
"render.cpu_progressive_help": "Accumulate the existing CPU integrator in passes of one primary sample. Selected reflections require one sample; area lights, dielectrics and anisotropy require Single image.",
|
||||
"render.cpu_progressive_help": "Accumulate fresh CPU camera samples and show every complete pass. Selected reflections use one attempt per camera sample; certified glass and planar anisotropy keep their existing limits. Adaptive pilots and production share the CPU profile budget.",
|
||||
"render.cpu_progressive_limits": "At most 1,048,576 pixels and 16,777,216 pixel × sample evaluations. Changing samples never resizes the image automatically.",
|
||||
"render.cpu_progressive_seed": "CPU sampling seed",
|
||||
"render.cpu_progressive_spp": "samples per pixel",
|
||||
@@ -3256,7 +3256,7 @@
|
||||
"render.export_display": "Export: display PNG",
|
||||
"render.export_image_label": "Export Image",
|
||||
"render.export_linear": "Export: raw linear EXR",
|
||||
"render.exr_pending": "GPU can retain raw linear EXR. Compression, multilayer images and auxiliary render passes remain unavailable.",
|
||||
"render.exr_pending": "CPU ray tracing and GPU path tracing can retain raw linear EXR. Compression, multilayer images and auxiliary render passes remain unavailable.",
|
||||
"render.film_grain": "Photographic grain",
|
||||
"render.fit": "Fit Product",
|
||||
"render.fixed_camera": "Camera",
|
||||
@@ -3619,7 +3619,7 @@
|
||||
"render.method_cpu": "CPU ray tracing",
|
||||
"render.method_gpu": "GPU path tracing",
|
||||
"render.method_limit_raster": "Raster uses the fixed opaque studio preview. Select an explicit raytracing method for supported optical controls.",
|
||||
"render.method_raster": "CPU raster",
|
||||
"render.method_raster": "CPU raster (schematic)",
|
||||
"render.navigation_help": "The camera moves as in Design (Preferences › Navigation). Save Setup records the current camera.",
|
||||
"render.nested_enable": "Certified nested dielectrics",
|
||||
"render.nested_help": "Up to 16 containing layers; every parent must be convex and every child strictly inside it. Intersections, touching surfaces, thin sheets and straight-shadow transmission refuse. Camera-inside state follows the certified hierarchy. Does not create a liquid surface or merge touching CAD boundaries.",
|
||||
@@ -3647,7 +3647,7 @@
|
||||
"render.output_exr_unavailable": "EXR output setup is unavailable here. Linear EXR export requires a completed GPU image with a retained linear master.",
|
||||
"render.output_fit": "Fit",
|
||||
"render.output_format": "Format",
|
||||
"render.output_format_status": "PNG is the exact completed display image. Optional GPU EXR retains raw scene-linear FLOAT32 radiance before display filters. Export never substitutes the preview cache or a different job.",
|
||||
"render.output_format_status": "PNG is the exact completed display image. Optional CPU/GPU EXR retains raw scene-linear FLOAT32 radiance before display filters. Export never substitutes the preview cache or a different job.",
|
||||
"render.output_grading": "Grading",
|
||||
"render.output_image": "Image",
|
||||
"render.output_largest": "{width} × {height} max",
|
||||
@@ -3732,7 +3732,7 @@
|
||||
"render.quality_custom": "Custom",
|
||||
"render.quality_draft": "Draft",
|
||||
"render.quality_high": "High",
|
||||
"render.quality_projection_help": "Q presets include future requirements. This action projects only a supported fixed-sample PNG budget and explicitly omits adaptive stopping, raw linear output and independent transmission limits.",
|
||||
"render.quality_projection_help": "This review changes only resolution, sample budget, path depth, seed and noise reduction. Other settings are kept; the budget does not certify convergence.",
|
||||
"render.quality_review": "Review sampling budget",
|
||||
"render.queue_pending": "There is no automatic production queue or animation renderer in this UI. Completed still images remain separately exportable.",
|
||||
"render.radial_assign": "Assign Appearance",
|
||||
@@ -3763,7 +3763,7 @@
|
||||
"render.result_navigation": "Pan and zoom use Navigation preferences. Space + drag pans.",
|
||||
"render.result_open": "Open the completed image in the main canvas.",
|
||||
"render.result_preview": "Live Preview",
|
||||
"render.result_provisional": "Provisional CPU image",
|
||||
"render.result_provisional": "Provisional image",
|
||||
"render.result_provisional_help": "This checkpoint is not exportable. Export keeps the last completed result; cancellation returns to that result.",
|
||||
"render.result_reduced_display": "Display preview reduced to the viewport device/cache limit. The retained PNG and export keep full resolution; native 100% inspection is unavailable for this reduced view.",
|
||||
"render.result_retained": "Showing the last completed image; a newer render has not replaced it.",
|
||||
@@ -3927,7 +3927,7 @@
|
||||
"render.stage_panels.prepare_plan": "Prepare creation plan",
|
||||
"render.stage_panels.projects": "Projects",
|
||||
"render.stage_panels.quality_ack": "I acknowledge the fixed budget and its omissions",
|
||||
"render.stage_panels.quality_omissions": "Adaptive stopping and error targets, separate transmission depth, Russian roulette rules, raw linear output, EXR master and convergence certification.",
|
||||
"render.stage_panels.quality_omissions": "This budget does not set adaptive error targets, independent transmission limits, Russian roulette rules or raw-master retention, and does not guarantee convergence.",
|
||||
"render.stage_panels.reference": "Reference",
|
||||
"render.stage_panels.references": "References",
|
||||
"render.stage_panels.remove_optics": "Remove optics",
|
||||
@@ -3971,7 +3971,7 @@
|
||||
"render.stage_panels.subtitle_environment": "Original HDR environment · whole scene",
|
||||
"render.stage_panels.subtitle_maps": "Authored spatial maps · temporary preview",
|
||||
"render.stage_panels.subtitle_physical": "Read-only plan · no material created or assigned",
|
||||
"render.stage_panels.subtitle_quality": "Fixed GPU budget · future requirements omitted",
|
||||
"render.stage_panels.subtitle_quality": "GPU sampling budget · listed fields only",
|
||||
"render.stage_panels.subtitle_rig": "Authored light rig · emitters and passive cards",
|
||||
"render.stage_panels.subtitle_saved_maps": "Authored maps · saved with this shot",
|
||||
"render.stage_panels.subtitle_stage": "Physical ground and stage · fitted to the product",
|
||||
@@ -3996,7 +3996,7 @@
|
||||
"render.studio_area_help": "Up to four one-sided rectangles. Orthogonal half-edges U and V define size; U × V faces the lit side. Coordinates are world-space millimeters.",
|
||||
"render.studio_center": "Center X, Y, Z (mm)",
|
||||
"render.studio_denoise": "Reduce noise in the finished image",
|
||||
"render.studio_denoise_help": "Final edge-aware filter; fine detail can soften. Glass and sharp specular surfaces bypass the filter.",
|
||||
"render.studio_denoise_help": "Progressive and final images use an edge-aware filter; fine detail may soften. Glass and sharp specular surfaces bypass it.",
|
||||
"render.studio_direction": "Direction toward sun (X, Y, Z)",
|
||||
"render.studio_environment": "Environment",
|
||||
"render.studio_environment_bounds": "Environment rotation must be finite within ±1,000,000 radians and intensity between 0 and 100.",
|
||||
|
||||
+13
-13
@@ -2789,9 +2789,9 @@
|
||||
"render.adaptive_absolute": "רצפת שגיאת RGB מוחלטת",
|
||||
"render.adaptive_coverage": "יעד שגיאת כיסוי",
|
||||
"render.adaptive_enable": "הקצאת דגימות באמצעות דגימות גישוש עצמאיות",
|
||||
"render.adaptive_help": "GPU בלבד. דגימות הגישוש העצמאיות מושלכות, ואז מוקצות 16 דגימות הפקה או יותר לכל פיקסל עד לערך „דגימות לכל פיקסל”. השונות של RGB ושל הכיסוי מכוונת את ההקצאה; מסלולים נדירים עלולים להיות מוחמצים ופיקסלים שהגיעו לתקרה עשויים להישאר רועשים. הדוח הסופי סופר את העבודה בפועל. אין ערובה מאושרת לרמת הרעש או לזמן.",
|
||||
"render.adaptive_help": "דגימות החלוץ העצמאיות נזרקות, ואז נקבעות לכל פיקסל לפחות 16 דגימות הפקה, עד לערך דגימות לפיקסל. מגבלות משאבי CPU כוללות את שני השלבים. השונות ב-RGB ובכיסוי מנחה את ההקצאה; פיקסלים שהגיעו לתקרה עשויים להישאר רועשים. הדוח הסופי מונה את הדגימות בפועל.",
|
||||
"render.adaptive_minimum": "מינימום דגימות הפקה",
|
||||
"render.adaptive_pending": "הקצאה אדפטיבית ב־GPU עם דגימות גישוש עצמאיות ושכבות פרנל מותנות זמינות. התכנסות מאושרת, ערובה לאירועים נדירים והעברה אדפטיבית ב־CPU עדיין אינן זמינות.",
|
||||
"render.adaptive_pending": "הקצאה מסתגלת עם דגימות חלוץ עצמאיות זמינה ב-CPU וב-GPU. שכבות פרנל מותנות דורשות GPU. ההקצאה הסטטיסטית אינה מבטיחה התכנסות.",
|
||||
"render.adaptive_pilots": "דגימות גישוש לכל פיקסל",
|
||||
"render.adaptive_relative": "יעד שגיאת RGB יחסית",
|
||||
"render.add_ground": "הוספת קרקע מותאמת להגדרה",
|
||||
@@ -2852,7 +2852,7 @@
|
||||
"render.appearance_editor.replace": "החלף את האופטיקה השמורה של גוף זה",
|
||||
"render.appearance_editor.revalidate": "אמת מחדש גוף זה",
|
||||
"render.appearance_editor.save": "שמור אופטיקה זו",
|
||||
"render.appearance_editor.save_reason": "שמירת האופטיקה הזו דורשת זכוכית פיזיקלית בגוף GPU עדכני. הציפוי ומפות התמונה המקומיות נשארים זמניים.",
|
||||
"render.appearance_editor.save_reason": "כדי לשמור זכוכית, יש להפעיל אותה בגוף CPU או GPU עדכני. לציפוי שקוף יש להשתמש בשמירת הציפוי בתצלום. קובצי תמונה מקומיים נשארים זמניים.",
|
||||
"render.appearance_editor.saved": "נשמר עם הצילום הזה",
|
||||
"render.appearance_editor.saved_state": "האופטיקה הזו נשמרת עם הצילום הזה.",
|
||||
"render.appearance_editor.scatter_reason": "פיזור פנימי דורש גבול של גוף מלא סגור.",
|
||||
@@ -2948,7 +2948,7 @@
|
||||
"render.children.copy_request": "העתק JSON של הבקשה",
|
||||
"render.children.coverage_error": "שגיאת כיסוי",
|
||||
"render.children.cpu": "מעקב קרניים במעבד",
|
||||
"render.children.denoise": "הפחת רעש בתמונה המוגמרת",
|
||||
"render.children.denoise": "הפחתת רעש בתמונה",
|
||||
"render.children.depth": "עומק נתיב",
|
||||
"render.children.distortion": "עיוות",
|
||||
"render.children.edge_samples": "דגימות קצה",
|
||||
@@ -3080,7 +3080,7 @@
|
||||
"render.cpu_point_help": "עד שמונה אורות נקודתיים, המתווספים לתאורה הכיוונית שנבחרה. המרחקים במילימטרים; המינימום אינו יכול לעלות על מרחק הייחוס.",
|
||||
"render.cpu_primary_samples": "דגימות קצה לכל פיקסל",
|
||||
"render.cpu_progressive": "CPU הדרגתי",
|
||||
"render.cpu_progressive_help": "צבירה של אינטגרטור ה־CPU הקיים במעברים של דגימה ראשית אחת. החזרות שנבחרו דורשות דגימה אחת; אורות שטח, דיאלקטריים ואנאיזוטרופיה דורשים תמונה בודדת.",
|
||||
"render.cpu_progressive_help": "נאספות דגימות מצלמת CPU חדשות ומוצג כל מעבר שהושלם. השתקפויות נבחרות משתמשות בניסיון אחד לכל דגימת מצלמה; זכוכית מאושרת ואנאיזוטרופיה מישורית שומרות על מגבלותיהן הקיימות. דגימות החלוץ וההפקה המסתגלות חולקות את תקציב פרופיל CPU.",
|
||||
"render.cpu_progressive_limits": "לכל היותר 1,048,576 פיקסלים ו־16,777,216 הערכות פיקסל × דגימה. שינוי הדגימות לעולם אינו משנה את גודל התמונה אוטומטית.",
|
||||
"render.cpu_progressive_seed": "זרע דגימה ב־CPU",
|
||||
"render.cpu_progressive_spp": "דגימות לכל פיקסל",
|
||||
@@ -3256,7 +3256,7 @@
|
||||
"render.export_display": "ייצוא: PNG לתצוגה",
|
||||
"render.export_image_label": "ייצוא תמונה",
|
||||
"render.export_linear": "ייצוא: EXR ליניארי גולמי",
|
||||
"render.exr_pending": "GPU יכול לשמור EXR ליניארי גולמי. דחיסה, תמונות רב־שכבתיות ומעברי הדמיה נלווים עדיין אינם זמינים.",
|
||||
"render.exr_pending": "עקיבת קרניים ב־CPU ועקיבת נתיבים ב־GPU יכולות לשמור EXR ליניארי גולמי. דחיסה, תמונות רב־שכבתיות ומעברי הדמיה נלווים עדיין אינם זמינים.",
|
||||
"render.film_grain": "גרעיניות צילומית",
|
||||
"render.fit": "התאמת המוצר לתצוגה",
|
||||
"render.fixed_camera": "מצלמה",
|
||||
@@ -3619,7 +3619,7 @@
|
||||
"render.method_cpu": "מעקב קרניים ב־CPU",
|
||||
"render.method_gpu": "מעקב מסלולים ב־GPU",
|
||||
"render.method_limit_raster": "רסטר משתמש בתצוגה המקדימה הקבועה של הסטודיו האטום. לפקדים האופטיים הנתמכים יש לבחור שיטת מעקב קרניים מפורשת.",
|
||||
"render.method_raster": "רסטר ב־CPU",
|
||||
"render.method_raster": "רסטר ב־CPU (סכמטי)",
|
||||
"render.navigation_help": "המצלמה נעה כמו בתכן (העדפות › ניווט). שמירת ההגדרה מתעדת את המצלמה הנוכחית.",
|
||||
"render.nested_enable": "דיאלקטריים מקוננים מאושרים",
|
||||
"render.nested_help": "עד 16 שכבות מכילות; כל הורה חייב להיות קמור וכל צאצא חייב להיות בתוכו לגמרי. חיתוכים, משטחים נוגעים, יריעות דקות והעברת צל ישרה נדחים. מצב מצלמה בפנים עוקב אחר ההיררכיה המאושרת. אינו יוצר משטח נוזל ואינו ממזג גבולות CAD נוגעים.",
|
||||
@@ -3647,7 +3647,7 @@
|
||||
"render.output_exr_unavailable": "הגדרת פלט EXR אינה זמינה כאן. ייצוא EXR לינארי דורש תמונת GPU שהושלמה עם מאסטר לינארי שנשמר.",
|
||||
"render.output_fit": "התאם",
|
||||
"render.output_format": "פורמט",
|
||||
"render.output_format_status": "PNG הוא תמונת התצוגה הגמורה המדויקת. EXR אופציונלי ב־GPU שומר קרינה גולמית ליניארית של הסצנה ב־FLOAT32 לפני מסנני התצוגה. הייצוא לעולם אינו מחליף אותה במטמון התצוגה המקדימה או במשימה אחרת.",
|
||||
"render.output_format_status": "PNG הוא תמונת התצוגה הגמורה המדויקת. EXR אופציונלי ב־CPU או ב־GPU שומר קרינה גולמית ליניארית של הסצנה ב־FLOAT32 לפני מסנני התצוגה. הייצוא לעולם אינו מחליף אותה במטמון התצוגה המקדימה או במשימה אחרת.",
|
||||
"render.output_grading": "גריידינג",
|
||||
"render.output_image": "תמונה",
|
||||
"render.output_largest": "מקס׳ {width} × {height}",
|
||||
@@ -3732,7 +3732,7 @@
|
||||
"render.quality_custom": "מותאם",
|
||||
"render.quality_draft": "טיוטה",
|
||||
"render.quality_high": "גבוהה",
|
||||
"render.quality_projection_help": "פרופילי Q מוכנים כוללים דרישות עתידיות. פעולה זו משליכה רק תקציב PNG נתמך בדגימות קבועות, ומשמיטה במפורש עצירה אדפטיבית, פלט ליניארי גולמי ומגבלות העברה עצמאיות.",
|
||||
"render.quality_projection_help": "בדיקה זו משנה רק רזולוציה, תקציב דגימות, עומק נתיב, זרע והפחתת רעש. ההגדרות האחרות נשמרות; התקציב אינו מעיד על התכנסות התמונה.",
|
||||
"render.quality_review": "סקירת תקציב הדגימה",
|
||||
"render.queue_pending": "בממשק זה אין תור הפקה אוטומטי ואין מנוע הדמיה לאנימציה. תמונות סטילס גמורות נשארות ניתנות לייצוא כל אחת בנפרד.",
|
||||
"render.radial_assign": "הקצה מראה",
|
||||
@@ -3763,7 +3763,7 @@
|
||||
"render.result_navigation": "הזזה וזום משתמשים בהעדפות הניווט. רווח + גרירה מזיזים.",
|
||||
"render.result_open": "פתיחת התמונה הגמורה בקנבס הראשי.",
|
||||
"render.result_preview": "תצוגה מקדימה חיה",
|
||||
"render.result_provisional": "תמונת CPU זמנית",
|
||||
"render.result_provisional": "תמונה זמנית",
|
||||
"render.result_provisional_help": "נקודת ביקורת זו אינה ניתנת לייצוא. הייצוא שומר את התוצאה הגמורה האחרונה; ביטול חוזר לתוצאה הזאת.",
|
||||
"render.result_reduced_display": "התצוגה המקדימה הוקטנה למגבלת המכשיר או המטמון של אזור התצוגה. ה־PNG השמור והייצוא שומרים על רזולוציה מלאה; בדיקה ברזולוציה המקורית (100%) אינה זמינה בתצוגה המוקטנת הזאת.",
|
||||
"render.result_retained": "מוצגת התמונה הגמורה האחרונה; הדמיה חדשה יותר לא החליפה אותה.",
|
||||
@@ -3927,7 +3927,7 @@
|
||||
"render.stage_panels.prepare_plan": "הכנת תוכנית יצירה",
|
||||
"render.stage_panels.projects": "מקרין",
|
||||
"render.stage_panels.quality_ack": "ידוע לי על התקציב הקבוע והפרטים המושמטים בו",
|
||||
"render.stage_panels.quality_omissions": "עצירה מסתגלת ויעדי שגיאה, עומק העברה נפרד, כללי רולטה רוסית, פלט ליניארי גולמי, קובץ מקור EXR ואישור התכנסות.",
|
||||
"render.stage_panels.quality_omissions": "תקציב זה אינו קובע יעדי שגיאה אדפטיביים, מגבלות מעבר אור נפרדות, כללי רולטה רוסית או שמירת מקור גולמי, ואינו מבטיח התכנסות של התמונה.",
|
||||
"render.stage_panels.reference": "ייחוס",
|
||||
"render.stage_panels.references": "ייחוסים",
|
||||
"render.stage_panels.remove_optics": "הסרת האופטיקה",
|
||||
@@ -3971,7 +3971,7 @@
|
||||
"render.stage_panels.subtitle_environment": "סביבת HDR מקורית · הסצנה כולה",
|
||||
"render.stage_panels.subtitle_maps": "מפות מרחביות מעוצבות · תצוגה מקדימה זמנית",
|
||||
"render.stage_panels.subtitle_physical": "תוכנית לקריאה בלבד · לא נוצר או משויך חומר",
|
||||
"render.stage_panels.subtitle_quality": "תקציב GPU קבוע · דרישות עתידיות מושמטות",
|
||||
"render.stage_panels.subtitle_quality": "תקציב דגימה ב־GPU · השדות המפורטים בלבד",
|
||||
"render.stage_panels.subtitle_rig": "מערך תאורה מעוצב · פולטי אור וכרטיסים סבילים",
|
||||
"render.stage_panels.subtitle_saved_maps": "מפות מעוצבות · נשמרות עם צילום זה",
|
||||
"render.stage_panels.subtitle_stage": "קרקע ובמה פיזיות · מותאמות למוצר",
|
||||
@@ -3996,7 +3996,7 @@
|
||||
"render.studio_area_help": "עד ארבעה מלבנים חד־צדדיים. חצאי הצלעות הניצבים U ו־V קובעים את הגודל; U × V פונה אל הצד המואר. הקואורדינטות במילימטרים של מרחב העולם.",
|
||||
"render.studio_center": "מרכז X, Y, Z (mm)",
|
||||
"render.studio_denoise": "הפחתת רעש בתמונה הסופית",
|
||||
"render.studio_denoise_help": "מסנן סופי שמתחשב בקצוות; פרטים עדינים עלולים להתרכך. זכוכית ומשטחים ספקולריים חדים עוקפים את המסנן.",
|
||||
"render.studio_denoise_help": "תמונות הדרגתיות וסופיות משתמשות במסנן שומר קצוות; פרטים עדינים עלולים להתרכך. זכוכית ומשטחים בעלי החזר חד אינם עוברים סינון.",
|
||||
"render.studio_direction": "כיוון אל השמש (X, Y, Z)",
|
||||
"render.studio_environment": "סביבה",
|
||||
"render.studio_environment_bounds": "סיבוב הסביבה חייב להיות סופי בטווח ±1,000,000 רדיאנים והעוצמה בין 0 ל־100.",
|
||||
|
||||
+13
-13
@@ -2789,9 +2789,9 @@
|
||||
"render.adaptive_absolute": "RGB 絶対誤差の下限",
|
||||
"render.adaptive_coverage": "被覆率の誤差目標",
|
||||
"render.adaptive_enable": "独立した予備サンプルで配分",
|
||||
"render.adaptive_help": "GPU 専用。独立した予備サンプルは破棄され、各ピクセルに最小16から設定上限までの本サンプルを配分します。RGB と被覆率の分散を目安にしますが、まれな経路を見逃したり上限でノイズが残ったりします。最終レポートは実際の処理量を示します。ノイズや時間を保証しません。",
|
||||
"render.adaptive_help": "独立したパイロットサンプルは破棄され、ピクセルごとに16以上、「ピクセルあたりのサンプル数」以下の本番サンプル数を決めます。CPUのリソース上限には両段階が含まれます。RGBとカバレッジの分散から配分し、上限に達したピクセルにはノイズが残ることがあります。最終レポートには実際のサンプル数を記録します。",
|
||||
"render.adaptive_minimum": "最小本サンプル数",
|
||||
"render.adaptive_pending": "GPU の独立予備サンプルによる適応配分と条件付きフレネル層化が利用できます。収束の認証、まれな事象の保証、CPU 適応輸送は未対応です。",
|
||||
"render.adaptive_pending": "独立したパイロットによる適応配分はCPUとGPUで利用できます。条件付きフレネル層にはGPUが必要です。統計的な配分は収束を保証しません。",
|
||||
"render.adaptive_pilots": "ピクセルごとの予備サンプル",
|
||||
"render.adaptive_relative": "RGB 相対誤差の目標",
|
||||
"render.add_ground": "フィットした床をセットアップに追加",
|
||||
@@ -2852,7 +2852,7 @@
|
||||
"render.appearance_editor.replace": "このボディの保存済み光学設定を置換",
|
||||
"render.appearance_editor.revalidate": "このボディを再検証",
|
||||
"render.appearance_editor.save": "この光学設定を保存",
|
||||
"render.appearance_editor.save_reason": "保存には現在のGPUボディの物理ガラスが必要です。コートとローカル画像マップは一時設定のままです。",
|
||||
"render.appearance_editor.save_reason": "ガラスを保存するには、現在のCPUまたはGPUボディで有効にしてください。クリアコートは「コートをショットに保存」を使用します。ローカル画像ファイルは一時設定のままです。",
|
||||
"render.appearance_editor.saved": "このショットに保存",
|
||||
"render.appearance_editor.saved_state": "この光学設定はこのショットに保存されています。",
|
||||
"render.appearance_editor.scatter_reason": "内部散乱には閉じたソリッド境界が必要です。",
|
||||
@@ -2948,7 +2948,7 @@
|
||||
"render.children.copy_request": "リクエストJSONをコピー",
|
||||
"render.children.coverage_error": "カバレッジ誤差",
|
||||
"render.children.cpu": "CPUレイトレーシング",
|
||||
"render.children.denoise": "完成画像のノイズを低減",
|
||||
"render.children.denoise": "画像のノイズを低減",
|
||||
"render.children.depth": "パス深度",
|
||||
"render.children.distortion": "歪み",
|
||||
"render.children.edge_samples": "エッジサンプル",
|
||||
@@ -3080,7 +3080,7 @@
|
||||
"render.cpu_point_help": "選択した平行光に最大8個の点光源を追加できます。距離はmmで、最小距離は基準距離以下にしてください。",
|
||||
"render.cpu_primary_samples": "輪郭サンプル数/ピクセル",
|
||||
"render.cpu_progressive": "CPUプログレッシブ",
|
||||
"render.cpu_progressive_help": "既存のCPU積分器を、一次サンプル1個ずつのパスで累積します。選択した反射は1サンプルが必要です。面光源、誘電体、異方性には固定画像を使用してください。",
|
||||
"render.cpu_progressive_help": "新しいCPUカメラサンプルを蓄積し、完了したすべてのパスを表示します。選択した反射はカメラサンプルごとに1回評価します。認証済みガラスと平面異方性には従来の制限が適用されます。適応サンプリングのパイロットと本番は同じCPUプロファイル予算を使います。",
|
||||
"render.cpu_progressive_limits": "最大1,048,576ピクセル、ピクセル×サンプルの合計は16,777,216以下です。サンプル数を変えても画像サイズは自動変更されません。",
|
||||
"render.cpu_progressive_seed": "CPUサンプリングシード",
|
||||
"render.cpu_progressive_spp": "サンプル/ピクセル",
|
||||
@@ -3256,7 +3256,7 @@
|
||||
"render.export_display": "書き出し:表示PNG",
|
||||
"render.export_image_label": "画像を書き出す",
|
||||
"render.export_linear": "書き出し:生の線形EXR",
|
||||
"render.exr_pending": "GPUでは線形EXRを保持できます。圧縮、複数レイヤー、補助パスは未実装です。",
|
||||
"render.exr_pending": "CPUレイトレーシングとGPUパストレーシングでは生の線形EXRを保持できます。圧縮、複数レイヤー、補助パスは未実装です。",
|
||||
"render.film_grain": "写真粒子",
|
||||
"render.fit": "製品を画面に合わせる",
|
||||
"render.fixed_camera": "カメラ",
|
||||
@@ -3619,7 +3619,7 @@
|
||||
"render.method_cpu": "CPU レイトレーシング",
|
||||
"render.method_gpu": "GPU パストレーシング",
|
||||
"render.method_limit_raster": "ラスターは固定の不透明スタジオプレビューを使用します。対応する光学設定には明示的なレイトレーシング方式を選択してください。",
|
||||
"render.method_raster": "CPU ラスター",
|
||||
"render.method_raster": "CPU ラスター(模式図)",
|
||||
"render.navigation_help": "カメラはデザインと同じように動きます(環境設定 › ナビゲーション)。「セットアップを保存」で現在のカメラを記録します。",
|
||||
"render.nested_enable": "検証済み入れ子誘電体",
|
||||
"render.nested_help": "最大16層。親は凸形状、子は厳密に内部である必要があります。交差・接触・薄膜・直線透過影は拒否します。内部カメラは検証した階層を使います。液面の生成や接触するCAD境界の結合は行いません。",
|
||||
@@ -3647,7 +3647,7 @@
|
||||
"render.output_exr_unavailable": "ここではEXR出力設定を利用できません。リニアEXR書き出しには、リニアマスターを保持した完成GPU画像が必要です。",
|
||||
"render.output_fit": "全体表示",
|
||||
"render.output_format": "形式",
|
||||
"render.output_format_status": "PNGは完了した表示画像そのものです。GPUの任意EXRは表示フィルター前の生の線形FLOAT32放射輝度を保持します。プレビューキャッシュや別ジョブへの置換はありません。",
|
||||
"render.output_format_status": "PNGは完了した表示画像そのものです。CPU/GPUの任意EXRは表示フィルター前の生の線形FLOAT32放射輝度を保持します。プレビューキャッシュや別ジョブへの置換はありません。",
|
||||
"render.output_grading": "カラーグレーディング",
|
||||
"render.output_image": "画像",
|
||||
"render.output_largest": "{width} × {height} 最大",
|
||||
@@ -3732,7 +3732,7 @@
|
||||
"render.quality_custom": "カスタム",
|
||||
"render.quality_draft": "ドラフト",
|
||||
"render.quality_high": "高品質",
|
||||
"render.quality_projection_help": "Qプリセットには将来の要件も含まれます。この操作は対応する固定サンプルPNG予算のみを適用し、適応的停止、生の線形出力、独立した透過制限は含みません。",
|
||||
"render.quality_projection_help": "この確認で変更するのは、解像度、サンプル予算、パス深度、シード、ノイズ低減だけです。他の設定は維持され、収束は保証されません。",
|
||||
"render.quality_review": "サンプリング予算を確認",
|
||||
"render.queue_pending": "このUIには自動の本番キューやアニメーションレンダラーはありません。完了した静止画像は個別に書き出せます。",
|
||||
"render.radial_assign": "外観を割り当て",
|
||||
@@ -3763,7 +3763,7 @@
|
||||
"render.result_navigation": "移動とズームはナビゲーション設定に従います。スペースキーを押しながらドラッグすると移動します。",
|
||||
"render.result_open": "完成した画像をメインキャンバスに表示します。",
|
||||
"render.result_preview": "ライブプレビュー",
|
||||
"render.result_provisional": "CPU仮画像",
|
||||
"render.result_provisional": "仮画像",
|
||||
"render.result_provisional_help": "この中間画像はエクスポートできません。最後に完成した結果をエクスポートします。キャンセルするとその結果に戻ります。",
|
||||
"render.result_reduced_display": "表示プレビューをビューポート機器またはキャッシュの上限に縮小しています。保持したPNGと書き出しは元の解像度です。この縮小表示ではネイティブ100%表示はできません。",
|
||||
"render.result_retained": "最後に完成した画像を表示しています。新しいレンダリングにはまだ置き換わっていません。",
|
||||
@@ -3927,7 +3927,7 @@
|
||||
"render.stage_panels.prepare_plan": "作成計画を準備",
|
||||
"render.stage_panels.projects": "投影",
|
||||
"render.stage_panels.quality_ack": "固定予算と省略事項を確認しました",
|
||||
"render.stage_panels.quality_omissions": "適応停止と誤差目標、個別の透過深度、ロシアンルーレット規則、線形RAW出力、EXRマスター、収束認証。",
|
||||
"render.stage_panels.quality_omissions": "この予算は、適応サンプリングの誤差目標、独立した透過制限、ロシアンルーレット規則、リニアマスターの保持を設定しません。収束も保証しません。",
|
||||
"render.stage_panels.reference": "参照",
|
||||
"render.stage_panels.references": "参照",
|
||||
"render.stage_panels.remove_optics": "光学設定を削除",
|
||||
@@ -3971,7 +3971,7 @@
|
||||
"render.stage_panels.subtitle_environment": "オリジナルHDR環境・シーン全体",
|
||||
"render.stage_panels.subtitle_maps": "作成済み空間マップ・一時プレビュー",
|
||||
"render.stage_panels.subtitle_physical": "読み取り専用計画・マテリアルの作成や割り当てなし",
|
||||
"render.stage_panels.subtitle_quality": "固定GPU予算・将来の要件は省略",
|
||||
"render.stage_panels.subtitle_quality": "GPUサンプリング予算 · 記載された設定のみ",
|
||||
"render.stage_panels.subtitle_rig": "作成済みライトリグ・光源と受動カード",
|
||||
"render.stage_panels.subtitle_saved_maps": "作成済みマップ・このショットに保存",
|
||||
"render.stage_panels.subtitle_stage": "物理的な地面とステージ・製品にフィット",
|
||||
@@ -3996,7 +3996,7 @@
|
||||
"render.studio_area_help": "最大 4 個の片面矩形です。直交する半辺 U と V でサイズを定め、U × V が発光方向になります。座標はワールド空間の mm です。",
|
||||
"render.studio_center": "中心 X, Y, Z (mm)",
|
||||
"render.studio_denoise": "完成画像のノイズを低減",
|
||||
"render.studio_denoise_help": "輪郭を保持する最終フィルターです。微細な描写が柔らかくなることがあります。ガラスと鋭い鏡面反射は対象外です。",
|
||||
"render.studio_denoise_help": "プログレッシブ画像と完成画像に輪郭を保持するフィルターを適用します。微細な描写が柔らかくなることがあります。ガラスと鋭い鏡面反射は対象外です。",
|
||||
"render.studio_direction": "太陽に向かう方向 (X, Y, Z)",
|
||||
"render.studio_environment": "環境",
|
||||
"render.studio_environment_bounds": "環境回転は±1,000,000ラジアン以内の有限値、強度は0~100でなければなりません。",
|
||||
|
||||
@@ -2120,7 +2120,7 @@
|
||||
"render.appearance_editor.replace": "이 바디의 저장된 광학 설정 교체",
|
||||
"render.appearance_editor.revalidate": "이 바디 다시 검증",
|
||||
"render.appearance_editor.save": "이 광학 설정 저장",
|
||||
"render.appearance_editor.save_reason": "저장하려면 현재 GPU 바디에 물리 유리가 필요합니다. 코트와 로컬 이미지 맵은 임시로 유지됩니다.",
|
||||
"render.appearance_editor.save_reason": "유리를 저장하려면 현재 CPU 또는 GPU 바디에서 활성화하세요. 클리어 코트는 샷에 코트 저장을 사용하세요. 로컬 이미지 파일은 임시 설정으로 유지됩니다.",
|
||||
"render.appearance_editor.saved": "이 샷에 저장됨",
|
||||
"render.appearance_editor.saved_state": "이 광학 설정은 이 샷과 함께 저장되어 있습니다.",
|
||||
"render.appearance_editor.scatter_reason": "내부 산란에는 닫힌 솔리드 경계가 필요합니다.",
|
||||
@@ -2168,7 +2168,7 @@
|
||||
"render.children.copy_request": "요청 JSON 복사",
|
||||
"render.children.coverage_error": "커버리지 오차",
|
||||
"render.children.cpu": "CPU 레이 트레이싱",
|
||||
"render.children.denoise": "완성 이미지의 노이즈 줄이기",
|
||||
"render.children.denoise": "이미지 노이즈 줄이기",
|
||||
"render.children.depth": "경로 깊이",
|
||||
"render.children.distortion": "왜곡",
|
||||
"render.children.edge_samples": "모서리 샘플",
|
||||
@@ -2389,7 +2389,7 @@
|
||||
"render.error_visible_solid": "여기서 미리 보려면 디자인에서 보이는 솔리드를 만드세요.",
|
||||
"render.exit_focus": "집중 보기 종료",
|
||||
"render.export_image_label": "이미지 내보내기",
|
||||
"render.exr_pending": "GPU는 원본 선형 EXR을 보존할 수 있습니다. 압축, 다중 레이어 이미지 및 보조 렌더 패스는 아직 사용할 수 없습니다.",
|
||||
"render.exr_pending": "CPU 레이 트레이싱과 GPU 패스 트레이싱은 원본 선형 EXR을 보존할 수 있습니다. 압축, 다중 레이어 이미지 및 보조 렌더 패스는 아직 사용할 수 없습니다.",
|
||||
"render.fit": "제품에 맞추기",
|
||||
"render.fixed_camera": "카메라",
|
||||
"render.fixed_world": "월드",
|
||||
@@ -2639,7 +2639,7 @@
|
||||
"render.output_exr_unavailable": "여기서는 EXR 출력을 설정할 수 없습니다. 선형 EXR 내보내기에는 선형 원본을 보관한 완료된 GPU 이미지가 필요합니다.",
|
||||
"render.output_fit": "맞춤",
|
||||
"render.output_format": "형식",
|
||||
"render.output_format_status": "PNG는 완료된 표시 이미지 그대로입니다. 선택적 GPU EXR은 표시 필터 전 선형 FLOAT32 방사 휘도를 보존합니다. 미리보기 캐시나 다른 작업으로 바꾸어 내보내지 않습니다.",
|
||||
"render.output_format_status": "PNG는 완료된 표시 이미지 그대로입니다. 선택적 CPU/GPU EXR은 표시 필터 전 선형 FLOAT32 방사 휘도를 보존합니다. 미리보기 캐시나 다른 작업으로 바꾸어 내보내지 않습니다.",
|
||||
"render.output_grading": "색 보정",
|
||||
"render.output_image": "이미지",
|
||||
"render.output_largest": "{width} × {height} 최대",
|
||||
@@ -2689,6 +2689,7 @@
|
||||
"render.quality_custom": "사용자 지정",
|
||||
"render.quality_draft": "초안",
|
||||
"render.quality_high": "높음",
|
||||
"render.quality_projection_help": "이 검토에서는 해상도, 샘플 예산, 경로 깊이, 시드 및 노이즈 제거만 변경합니다. 다른 설정은 유지되며 수렴은 보장되지 않습니다.",
|
||||
"render.radial_assign": "외관 지정",
|
||||
"render.radial_compare": "비교",
|
||||
"render.radial_fit": "제품 맞추기",
|
||||
@@ -2831,7 +2832,7 @@
|
||||
"render.stage_panels.prepare_plan": "생성 계획 준비",
|
||||
"render.stage_panels.projects": "투영",
|
||||
"render.stage_panels.quality_ack": "고정 예산과 생략 사항을 확인했습니다",
|
||||
"render.stage_panels.quality_omissions": "적응형 중지 및 오차 목표, 별도 투과 깊이, 러시안 룰렛 규칙, 원시 선형 출력, EXR 마스터 및 수렴 인증.",
|
||||
"render.stage_panels.quality_omissions": "이 예산은 적응형 오차 목표, 독립적인 투과 한도, 러시안 룰렛 규칙 또는 선형 마스터 보존을 설정하지 않으며 수렴을 보장하지 않습니다.",
|
||||
"render.stage_panels.reference": "참조",
|
||||
"render.stage_panels.references": "참조",
|
||||
"render.stage_panels.remove_optics": "광학 설정 제거",
|
||||
@@ -2875,7 +2876,7 @@
|
||||
"render.stage_panels.subtitle_environment": "원본 HDR 환경 · 전체 장면",
|
||||
"render.stage_panels.subtitle_maps": "제작된 공간 맵 · 임시 미리 보기",
|
||||
"render.stage_panels.subtitle_physical": "읽기 전용 계획 · 재질 생성 또는 할당 없음",
|
||||
"render.stage_panels.subtitle_quality": "고정 GPU 예산 · 향후 요구 사항 생략",
|
||||
"render.stage_panels.subtitle_quality": "GPU 샘플링 예산 · 표시된 설정만 변경",
|
||||
"render.stage_panels.subtitle_rig": "제작된 조명 리그 · 광원 및 수동 카드",
|
||||
"render.stage_panels.subtitle_saved_maps": "제작된 맵 · 이 샷에 저장",
|
||||
"render.stage_panels.subtitle_stage": "물리적 바닥 및 스테이지 · 제품에 맞춤",
|
||||
@@ -2892,6 +2893,7 @@
|
||||
"render.state_in_progress": "이미지 생성 중",
|
||||
"render.state_model": "모델 미리보기",
|
||||
"render.strip_label": "스트립",
|
||||
"render.studio_denoise_help": "프로그레시브 이미지와 완성 이미지에 경계를 보존하는 필터를 적용합니다. 미세한 디테일이 부드러워질 수 있습니다. 유리와 날카로운 정반사 표면은 필터를 거치지 않습니다.",
|
||||
"render.studio_tiles_require_gpu": "스튜디오에는 GPU 경로 추적이 필요합니다. 출력 편집기에서 GPU를 선택하세요.",
|
||||
"render.studios_environments": "스튜디오 및 환경",
|
||||
"render.tone_aces_fitted": "ACES 근사",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.aging_raytrace_help": "يُطبّق التعتيق في تتبع الأشعة على المعالج وتتبع المسارات على المعالج الرسومي. يبقى التصيير النقطي تخطيطياً."
|
||||
},
|
||||
"de": {
|
||||
"render.aging_raytrace_help": "Alterung wird beim CPU-Raytracing und GPU-Pathtracing angewendet. Raster bleibt eine schematische Darstellung."
|
||||
},
|
||||
"en": {
|
||||
"render.aging_raytrace_help": "Aging is applied by CPU ray tracing and GPU path tracing. Raster keeps schematic rendering."
|
||||
},
|
||||
"en-GB": {
|
||||
"render.aging_raytrace_help": "Ageing is applied by CPU ray tracing and GPU path tracing. Raster keeps schematic rendering."
|
||||
},
|
||||
"he": {
|
||||
"render.aging_raytrace_help": "התיישנות מוחלת במעקב קרניים במעבד ובמעקב מסלולים במעבד הגרפי. רינדור רסטר נשאר סכמטי."
|
||||
},
|
||||
"ja": {
|
||||
"render.aging_raytrace_help": "エイジングはCPUレイトレーシングとGPUパストレーシングで適用されます。ラスタは模式的な描画を維持します。"
|
||||
},
|
||||
"ko": {
|
||||
"render.aging_raytrace_help": "에이징은 CPU 광선 추적과 GPU 경로 추적에 적용됩니다. 래스터는 도식적인 렌더링을 유지합니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.aging_raytrace_help": "Hiệu ứng lão hoá được áp dụng khi dò tia bằng CPU và dò đường đi bằng GPU. Kết xuất raster giữ kiểu sơ đồ."
|
||||
},
|
||||
"zh": {
|
||||
"render.aging_raytrace_help": "老化效果适用于CPU光线追踪和GPU路径追踪。光栅保持示意渲染。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.cpu_volume_enable_path": "تفعيل الإضاءة غير المباشرة",
|
||||
"render.cpu_volume_enable_path_plan": "تتبع أشعة CPU · عمق المسار {depth} · {spp} عينة/بكسل · بذرة {seed} · ظلال متتبعة. يحتفظ بالغلاف الجوي وميزانية الصورة المحددين.",
|
||||
"render.cpu_volume_help": "يستخدم الغلاف الجوي على CPU الإضاءة غير المباشرة. يحافظ الانطفاء الصفري على الصندوق المحفوظ دون ضباب.",
|
||||
"render.cpu_volume_method_adopt": "تتبع أشعة CPU مع إضاءة غير مباشرة",
|
||||
"render.cpu_volume_method_incompatible": "لا يمكن نقل إعدادات الإضاءة أو أخذ العينات أو الغلاف الجوي هذه إلى هذه الطريقة. تبقى إعداداتك محفوظة.",
|
||||
"render.cpu_volume_path_required": "فعّل الإضاءة غير المباشرة لاستخدام الغلاف الجوي على CPU.",
|
||||
"render.volume_denoise_required": "أوقف تقليل الضوضاء لاستخدام الغلاف الجوي.",
|
||||
"render.volume_glass_required": "لا يمكن الجمع بين الغلاف الجوي والزجاج في هذا الإعداد.",
|
||||
"render.volume_lighting_required": "اختر بيئة أو مجموعة إضاءة محددة لتصيير الغلاف الجوي.",
|
||||
"render.volume_single_stratum_required": "استخدم عينة نفاذ واحدة لتصيير الغلاف الجوي."
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_volume_enable_path": "Indirekte Beleuchtung einschalten",
|
||||
"render.cpu_volume_enable_path_plan": "CPU-Raytracing · Pfadtiefe {depth} · {spp} Samples/Pixel · Seed {seed} · Raytracing-Schatten. Atmosphäre und Bildbudget bleiben erhalten.",
|
||||
"render.cpu_volume_help": "CPU-Atmosphäre nutzt indirekte Beleuchtung. Bei Extinktion null bleibt die gespeicherte Box ohne Nebel erhalten.",
|
||||
"render.cpu_volume_method_adopt": "CPU-Raytracing mit indirekter Beleuchtung",
|
||||
"render.cpu_volume_method_incompatible": "Diese Licht-, Sampling- oder Atmosphäreinstellungen lassen sich nicht auf diese Methode übertragen. Die Einstellungen bleiben erhalten.",
|
||||
"render.cpu_volume_path_required": "Indirekte Beleuchtung einschalten, um CPU-Atmosphäre zu nutzen.",
|
||||
"render.volume_denoise_required": "Für Atmosphäre die Rauschreduzierung ausschalten.",
|
||||
"render.volume_glass_required": "Atmosphäre und Glas können in dieser Einstellung nicht kombiniert werden.",
|
||||
"render.volume_lighting_required": "Für Atmosphäre eine Umgebung oder ein eigenes Lichtrig wählen.",
|
||||
"render.volume_single_stratum_required": "Für Atmosphäre ein Transmissionssample verwenden."
|
||||
},
|
||||
"en": {
|
||||
"render.cpu_volume_enable_path": "Enable Indirect lighting",
|
||||
"render.cpu_volume_enable_path_plan": "CPU ray tracing · path depth {depth} · {spp} samples/pixel · seed {seed} · traced shadows. Keeps the authored atmosphere and image budget.",
|
||||
"render.cpu_volume_help": "CPU Atmosphere uses Indirect lighting. Zero extinction keeps the saved box without fog.",
|
||||
"render.cpu_volume_method_adopt": "CPU ray tracing with indirect lighting",
|
||||
"render.cpu_volume_method_incompatible": "These light, sampling or atmosphere settings cannot be transferred to this method. Your settings are kept.",
|
||||
"render.cpu_volume_path_required": "Enable Indirect lighting to use CPU Atmosphere.",
|
||||
"render.volume_denoise_required": "Turn off noise reduction to use Atmosphere.",
|
||||
"render.volume_glass_required": "Atmosphere cannot be combined with Glass in this setup.",
|
||||
"render.volume_lighting_required": "Choose an environment or an explicit light rig to render Atmosphere.",
|
||||
"render.volume_single_stratum_required": "Use one transmission sample to render Atmosphere."
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_volume_enable_path": "הפעלת תאורה עקיפה",
|
||||
"render.cpu_volume_enable_path_plan": "מעקב קרניים ב־CPU · עומק מסלול {depth} · {spp} דגימות לפיקסל · זרע {seed} · צללים במעקב קרניים. האטמוספרה ותקציב התמונה שנקבעו נשמרים.",
|
||||
"render.cpu_volume_help": "אטמוספרה ב־CPU משתמשת בתאורה עקיפה. הכחדה אפסית שומרת את התיבה ללא ערפל.",
|
||||
"render.cpu_volume_method_adopt": "מעקב קרניים ב־CPU עם תאורה עקיפה",
|
||||
"render.cpu_volume_method_incompatible": "לא ניתן להעביר הגדרות תאורה, דגימה או אטמוספרה אלה לשיטה זו. ההגדרות שלך נשמרות.",
|
||||
"render.cpu_volume_path_required": "יש להפעיל תאורה עקיפה כדי להשתמש באטמוספרה ב־CPU.",
|
||||
"render.volume_denoise_required": "יש לכבות הפחתת רעש כדי להשתמש באטמוספרה.",
|
||||
"render.volume_glass_required": "לא ניתן לשלב אטמוספרה וזכוכית בהגדרה זו.",
|
||||
"render.volume_lighting_required": "יש לבחור סביבה או מערך תאורה מפורש כדי לרנדר אטמוספרה.",
|
||||
"render.volume_single_stratum_required": "יש להשתמש בדגימת מעבר אחת כדי לרנדר אטמוספרה."
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_volume_enable_path": "間接照明を有効にする",
|
||||
"render.cpu_volume_enable_path_plan": "CPUレイトレーシング · パス深度{depth} · {spp}サンプル/ピクセル · シード{seed} · レイトレースシャドウ。大気と画像予算の設定を保持します。",
|
||||
"render.cpu_volume_help": "CPUの大気は間接照明を使用します。消散係数がゼロでも、霧のないボックスとして設定は保存されます。",
|
||||
"render.cpu_volume_method_adopt": "間接照明を使うCPUレイトレーシング",
|
||||
"render.cpu_volume_method_incompatible": "この照明、サンプリング、大気の設定はこの方式に引き継げません。設定は保持されます。",
|
||||
"render.cpu_volume_path_required": "CPUの大気を使うには間接照明を有効にしてください。",
|
||||
"render.volume_denoise_required": "大気を使うにはノイズ低減をオフにしてください。",
|
||||
"render.volume_glass_required": "この設定では大気とガラスを併用できません。",
|
||||
"render.volume_lighting_required": "大気を描画するには環境または明示的なライト構成を選んでください。",
|
||||
"render.volume_single_stratum_required": "大気を描画するには透過サンプル数を1にしてください。"
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_volume_enable_path": "간접 조명 켜기",
|
||||
"render.cpu_volume_enable_path_plan": "CPU 레이 트레이싱 · 경로 깊이 {depth} · 픽셀당 {spp}샘플 · 시드 {seed} · 레이 트레이싱 그림자. 작성한 대기와 이미지 예산을 유지합니다.",
|
||||
"render.cpu_volume_help": "CPU 대기는 간접 조명을 사용합니다. 소광 계수가 0이면 저장된 상자는 안개 없이 유지됩니다.",
|
||||
"render.cpu_volume_method_adopt": "간접 조명을 사용하는 CPU 레이 트레이싱",
|
||||
"render.cpu_volume_method_incompatible": "이 조명, 샘플링 또는 대기 설정을 이 방식으로 옮길 수 없습니다. 설정은 유지됩니다.",
|
||||
"render.cpu_volume_path_required": "CPU 대기를 사용하려면 간접 조명을 켜세요.",
|
||||
"render.volume_denoise_required": "대기를 사용하려면 노이즈 감소를 끄세요.",
|
||||
"render.volume_glass_required": "이 설정에서는 대기와 유리를 함께 사용할 수 없습니다.",
|
||||
"render.volume_lighting_required": "대기를 렌더링하려면 환경 또는 명시적인 조명 구성을 선택하세요.",
|
||||
"render.volume_single_stratum_required": "대기를 렌더링하려면 투과 샘플을 1로 설정하세요."
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_volume_enable_path": "Bật chiếu sáng gián tiếp",
|
||||
"render.cpu_volume_enable_path_plan": "Dò tia CPU · độ sâu đường đi {depth} · {spp} mẫu/pixel · hạt giống {seed} · bóng dò tia. Giữ nguyên khí quyển và ngân sách ảnh đã đặt.",
|
||||
"render.cpu_volume_help": "Khí quyển trên CPU dùng chiếu sáng gián tiếp. Hệ số suy giảm bằng không vẫn giữ hộp đã lưu mà không tạo sương.",
|
||||
"render.cpu_volume_method_adopt": "Dò tia CPU với chiếu sáng gián tiếp",
|
||||
"render.cpu_volume_method_incompatible": "Không thể chuyển các thiết lập đèn, lấy mẫu hoặc khí quyển này sang phương pháp này. Thiết lập của bạn được giữ nguyên.",
|
||||
"render.cpu_volume_path_required": "Bật chiếu sáng gián tiếp để dùng Khí quyển trên CPU.",
|
||||
"render.volume_denoise_required": "Tắt giảm nhiễu để dùng Khí quyển.",
|
||||
"render.volume_glass_required": "Không thể kết hợp Khí quyển và Kính trong thiết lập này.",
|
||||
"render.volume_lighting_required": "Chọn môi trường hoặc bộ đèn tường minh để kết xuất Khí quyển.",
|
||||
"render.volume_single_stratum_required": "Dùng một mẫu truyền sáng để kết xuất Khí quyển."
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_volume_enable_path": "启用间接照明",
|
||||
"render.cpu_volume_enable_path_plan": "CPU 光线追踪 · 路径深度 {depth} · 每像素 {spp} 个采样 · 种子 {seed} · 追踪阴影。保留已编写的大气和图像预算。",
|
||||
"render.cpu_volume_help": "CPU 大气使用间接照明。消光系数为零时,已保存的包围盒仍会保留,但不会产生雾。",
|
||||
"render.cpu_volume_method_adopt": "使用间接照明的 CPU 光线追踪",
|
||||
"render.cpu_volume_method_incompatible": "这些灯光、采样或大气设置无法转移到此方式。您的设置会保留。",
|
||||
"render.cpu_volume_path_required": "启用间接照明以使用 CPU 大气。",
|
||||
"render.volume_denoise_required": "关闭降噪以使用大气。",
|
||||
"render.volume_glass_required": "此设置无法同时使用大气和玻璃。",
|
||||
"render.volume_lighting_required": "选择环境或显式灯光配置以渲染大气。",
|
||||
"render.volume_single_stratum_required": "将透射采样数设为 1 以渲染大气。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.cpu_constant_direct_help": "بدون الإضاءة غير المباشرة، يمكن أن يظهر هذا المصدر الثابت في الانعكاسات وعبر الزجاج. لكنه لا يضيء الأسطح المعتمة. فعّل الإضاءة غير المباشرة لاستخدامه في إضاءة المشهد.",
|
||||
"render.cpu_constant_enable_indirect_plan": "عمق المسار {depth}؛ أخذ عينات تدريجي بمعدل {spp} عينة/بكسل (البذرة {seed})؛ شعاع أولي واحد لكل عينة؛ ظلال متتبعة. يحتفظ بحجم الصورة وميزانية CPU والإعدادات الأخرى.",
|
||||
"render.cpu_indirect_method_required": "اختر تتبع أشعة CPU لتفعيل وضع الإضاءة غير المباشرة هذا."
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_constant_direct_help": "Ohne indirekte Beleuchtung kann diese konstante Quelle in Reflexionen und durch Glas sichtbar sein. Sie beleuchtet keine undurchsichtigen Flächen. Schalte indirekte Beleuchtung ein, um sie als Szenenlicht zu nutzen.",
|
||||
"render.cpu_constant_enable_indirect_plan": "Pfadtiefe {depth}; progressives Sampling mit {spp} Samples/Pixel (Seed {seed}); ein Primärstrahl pro Sample; Raytracing-Schatten. Bildgröße, CPU-Budget und andere Einstellungen bleiben erhalten.",
|
||||
"render.cpu_indirect_method_required": "Wähle CPU-Raytracing, um diesen Modus für indirekte Beleuchtung einzuschalten."
|
||||
},
|
||||
"en": {
|
||||
"render.cpu_constant_direct_help": "Without Indirect lighting, this constant source can appear in reflections and through Glass. It does not light opaque surfaces. Enable Indirect lighting to use it for scene lighting.",
|
||||
"render.cpu_constant_enable_indirect_plan": "Path depth {depth}; progressive sampling at {spp} samples/pixel (seed {seed}); one primary ray per sample; traced shadows. Keeps your image size, CPU budget and other settings.",
|
||||
"render.cpu_indirect_method_required": "Choose CPU ray tracing to enable this Indirect lighting mode."
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_constant_direct_help": "ללא תאורה עקיפה, מקור קבוע זה עשוי להופיע בהשתקפויות ודרך זכוכית. הוא אינו מאיר משטחים אטומים. יש להפעיל תאורה עקיפה כדי להשתמש בו לתאורת הסצנה.",
|
||||
"render.cpu_constant_enable_indirect_plan": "עומק מסלול {depth}; דגימה הדרגתית עם {spp} דגימות לפיקסל (זרע {seed}); קרן ראשית אחת לכל דגימה; צללים במעקב קרניים. גודל התמונה, תקציב ה־CPU וההגדרות האחרות נשמרים.",
|
||||
"render.cpu_indirect_method_required": "יש לבחור מעקב קרניים ב־CPU כדי להפעיל מצב זה של תאורה עקיפה."
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_constant_direct_help": "間接照明を使わない場合、この一定の光源は反射やガラス越しに見えることがありますが、不透明な面は照らしません。シーンの照明に使うには、間接照明を有効にしてください。",
|
||||
"render.cpu_constant_enable_indirect_plan": "パスの深さ {depth}、ピクセルあたり {spp} サンプルのプログレッシブサンプリング(シード {seed})、各サンプルに一次レイ1本、レイトレーシングの影を使用します。画像サイズ、CPU予算、その他の設定は保持します。",
|
||||
"render.cpu_indirect_method_required": "この間接照明モードを有効にするには、CPUレイトレーシングを選択してください。"
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_constant_direct_help": "간접 조명이 꺼져 있으면 이 일정한 광원은 반사나 유리를 통해 보일 수 있지만 불투명한 표면을 밝히지는 않습니다. 장면 조명으로 사용하려면 간접 조명을 켜세요.",
|
||||
"render.cpu_constant_enable_indirect_plan": "경로 깊이 {depth}, 픽셀당 {spp}개 샘플의 점진적 샘플링(시드 {seed}), 샘플당 기본 광선 1개, 추적된 그림자를 사용합니다. 이미지 크기, CPU 예산 및 다른 설정을 유지합니다.",
|
||||
"render.cpu_indirect_method_required": "이 간접 조명 모드를 켜려면 CPU 레이 트레이싱을 선택하세요."
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_constant_direct_help": "Khi tắt Chiếu sáng gián tiếp, nguồn sáng đồng nhất này có thể xuất hiện trong phản xạ và qua Kính nhưng không chiếu sáng các bề mặt đục. Bật Chiếu sáng gián tiếp để dùng nguồn này chiếu sáng cảnh.",
|
||||
"render.cpu_constant_enable_indirect_plan": "Độ sâu đường đi {depth}; lấy mẫu tăng dần với {spp} mẫu/pixel (hạt giống {seed}); một tia sơ cấp cho mỗi mẫu; bóng đổ dò tia. Giữ kích thước ảnh, ngân sách CPU và các cài đặt khác.",
|
||||
"render.cpu_indirect_method_required": "Chọn dò tia CPU để bật chế độ Chiếu sáng gián tiếp này."
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_constant_direct_help": "未启用间接照明时,此恒定光源可能出现在反射中或透过玻璃可见,但不会照亮不透明表面。要用它照亮场景,请启用间接照明。",
|
||||
"render.cpu_constant_enable_indirect_plan": "路径深度 {depth};每像素 {spp} 个样本的渐进采样(种子 {seed});每个样本一条主光线;光线追踪阴影。保留图像尺寸、CPU预算和其他设置。",
|
||||
"render.cpu_indirect_method_required": "请选择CPU光线追踪以启用此间接照明模式。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.cpu_glass_enable_path": "تشغيل الإضاءة غير المباشرة",
|
||||
"render.cpu_path_depth": "عمق المسار",
|
||||
"render.cpu_path_help": "تحسب الضوء المرتد بين الأسطح المعتمة والمار عبر أشكال الزجاج المدعومة. تتطلب التصيير التدريجي وظلال تتبع الأشعة. يحسب عمق المسار الأسطح المضاءة وحدود الزجاج وأحداث التشتت بدءاً من أول تقاطع (1–16). يقتصر العمق 1 على الإضاءة المباشرة للأسطح المعتمة؛ وتسمح القيم الأعلى بمزيد من الارتدادات أو عبور الزجاج وقد تستغرق وقتاً أطول. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي.",
|
||||
"render.cpu_path_lighting": "إضاءة غير مباشرة",
|
||||
"render.cpu_path_optics_reason": "تبقى إعداداتك كما هي. أوقف الإضاءة غير المباشرة لاستخدام الانعكاسات المخصصة أو التباين الاتجاهي."
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_glass_enable_path": "Indirekte Beleuchtung einschalten",
|
||||
"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_lighting": "Indirekte Beleuchtung",
|
||||
"render.cpu_path_optics_reason": "Die Einstellungen bleiben erhalten. Für eigene Reflexionen oder Anisotropie die indirekte Beleuchtung ausschalten."
|
||||
},
|
||||
"en": {
|
||||
"render.cpu_glass_enable_path": "Enable Indirect lighting",
|
||||
"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_lighting": "Indirect lighting",
|
||||
"render.cpu_path_optics_reason": "Your settings are kept. Turn off Indirect lighting to use custom reflections or anisotropy."
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_glass_enable_path": "הפעלת תאורה עקיפה",
|
||||
"render.cpu_path_depth": "עומק מסלול",
|
||||
"render.cpu_path_help": "מחשבת אור שמוחזר בין משטחים אטומים ועובר דרך צורות זכוכית נתמכות. נדרשים רינדור הדרגתי וצללים במעקב קרניים. עומק המסלול סופר משטחים מוצללים, גבולות זכוכית ואירועי פיזור החל מהמפגש הראשון (1–16). עומק 1 נותן תאורה ישירה למשטחים אטומים; ערכים גבוהים מאפשרים יותר החזרות או מעברים בזכוכית ועשויים לדרוש יותר זמן. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה.",
|
||||
"render.cpu_path_lighting": "תאורה עקיפה",
|
||||
"render.cpu_path_optics_reason": "ההגדרות שלך נשמרות. יש לכבות תאורה עקיפה כדי להשתמש בהשתקפויות מותאמות או באנאיזוטרופיה."
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_glass_enable_path": "間接照明を有効にする",
|
||||
"render.cpu_path_depth": "パス深度",
|
||||
"render.cpu_path_help": "不透明な面の間で反射し、対応するガラス形状を通る光を計算します。プログレッシブ描画とレイトレーシングの影が必要です。パス深度は最初の交点から、照明を計算する面、ガラス境界、散乱イベントを数えます(1~16)。深度1は不透明面の直接照明のみです。値を上げると反射やガラスの通過を増やせますが、時間がかかる場合があります。独自の反射設定や異方性を使うには間接照明をオフにしてください。",
|
||||
"render.cpu_path_lighting": "間接照明",
|
||||
"render.cpu_path_optics_reason": "設定は保持されます。独自の反射設定や異方性を使うには間接照明をオフにしてください。"
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_glass_enable_path": "간접 조명 켜기",
|
||||
"render.cpu_path_depth": "경로 깊이",
|
||||
"render.cpu_path_help": "불투명 표면 사이에서 반사되고 지원되는 유리 형상을 통과하는 빛을 계산합니다. 점진적 렌더링과 레이 추적 그림자가 필요합니다. 경로 깊이는 첫 교점부터 조명이 계산되는 표면, 유리 경계 및 산란 이벤트를 셉니다(1–16). 깊이 1은 불투명 표면의 직접 조명만 계산합니다. 값을 높이면 반사나 유리 통과가 늘어나며 시간이 더 걸릴 수 있습니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요.",
|
||||
"render.cpu_path_lighting": "간접 조명",
|
||||
"render.cpu_path_optics_reason": "설정은 유지됩니다. 사용자 반사 설정이나 이방성을 사용하려면 간접 조명을 끄세요."
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_glass_enable_path": "Bật Chiếu sáng gián tiếp",
|
||||
"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_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."
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_glass_enable_path": "启用间接照明",
|
||||
"render.cpu_path_depth": "路径深度",
|
||||
"render.cpu_path_help": "计算在不透明表面之间反射并穿过受支持玻璃形状的光。需要渐进渲染和光线追踪阴影。路径深度从首次相交开始计算着色表面、玻璃边界和散射事件(1–16)。深度 1 只计算不透明表面的直接照明;更高的值允许更多反射或玻璃穿越,也可能耗时更长。使用自定义反射或各向异性时,请关闭间接照明。",
|
||||
"render.cpu_path_lighting": "间接照明",
|
||||
"render.cpu_path_optics_reason": "设置会保留。使用自定义反射或各向异性时,请关闭间接照明。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"en": {
|
||||
"render.cpu_quality_budget_help": "Changes size, production sample cap, seed and CPU resource profile. Camera, lens, lighting, GI depth and effects are kept.",
|
||||
"render.cpu_quality_progressive_required": "Enable progressive sampling below before choosing a CPU budget.",
|
||||
"render.cpu_quality_summary": "{width} × {height} · {samples} sample cap · seed {seed} · {profile}"
|
||||
},
|
||||
"ar": {
|
||||
"render.cpu_quality_budget_help": "يغير الحجم وحد عينات الإنتاج والبذرة وملف موارد CPU. تُحفظ الكاميرا والعدسة والإضاءة وعمق GI والتأثيرات.",
|
||||
"render.cpu_quality_progressive_required": "فعّل أخذ العينات التدريجي أدناه قبل اختيار ميزانية CPU.",
|
||||
"render.cpu_quality_summary": "{width} × {height} · حد العينات {samples} · البذرة {seed} · {profile}"
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_quality_budget_help": "Ändert Größe, Obergrenze der Produktionssamples, Startwert und CPU-Ressourcenprofil. Kamera, Objektiv, Beleuchtung, GI-Tiefe und Effekte bleiben erhalten.",
|
||||
"render.cpu_quality_progressive_required": "Aktiviere unten progressive Samples, bevor du ein CPU-Budget auswählst.",
|
||||
"render.cpu_quality_summary": "{width} × {height} · Sample-Grenze {samples} · Startwert {seed} · {profile}"
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_quality_budget_help": "משנה גודל, תקרת דגימות הפקה, זרע ופרופיל משאבי CPU. המצלמה, העדשה, התאורה, עומק GI והאפקטים נשמרים.",
|
||||
"render.cpu_quality_progressive_required": "יש להפעיל דגימה מתקדמת למטה לפני בחירת תקציב CPU.",
|
||||
"render.cpu_quality_summary": "{width} × {height} · תקרת דגימות {samples} · זרע {seed} · {profile}"
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_quality_budget_help": "サイズ、本番サンプル数の上限、シード、CPUリソースプロファイルを変更します。カメラ、レンズ、照明、GI深度、エフェクトは維持されます。",
|
||||
"render.cpu_quality_progressive_required": "CPU予算を選ぶ前に、下でプログレッシブサンプリングを有効にしてください。",
|
||||
"render.cpu_quality_summary": "{width} × {height} · サンプル上限 {samples} · シード {seed} · {profile}"
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_quality_budget_help": "크기, 제작 샘플 상한, 시드와 CPU 리소스 프로필을 변경합니다. 카메라, 렌즈, 조명, GI 깊이와 효과는 유지됩니다.",
|
||||
"render.cpu_quality_progressive_required": "CPU 예산을 선택하기 전에 아래에서 점진적 샘플링을 활성화하세요.",
|
||||
"render.cpu_quality_summary": "{width} × {height} · 샘플 상한 {samples} · 시드 {seed} · {profile}"
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_quality_budget_help": "Thay đổi kích thước, trần mẫu sản xuất, hạt giống và cấu hình tài nguyên CPU. Giữ nguyên máy ảnh, ống kính, ánh sáng, độ sâu GI và hiệu ứng.",
|
||||
"render.cpu_quality_progressive_required": "Bật lấy mẫu tiến dần bên dưới trước khi chọn ngân sách CPU.",
|
||||
"render.cpu_quality_summary": "{width} × {height} · trần mẫu {samples} · hạt giống {seed} · {profile}"
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_quality_budget_help": "更改尺寸、正式样本上限、种子和CPU资源配置。保留相机、镜头、照明、GI深度和效果。",
|
||||
"render.cpu_quality_progressive_required": "选择CPU预算前,请先在下方启用渐进采样。",
|
||||
"render.cpu_quality_summary": "{width} × {height} · 样本上限 {samples} · 种子 {seed} · {profile}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.appearance_editor.discard_saved_edit": "تجاهل التعديلات البصرية غير المحفوظة",
|
||||
"render.cpu_subsurface_denoise_reason": "يتطلب التشتت داخل الزجاج على CPU إيقاف إزالة الضجيج. تبقى إعداداتك محفوظة.",
|
||||
"render.cpu_subsurface_help": "يغيّر التشتت اتجاه الضوء داخل الزجاج المغلق. تتطلب المعدلات الموجبة إضاءة غير مباشرة مع إيقاف إزالة الضجيج. المعدل هو عدد التصادمات لكل مم؛ لا يضيف الصفر أي تشتت. يفضّل انحياز الاتجاه الموجب الأمام، والسالب الخلف.",
|
||||
"render.material_cpu_glass_only": "تتطلب خرائط ملفات الصور المؤقتة GPU. يمكن لـCPU استخدام خرائط الأسطح المضمنة المحفوظة.",
|
||||
"render.material_cpu_optics_limits": "شغّل الإضاءة غير المباشرة لأشكال الزجاج المغلقة المدعومة والتداخل المتحقق منه والتشتت داخل الزجاج. يمكن للصفائح الرقيقة تمرير الضوء للظلال. وإلا فاستخدم صفيحة مستوية أو صندوقًا محاذيًا للمحاور. تظل الأشكال المتلامسة والظلال النافذة عبر الزجاج المغلق غير مدعومة. تبقى قيمك محفوظة.",
|
||||
"render.material_cpu_saved_runtime_conflict": "لا يمكن الجمع بين البصريات المحفوظة على CPU والارتباطات البصرية المؤقتة السابقة. احفظ البصريات التي راجعتها أو أزل الارتباط المؤقت المتعارض.",
|
||||
"render.material_raytrace_required": "يتطلب الزجاج الفيزيائي تتبع الأشعة على CPU أو تتبع المسارات على GPU.",
|
||||
"render.material_saved_edit_pending": "هذه التعديلات البصرية غير محفوظة. احفظ القيم التي راجعتها، أو أزل التعيين المحفوظ، أو تجاهل التعديلات. لا يمكن للارتباطات المؤقتة السابقة والمؤثرات غير المدعومة تجاوز الهدف المحفوظ."
|
||||
},
|
||||
"de": {
|
||||
"render.appearance_editor.discard_saved_edit": "Ungespeicherte Optikänderungen verwerfen",
|
||||
"render.cpu_subsurface_denoise_reason": "Streuung im Glas auf der CPU benötigt ausgeschaltete Rauschreduzierung. Die Einstellungen bleiben erhalten.",
|
||||
"render.cpu_subsurface_help": "Streuung lenkt Licht im Inneren geschlossenen Glases um. Positive Raten benötigen indirekte Beleuchtung ohne Rauschreduzierung. Die Rate zählt Kollisionen pro mm; 0 fügt keine Streuung hinzu. Positive Richtungswerte bevorzugen vorwärts, negative rückwärts.",
|
||||
"render.material_cpu_glass_only": "Temporäre Bilddatei-Karten benötigen die GPU. Die CPU kann gespeicherte integrierte Oberflächenkarten verwenden.",
|
||||
"render.material_cpu_optics_limits": "Indirekte Beleuchtung für unterstützte geschlossene Glasformen, geprüfte Verschachtelung und Streuung im Inneren einschalten. Dünne Scheiben können Licht für Schatten durchlassen. Sonst eine ebene Scheibe oder achsenparallele Box verwenden. Berührende Formen und durch geschlossenes Glas übertragene Schatten werden weiterhin nicht unterstützt. Die Werte bleiben erhalten.",
|
||||
"render.material_cpu_saved_runtime_conflict": "Gespeicherte CPU-Optik kann nicht mit temporären optischen Bindungen kombiniert werden. Geprüfte Optik speichern oder die temporäre Bindung entfernen.",
|
||||
"render.material_raytrace_required": "Physikalisches Glas benötigt CPU-Raytracing oder GPU-Pathtracing.",
|
||||
"render.material_saved_edit_pending": "Diese Optikänderungen sind ungespeichert. Geprüfte Werte speichern, die gespeicherte Zuordnung entfernen oder Änderungen verwerfen. Temporäre Bindungen und nicht unterstützte Effekte können das gespeicherte Ziel nicht überschreiben."
|
||||
},
|
||||
"en": {
|
||||
"render.appearance_editor.discard_saved_edit": "Discard unsaved optical edits",
|
||||
"render.cpu_subsurface_denoise_reason": "CPU subsurface scattering needs Denoise off. Your settings are kept.",
|
||||
"render.cpu_subsurface_help": "Scattering redirects light inside closed Glass. Positive rates need Indirect lighting with Denoise off. The rate is collisions per mm; 0 adds no scattering. Positive phase bias favors forward directions; negative favors backward.",
|
||||
"render.material_cpu_glass_only": "Temporary image-file maps require GPU. CPU can use saved built-in surface maps.",
|
||||
"render.material_cpu_optics_limits": "Turn on Indirect lighting for supported closed Glass shapes, certified nesting and subsurface scattering. Thin sheets can pass shadows. Otherwise use a planar sheet or axis-aligned box. Touching shapes and transmitted shadows through closed Glass remain unsupported. Your values are kept.",
|
||||
"render.material_cpu_saved_runtime_conflict": "Saved CPU optics cannot combine with legacy temporary optical bindings. Save the reviewed optics or remove the conflicting temporary binding.",
|
||||
"render.material_raytrace_required": "Physical Glass requires CPU ray tracing or GPU path tracing.",
|
||||
"render.material_saved_edit_pending": "These optical edits are unsaved. Save reviewed values, remove the saved assignment, or discard the edits. Legacy temporary bindings and unsupported effects cannot override the saved target."
|
||||
},
|
||||
"he": {
|
||||
"render.appearance_editor.discard_saved_edit": "ביטול שינויים אופטיים שלא נשמרו",
|
||||
"render.cpu_subsurface_denoise_reason": "פיזור בתוך זכוכית ב־CPU דורש הסרת רעש כבויה. ההגדרות נשמרות.",
|
||||
"render.cpu_subsurface_help": "פיזור משנה את כיוון האור בתוך זכוכית סגורה. קצב חיובי דורש תאורה עקיפה והסרת רעש כבויה. הקצב הוא מספר התנגשויות למ״מ; 0 אינו מוסיף פיזור. הטיית כיוון חיובית מעדיפה קדימה, ושלילית אחורה.",
|
||||
"render.material_cpu_glass_only": "מפות זמניות מקובצי תמונה דורשות GPU. ב־CPU אפשר להשתמש במפות משטח מובנות שנשמרו.",
|
||||
"render.material_cpu_optics_limits": "יש להפעיל תאורה עקיפה לצורות זכוכית סגורות נתמכות, לקינון שנבדק ולפיזור בתוך הזכוכית. יריעות דקות יכולות להעביר אור לצללים. אחרת יש להשתמש ביריעה מישורית או בתיבה מקבילה לצירים. צורות נוגעות וצללים העוברים בזכוכית סגורה עדיין אינם נתמכים. הערכים נשמרים.",
|
||||
"render.material_cpu_saved_runtime_conflict": "אי אפשר לשלב הגדרות אופטיות שמורות ב־CPU עם הקישורים האופטיים הזמניים הישנים. יש לשמור את ההגדרות שנבדקו או להסיר את הקישור הזמני המתנגש.",
|
||||
"render.material_raytrace_required": "זכוכית פיזיקלית דורשת עקיבת קרניים ב־CPU או עקיבת נתיבים ב־GPU.",
|
||||
"render.material_saved_edit_pending": "השינויים האופטיים האלה לא נשמרו. יש לשמור את הערכים שנבדקו, להסיר את ההקצאה השמורה או לבטל את השינויים. קישורים זמניים ישנים ואפקטים שאינם נתמכים אינם יכולים לעקוף את היעד השמור."
|
||||
},
|
||||
"ja": {
|
||||
"render.appearance_editor.discard_saved_edit": "未保存の光学設定の変更を破棄",
|
||||
"render.cpu_subsurface_denoise_reason": "CPUの内部散乱ではノイズ除去をオフにしてください。設定値は保持されます。",
|
||||
"render.cpu_subsurface_help": "散乱は閉じたガラスの内部で光の方向を変えます。正の値には間接照明を使い、ノイズ除去をオフにします。率は1mmあたりの衝突回数です。0では散乱を加えません。方向の偏りが正なら前方、負なら後方を優先します。",
|
||||
"render.material_cpu_glass_only": "一時画像ファイルのマップにはGPUが必要です。CPUでは保存済みの組み込み表面マップを使用できます。",
|
||||
"render.material_cpu_optics_limits": "対応する閉じたガラス形状、検証済みの入れ子形状、内部散乱には間接照明をオンにしてください。薄いシートは影に光を透過できます。それ以外は平面シートか軸平行の箱を使います。接触する形状と閉じたガラスを通る透過影には未対応です。値は保持されます。",
|
||||
"render.material_cpu_saved_runtime_conflict": "保存済みCPU光学設定と一時的な光学割り当ては併用できません。確認した設定を保存するか、競合する一時割り当てを削除してください。",
|
||||
"render.material_raytrace_required": "物理ガラスにはCPUレイトレーシングまたはGPUパストレーシングが必要です。",
|
||||
"render.material_saved_edit_pending": "光学設定の変更は未保存です。確認した値を保存するか、保存済み割り当てを削除するか、変更を破棄してください。一時割り当てや未対応の効果で保存済み対象を上書きできません。"
|
||||
},
|
||||
"ko": {
|
||||
"render.appearance_editor.discard_saved_edit": "저장하지 않은 광학 편집 버리기",
|
||||
"render.cpu_subsurface_denoise_reason": "CPU 내부 산란을 사용하려면 노이즈 제거를 끄세요. 설정은 유지됩니다.",
|
||||
"render.cpu_subsurface_help": "산란은 닫힌 유리 내부에서 빛의 방향을 바꿉니다. 양수 비율에는 간접광을 사용하고 노이즈 제거를 끄세요. 비율은 mm당 충돌 횟수이며, 0은 산란을 추가하지 않습니다. 방향 편향이 양수이면 전방, 음수이면 후방을 선호합니다.",
|
||||
"render.material_cpu_glass_only": "임시 이미지 파일 맵에는 GPU가 필요합니다. CPU는 저장된 내장 표면 맵을 사용할 수 있습니다.",
|
||||
"render.material_cpu_optics_limits": "지원되는 닫힌 유리 형상, 검증된 중첩 및 내부 산란에는 간접 조명을 켜세요. 얇은 시트는 그림자에 빛을 통과시킬 수 있습니다. 그 외에는 평면 시트나 축 정렬 상자를 사용하세요. 서로 닿는 형상과 닫힌 유리를 통과하는 투과 그림자는 아직 지원하지 않습니다. 값은 유지됩니다.",
|
||||
"render.material_cpu_saved_runtime_conflict": "저장된 CPU 광학 설정은 임시 광학 바인딩과 함께 사용할 수 없습니다. 검토한 설정을 저장하거나 충돌하는 임시 바인딩을 제거하세요.",
|
||||
"render.material_raytrace_required": "물리 유리에는 CPU 레이 트레이싱이나 GPU 패스 트레이싱이 필요합니다.",
|
||||
"render.material_saved_edit_pending": "광학 편집을 아직 저장하지 않았습니다. 검토한 값을 저장하거나 저장된 할당을 제거하거나 편집을 버리세요. 임시 바인딩과 지원하지 않는 효과는 저장된 대상을 덮어쓸 수 없습니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.appearance_editor.discard_saved_edit": "Hủy chỉnh sửa quang học chưa lưu",
|
||||
"render.cpu_subsurface_denoise_reason": "Tán xạ bên trong kính trên CPU cần tắt Khử nhiễu. Các cài đặt được giữ nguyên.",
|
||||
"render.cpu_subsurface_help": "Tán xạ đổi hướng ánh sáng bên trong kính kín. Tốc độ dương cần Chiếu sáng gián tiếp và tắt Khử nhiễu. Tốc độ là số va chạm mỗi mm; 0 không thêm tán xạ. Độ lệch hướng dương ưu tiên phía trước, âm ưu tiên phía sau.",
|
||||
"render.material_cpu_glass_only": "Bản đồ từ tệp ảnh tạm thời yêu cầu GPU. CPU có thể dùng bản đồ bề mặt tích hợp đã lưu.",
|
||||
"render.material_cpu_optics_limits": "Bật Chiếu sáng gián tiếp cho hình kính kín được hỗ trợ, cấu trúc lồng nhau đã kiểm chứng và tán xạ bên trong kính. Tấm mỏng có thể truyền ánh sáng cho bóng. Nếu không, dùng tấm phẳng hoặc hộp thẳng theo trục. Chưa hỗ trợ hình chạm nhau và bóng truyền qua kính kín. Các giá trị được giữ nguyên.",
|
||||
"render.material_cpu_saved_runtime_conflict": "Không thể kết hợp quang học CPU đã lưu với các liên kết quang học tạm thời kiểu cũ. Hãy lưu quang học đã xem xét hoặc xóa liên kết tạm thời xung đột.",
|
||||
"render.material_raytrace_required": "Kính vật lý yêu cầu dò tia trên CPU hoặc dò đường đi trên GPU.",
|
||||
"render.material_saved_edit_pending": "Các chỉnh sửa quang học này chưa được lưu. Hãy lưu các giá trị đã xem xét, xóa phép gán đã lưu hoặc hủy chỉnh sửa. Liên kết tạm thời kiểu cũ và hiệu ứng chưa được hỗ trợ không thể ghi đè đích đã lưu."
|
||||
},
|
||||
"zh": {
|
||||
"render.appearance_editor.discard_saved_edit": "放弃未保存的光学编辑",
|
||||
"render.cpu_subsurface_denoise_reason": "CPU 内部散射需要关闭降噪。设置会保留。",
|
||||
"render.cpu_subsurface_help": "散射会改变闭合玻璃内部的光线方向。正散射率需要开启间接光照并关闭降噪。散射率表示每毫米的碰撞次数;0 不增加散射。方向偏置为正时偏向前方,为负时偏向后方。",
|
||||
"render.material_cpu_glass_only": "临时图像文件贴图需要GPU。CPU可以使用已保存的内置表面贴图。",
|
||||
"render.material_cpu_optics_limits": "启用间接光照可使用受支持的闭合玻璃形状、经验证的嵌套结构及内部散射。薄片可以透射阴影。否则请使用平面薄片或轴对齐盒体。仍不支持相互接触的形状及穿过闭合玻璃的透射阴影。设置值会保留。",
|
||||
"render.material_cpu_saved_runtime_conflict": "已保存的CPU光学设置不能与临时光学绑定组合。请保存已审阅的设置或移除冲突的临时绑定。",
|
||||
"render.material_raytrace_required": "物理玻璃需要CPU光线追踪或GPU路径追踪。",
|
||||
"render.material_saved_edit_pending": "这些光学编辑尚未保存。请保存已审阅的值、移除已保存的分配或放弃编辑。临时绑定和不支持的效果不能覆盖已保存的目标。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.cpu_maps_saved_help": "يمكن لتصيير CPU استخدام خرائط الأسطح المعتمة المحفوظة: اختر سطحًا هنا ثم احفظ خرائطه في اللقطة. مع الإضاءة غير المباشرة، يمكن استخدام الزجاج في جسم آخر؛ لا يمكن استخدام الزجاج في الجسم ذي الخرائط. يجب إيقاف الانعكاسات المختارة والتباين الاتجاهي والتأثيرات الإجرائية. تتطلب مسارات ملفات الخرائط المؤقتة GPU."
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_maps_saved_help": "CPU-Renderings können gespeicherte Karten undurchsichtiger Flächen verwenden: Oberfläche wählen und die Karten in der Aufnahme speichern. Mit indirekter Beleuchtung darf ein anderer Körper Glas verwenden; der Körper mit Karten darf kein Glas sein. Ausgewählte Reflexionen, Anisotropie und prozedurale Effekte ausschalten. Temporäre Kartendateipfade verwenden die GPU."
|
||||
},
|
||||
"en": {
|
||||
"render.cpu_maps_saved_help": "CPU renders can use saved opaque maps: choose a surface here, then Save maps to shot. With Indirect lighting, Glass can be on another body; a mapped body cannot use Glass. Selected reflections, anisotropy and procedural surface effects must be off. Temporary map file paths use GPU."
|
||||
},
|
||||
"es": {
|
||||
"render.cpu_maps_saved_help": "Los renders CPU pueden usar mapas opacos guardados: elige una superficie y guarda sus mapas en la toma. Con Iluminación indirecta, otro cuerpo puede usar Vidrio; el cuerpo con mapas no puede usar Vidrio. Desactiva los reflejos seleccionados, la anisotropía y los efectos procedurales. Las rutas temporales de archivos de mapas usan GPU."
|
||||
},
|
||||
"fr": {
|
||||
"render.cpu_maps_saved_help": "Le rendu CPU accepte les cartes opaques enregistrées : choisissez une surface, puis enregistrez ses cartes dans la vue. Avec l’éclairage indirect, un autre corps peut utiliser Verre ; le corps avec les cartes ne le peut pas. Désactivez les réflexions sélectionnées, l’anisotropie et les effets procéduraux. Les fichiers temporaires de cartes utilisent le GPU."
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_maps_saved_help": "רינדור CPU יכול להשתמש במפות אטומות שמורות: בחרו כאן משטח ושמרו את המפות בתצלום. עם תאורה עקיפה, גוף אחר יכול להשתמש בזכוכית; הגוף בעל המפות אינו יכול להשתמש בזכוכית. יש לכבות החזרות שנבחרו, אנאיזוטרופיה ואפקטים פרוצדורליים. נתיבים זמניים לקובצי מפות משתמשים ב־GPU."
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_maps_saved_help": "CPUレンダリングは保存された不透明サーフェスマップを使えます。サーフェスを選び、ショットにマップを保存してください。間接光を使う場合、別のボディにはガラスを使えますが、マップを持つボディには使えません。選択した反射、異方性、プロシージャル効果はオフにします。一時的なマップファイルにはGPUを使います。"
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_maps_saved_help": "CPU 렌더링은 저장한 불투명 표면 맵을 사용할 수 있습니다. 표면을 선택하고 샷에 맵을 저장하세요. 간접광을 사용하면 다른 바디에 유리를 사용할 수 있지만, 맵이 있는 바디에는 유리를 사용할 수 없습니다. 선택 반사, 비등방성 및 절차적 효과는 끄세요. 임시 맵 파일 경로는 GPU를 사용합니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_maps_saved_help": "Kết xuất CPU hỗ trợ bản đồ bề mặt đục đã lưu: chọn bề mặt rồi lưu bản đồ vào ảnh chụp. Với chiếu sáng gián tiếp, thân khác có thể dùng kính; thân có bản đồ không thể dùng kính. Tắt phản xạ được chọn, dị hướng và hiệu ứng thủ tục. Đường dẫn tạm tới tệp bản đồ dùng GPU."
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_maps_saved_help": "CPU 渲染可使用已保存的不透明表面贴图:在此选择表面,再将贴图保存到镜头。使用间接光照时,其他实体可以使用玻璃;带有贴图的实体不能使用玻璃。请关闭选定的反射、各向异性和程序化表面效果。临时贴图文件路径使用 GPU。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.cpu_denoise_help": "يقلل التشويش في كل تحديث للصورة. قد ينعّم التفاصيل الدقيقة وحواف عمق المجال. يبقى EXR الخام دون تغيير. تستخدم تعديلات الصورة المكتملة صورتها الخام ولا تطبّق تقليل التشويش مرة أخرى."
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_denoise_help": "Verringert das Rauschen bei jeder Bildaktualisierung. Feine Details und Übergänge der Tiefenschärfe können weicher werden. Die EXR-Rohdaten bleiben unverändert. Nachträgliche Anpassungen eines fertigen Bildes verwenden dessen Rohbild und wenden die Rauschreduzierung nicht erneut an."
|
||||
},
|
||||
"en": {
|
||||
"render.cpu_denoise_help": "Reduces noise in every image update. It may soften fine detail and depth-of-field edges. Raw EXR is unchanged. Adjusting a completed image uses its raw image and does not apply noise reduction again."
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_denoise_help": "מפחיתה רעש בכל עדכון תמונה. היא עלולה לרכך פרטים עדינים וקצוות של עומק שדה. קובץ EXR הגולמי אינו משתנה. התאמות של תמונה שהושלמה משתמשות בתמונה הגולמית שלה ואינן מחילות שוב הפחתת רעש."
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_denoise_help": "画像が更新されるたびにノイズを減らします。細部や被写界深度によるぼけの境界が柔らかくなる場合があります。RAW EXRは変更されません。完成画像の調整にはそのRAW画像を使用し、ノイズ低減は再適用しません。"
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_denoise_help": "이미지가 갱신될 때마다 노이즈를 줄입니다. 미세한 디테일과 피사계 심도 경계가 부드러워질 수 있습니다. 원본 EXR은 바뀌지 않습니다. 완성된 이미지를 조정할 때는 해당 원본 이미지를 사용하며 노이즈 감소를 다시 적용하지 않습니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_denoise_help": "Giảm nhiễu mỗi khi ảnh được cập nhật. Có thể làm mềm chi tiết nhỏ và biên vùng mờ do độ sâu trường ảnh. EXR thô không thay đổi. Khi điều chỉnh ảnh đã hoàn tất, hệ thống dùng ảnh thô của ảnh đó và không áp dụng giảm nhiễu lần nữa."
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_denoise_help": "每次图像更新时减少噪点,可能使细节和景深模糊边缘变柔。原始EXR保持不变。调整已完成的图像时使用其原始图像,不会再次降噪。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.cpu_studio_enable": "تفعيل الإضاءة غير المباشرة لهذا التوزيع",
|
||||
"render.cpu_studio_enable_apply": "تفعيل الإضاءة غير المباشرة وتطبيقها",
|
||||
"render.cpu_studio_indirect_help": "يحتاج توزيع الشمس هذا إلى إضاءة غير مباشرة بعمق 8 وظلال متتبعة وعينة كاميرا واحدة لكل مرور. إذا كان التصيير التدريجي معطلاً، يبدأ بـ 16 عينة لكل بكسل وبذرة 0. تُحفظ الإعدادات التدريجية الحالية وملف الموارد. تمنع الإعدادات غير المتوافقة التطبيق دون تغييرها.",
|
||||
"render.cpu_studio_radiance_limit": "سطوع المصدر المساحي في هذا التوزيع هو {radiance}، ويتجاوز حد CPU البالغ 64. تُحفظ الإضاءة المؤلَّفة؛ اختر توزيعاً آخر أو GPU."
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_studio_enable": "Indirekte Beleuchtung für dieses Lichtset aktivieren",
|
||||
"render.cpu_studio_enable_apply": "Indirekte Beleuchtung aktivieren und anwenden",
|
||||
"render.cpu_studio_indirect_help": "Dieses Sonnen-Lichtset benötigt indirekte Beleuchtung mit Tiefe 8, verfolgte Schatten und eine Kamerastichprobe pro Durchlauf. Ist progressives Rendern ausgeschaltet, beginnt es mit 16 Stichproben pro Pixel und Startwert 0. Vorhandene progressive Einstellungen und das Ressourcenprofil bleiben erhalten. Unvereinbare Einstellungen verhindern die Anwendung, ohne sie zu ändern.",
|
||||
"render.cpu_studio_radiance_limit": "Dieses Lichtset hat eine Flächenstrahldichte von {radiance}, über der CPU-Grenze von 64. Die vorgegebene Beleuchtung bleibt erhalten; wählen Sie ein anderes Lichtset oder GPU."
|
||||
},
|
||||
"en": {
|
||||
"render.cpu_studio_enable": "Enable indirect lighting for this rig",
|
||||
"render.cpu_studio_enable_apply": "Enable indirect lighting and apply",
|
||||
"render.cpu_studio_indirect_help": "This Sun rig needs indirect lighting at depth 8, traced shadows and one camera sample per pass. If progressive rendering is off, it starts at 16 samples per pixel with seed 0. Existing progressive settings and the resource profile are kept. Incompatible settings stop the apply without changing them.",
|
||||
"render.cpu_studio_radiance_limit": "This rig has area radiance {radiance}, above the CPU limit of 64. Its authored lighting is kept; choose another rig or GPU."
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_studio_enable": "הפעלת תאורה עקיפה עבור מערך זה",
|
||||
"render.cpu_studio_enable_apply": "הפעלת תאורה עקיפה והחלה",
|
||||
"render.cpu_studio_indirect_help": "מערך השמש הזה דורש תאורה עקיפה בעומק 8, צללים מחושבים ודגימת מצלמה אחת בכל מעבר. אם רינדור הדרגתי כבוי, הוא מתחיל ב־16 דגימות לפיקסל עם זרע 0. הגדרות הדרגתיות קיימות ופרופיל המשאבים נשמרים. הגדרות שאינן תואמות עוצרות את ההחלה בלי לשנותן.",
|
||||
"render.cpu_studio_radiance_limit": "עוצמת מקור השטח במערך זה היא {radiance}, מעל מגבלת CPU של 64. התאורה המקורית נשמרת; בחרו מערך אחר או GPU."
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_studio_enable": "このリグで間接照明を有効にする",
|
||||
"render.cpu_studio_enable_apply": "間接照明を有効にして適用",
|
||||
"render.cpu_studio_indirect_help": "この太陽リグには深度8の間接照明、追跡した影、パスごとに1つのカメラサンプルが必要です。プログレッシブ描画がオフなら、ピクセルごと16サンプル、シード0で開始します。既存のプログレッシブ設定とリソースプロファイルは維持します。非対応の設定がある場合は変更せず適用を中止します。",
|
||||
"render.cpu_studio_radiance_limit": "このリグの面光源の放射輝度は{radiance}で、CPU上限64を超えます。作成された照明は維持します。別のリグかGPUを選んでください。"
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_studio_enable": "이 리그의 간접 조명 켜기",
|
||||
"render.cpu_studio_enable_apply": "간접 조명을 켜고 적용",
|
||||
"render.cpu_studio_indirect_help": "이 태양 리그에는 깊이 8의 간접 조명, 추적된 그림자, 패스당 카메라 샘플 1개가 필요합니다. 점진적 렌더링이 꺼져 있으면 픽셀당 샘플 16개와 시드 0으로 시작합니다. 기존 점진적 설정과 리소스 프로필은 유지됩니다. 호환되지 않는 설정은 변경하지 않고 적용을 중단합니다.",
|
||||
"render.cpu_studio_radiance_limit": "이 리그의 면 광원 방사 휘도 {radiance}은(는) CPU 한도 64를 넘습니다. 작성된 조명은 유지됩니다. 다른 리그나 GPU를 선택하세요."
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_studio_enable": "Bật chiếu sáng gián tiếp cho bộ đèn này",
|
||||
"render.cpu_studio_enable_apply": "Bật chiếu sáng gián tiếp và áp dụng",
|
||||
"render.cpu_studio_indirect_help": "Bộ đèn Mặt Trời này cần chiếu sáng gián tiếp với độ sâu 8, bóng được truy vết và một mẫu máy ảnh mỗi lượt. Nếu kết xuất tăng dần đang tắt, bắt đầu với 16 mẫu mỗi điểm ảnh và hạt giống 0. Giữ nguyên thiết lập tăng dần và hồ sơ tài nguyên hiện có. Thiết lập không tương thích sẽ ngăn áp dụng mà không thay đổi chúng.",
|
||||
"render.cpu_studio_radiance_limit": "Độ chói bức xạ đèn diện tích của bộ này là {radiance}, vượt giới hạn CPU 64. Giữ nguyên ánh sáng đã tạo; hãy chọn bộ đèn khác hoặc GPU."
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_studio_enable": "为此灯组启用间接光照",
|
||||
"render.cpu_studio_enable_apply": "启用间接光照并应用",
|
||||
"render.cpu_studio_indirect_help": "此太阳灯组需要深度为8的间接光照、追踪阴影,以及每轮一个相机采样。如果渐进渲染关闭,则以每像素16个采样和种子0开始。保留现有渐进设置和资源配置。不兼容的设置会阻止应用,且不会被更改。",
|
||||
"render.cpu_studio_radiance_limit": "此灯组的面光源辐射亮度为{radiance},超过CPU上限64。保留原有灯光,请选择其他灯组或GPU。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.cpu_celestial_adopt": "تتبع الأشعة على المعالج مع إضاءة غير مباشرة · عمق المسار {depth} · {spp} عينة لكل بكسل · ظلال متتبعة · بذرة {seed} · عينة أولية واحدة",
|
||||
"render.cpu_celestial_adopt_label": "تتبع الأشعة على المعالج مع إضاءة غير مباشرة",
|
||||
"render.cpu_celestial_path_required": "تستخدم الشمس المحدودة والسماء المتدرجة الإضاءة غير المباشرة على المعالج.",
|
||||
"render.cpu_celestial_switch": "اختر تتبع الأشعة على المعالج مع الإضاءة غير المباشرة للاحتفاظ بهذه الشمس أو السماء.",
|
||||
"render.cpu_celestial_unportable": "أبقِ تتبع المسارات على المعالج الرسومي لإعدادات عينات النفاذ الحالية.",
|
||||
"render.cpu_sky_bounds": "يجب أن يكون دوران السماء محدودًا ضمن ±1,000,000 راديان، وأن تكون الشدة بين 0 و100."
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_celestial_adopt": "CPU-Raytracing mit indirekter Beleuchtung · Pfadtiefe {depth} · {spp} SPP · verfolgte Schatten · Seed {seed} · 1 Primärsample",
|
||||
"render.cpu_celestial_adopt_label": "CPU-Raytracing mit indirekter Beleuchtung",
|
||||
"render.cpu_celestial_path_required": "Endliche Sonne und Verlaufshimmel verwenden indirekte CPU-Beleuchtung.",
|
||||
"render.cpu_celestial_switch": "Wähle CPU-Raytracing mit indirekter Beleuchtung, um diese Sonne oder diesen Himmel beizubehalten.",
|
||||
"render.cpu_celestial_unportable": "Behalte GPU-Pathtracing für die aktuellen Transmissions-Sample-Einstellungen bei.",
|
||||
"render.cpu_sky_bounds": "Die Himmelsrotation muss endlich innerhalb ±1.000.000 Radiant liegen; die Intensität muss 0–100 sein."
|
||||
},
|
||||
"en": {
|
||||
"render.cpu_celestial_adopt": "CPU ray tracing with indirect lighting · path depth {depth} · {spp} SPP · traced shadows · seed {seed} · 1 primary sample",
|
||||
"render.cpu_celestial_adopt_label": "CPU ray tracing with indirect lighting",
|
||||
"render.cpu_celestial_path_required": "Finite Sun and gradient Sky use CPU indirect lighting.",
|
||||
"render.cpu_celestial_switch": "Choose CPU ray tracing with indirect lighting to keep this Sun or Sky.",
|
||||
"render.cpu_celestial_unportable": "Keep GPU path tracing for the current transmission sample settings.",
|
||||
"render.cpu_sky_bounds": "Sky rotation must be finite within ±1,000,000 radians; intensity must be 0–100."
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_celestial_adopt": "מעקב קרניים במעבד עם תאורה עקיפה · עומק מסלול {depth} · {spp} דגימות לפיקסל · צללים במעקב · זרע {seed} · דגימה ראשית אחת",
|
||||
"render.cpu_celestial_adopt_label": "מעקב קרניים במעבד עם תאורה עקיפה",
|
||||
"render.cpu_celestial_path_required": "שמש בעלת גודל סופי ושמיים מדורגים משתמשים בתאורה עקיפה במעבד.",
|
||||
"render.cpu_celestial_switch": "כדי לשמור על השמש או השמיים האלה, יש לבחור מעקב קרניים במעבד עם תאורה עקיפה.",
|
||||
"render.cpu_celestial_unportable": "יש לשמור על מעקב מסלולים במעבד הגרפי עבור הגדרות דגימות ההעברה הנוכחיות.",
|
||||
"render.cpu_sky_bounds": "סיבוב השמיים חייב להיות סופי בטווח ±1,000,000 רדיאנים, והעוצמה חייבת להיות בטווח 0–100."
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_celestial_adopt": "間接照明付き CPU レイトレーシング · パス深度 {depth} · {spp} SPP · 追跡シャドウ · シード {seed} · プライマリサンプル1個",
|
||||
"render.cpu_celestial_adopt_label": "間接照明付き CPU レイトレーシング",
|
||||
"render.cpu_celestial_path_required": "有限の太陽とグラデーション空は CPU の間接照明を使用します。",
|
||||
"render.cpu_celestial_switch": "この太陽や空を維持するには、間接照明を使用する CPU レイトレーシングを選択してください。",
|
||||
"render.cpu_celestial_unportable": "現在の透過サンプル設定には GPU パストレーシングを使用してください。",
|
||||
"render.cpu_sky_bounds": "空の回転は ±1,000,000 ラジアン以内の有限値、強度は 0~100 にしてください。"
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_celestial_adopt": "간접 조명을 사용하는 CPU 레이 트레이싱 · 경로 깊이 {depth} · {spp} SPP · 추적 그림자 · 시드 {seed} · 기본 샘플1개",
|
||||
"render.cpu_celestial_adopt_label": "간접 조명을 사용하는 CPU 레이 트레이싱",
|
||||
"render.cpu_celestial_path_required": "유한 태양과 그라데이션 하늘은 CPU 간접 조명을 사용합니다.",
|
||||
"render.cpu_celestial_switch": "이 태양 또는 하늘을 유지하려면 간접 조명을 사용하는 CPU 레이 트레이싱을 선택하세요.",
|
||||
"render.cpu_celestial_unportable": "현재 투과 샘플 설정에는 GPU 경로 추적을 유지하세요.",
|
||||
"render.cpu_sky_bounds": "하늘 회전은 ±1,000,000 라디안 이내의 유한 값이어야 하며 강도는 0~100이어야 합니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_celestial_adopt": "Dò tia CPU với chiếu sáng gián tiếp · độ sâu đường đi {depth} · {spp} SPP · bóng đổ dò tia · seed {seed} · 1 mẫu chính",
|
||||
"render.cpu_celestial_adopt_label": "Dò tia CPU với chiếu sáng gián tiếp",
|
||||
"render.cpu_celestial_path_required": "Mặt Trời hữu hạn và bầu trời chuyển sắc dùng chiếu sáng gián tiếp CPU.",
|
||||
"render.cpu_celestial_switch": "Chọn dò tia CPU với chiếu sáng gián tiếp để giữ Mặt Trời hoặc bầu trời này.",
|
||||
"render.cpu_celestial_unportable": "Giữ dò đường đi GPU cho cài đặt mẫu truyền sáng hiện tại.",
|
||||
"render.cpu_sky_bounds": "Góc xoay bầu trời phải hữu hạn trong ±1.000.000 radian; cường độ phải từ 0–100."
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_celestial_adopt": "CPU 光线追踪与间接照明 · 路径深度 {depth} · {spp} SPP · 追踪阴影 · 种子 {seed} · 1 个主样本",
|
||||
"render.cpu_celestial_adopt_label": "CPU 光线追踪与间接照明",
|
||||
"render.cpu_celestial_path_required": "有限太阳和渐变天空使用 CPU 间接照明。",
|
||||
"render.cpu_celestial_switch": "选择带间接照明的 CPU 光线追踪,以保留此太阳或天空。",
|
||||
"render.cpu_celestial_unportable": "当前透射采样设置请继续使用 GPU 路径追踪。",
|
||||
"render.cpu_sky_bounds": "天空旋转必须为 ±1,000,000 弧度内的有限值;强度必须为 0–100。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.shot_lens_cpu_progressive_reason": "فعّل أخذ العينات التدريجي في قسم «أخذ العينات» قبل تمكين عمق المجال على CPU.",
|
||||
"render.shot_lens_raytrace_reason": "يتوفر عمق المجال مع تتبع الأشعة على CPU وتتبع المسارات على GPU."
|
||||
},
|
||||
"de": {
|
||||
"render.shot_lens_cpu_progressive_reason": "Vor der CPU-Schärfentiefe unter Sampling die progressive Abtastung übernehmen.",
|
||||
"render.shot_lens_raytrace_reason": "Schärfentiefe ist für CPU-Raytracing und GPU-Pathtracing verfügbar."
|
||||
},
|
||||
"en": {
|
||||
"render.shot_lens_cpu_progressive_reason": "Adopt progressive sampling in Sampling before enabling CPU depth of field.",
|
||||
"render.shot_lens_raytrace_reason": "Depth of field is available for CPU ray tracing and GPU path tracing."
|
||||
},
|
||||
"he": {
|
||||
"render.shot_lens_cpu_progressive_reason": "יש להפעיל דגימה הדרגתית בחלונית הדגימה לפני הפעלת עומק שדה ב־CPU.",
|
||||
"render.shot_lens_raytrace_reason": "עומק שדה זמין בעקיבת קרניים ב־CPU ובעקיבת נתיבים ב־GPU."
|
||||
},
|
||||
"ja": {
|
||||
"render.shot_lens_cpu_progressive_reason": "CPUの被写界深度を有効にするには、サンプリングでプログレッシブ方式を適用してください。",
|
||||
"render.shot_lens_raytrace_reason": "被写界深度はCPUレイトレーシングとGPUパストレーシングで利用できます。"
|
||||
},
|
||||
"ko": {
|
||||
"render.shot_lens_cpu_progressive_reason": "CPU 피사계 심도를 켜기 전에 샘플링에서 점진적 샘플링을 적용하세요.",
|
||||
"render.shot_lens_raytrace_reason": "피사계 심도는 CPU 레이 트레이싱과 GPU 패스 트레이싱에서 사용할 수 있습니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.shot_lens_cpu_progressive_reason": "Bật lấy mẫu lũy tiến trong mục Lấy mẫu trước khi bật độ sâu trường ảnh trên CPU.",
|
||||
"render.shot_lens_raytrace_reason": "Độ sâu trường ảnh khả dụng với dò tia trên CPU và dò đường đi trên GPU."
|
||||
},
|
||||
"zh": {
|
||||
"render.shot_lens_cpu_progressive_reason": "启用CPU景深前,请在采样中应用渐进式采样。",
|
||||
"render.shot_lens_raytrace_reason": "CPU光线追踪和GPU路径追踪均支持景深。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.cpu_transparent_help": "يزيل PNG الشفاف الخلفية ويحافظ على تغطية الحواف. يجب أن تبقى عتامة الأجسام والأوجه عند 100%."
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_transparent_help": "Transparentes PNG entfernt den Hintergrund und erhält die Kantenabdeckung. Körper- und Flächendeckkraft müssen 100 % bleiben."
|
||||
},
|
||||
"en": {
|
||||
"render.cpu_transparent_help": "Transparent PNG removes the background and keeps edge coverage. Body and face opacity must stay at 100%."
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_transparent_help": "PNG שקוף מסיר את הרקע ושומר על כיסוי הקצוות. אטימות הגופים והפאות חייבת להישאר 100%."
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_transparent_help": "透明PNGは背景を除き、輪郭の被覆率を保持します。ボディと面の不透明度は100%にしてください。"
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_transparent_help": "투명 PNG는 배경을 제거하고 가장자리 커버리지를 유지합니다. 바디와 면의 불투명도는 100%여야 합니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_transparent_help": "PNG trong suốt loại bỏ nền và giữ độ phủ ở biên. Độ đục của thân và mặt phải giữ ở 100%."
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_transparent_help": "透明PNG移除背景并保留边缘覆盖率。实体和面的不透明度必须保持100%。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.directional_sun_limit": "تدعم إضاءة GPU ثمانية مصادر ضوء اتجاهية إجمالاً، من بينها شمس واحدة بظلال ناعمة كحد أقصى. انسخ ضوءاً اتجاهياً بظلال حادة لإضافة مصدر بعيد آخر."
|
||||
},
|
||||
"de": {
|
||||
"render.directional_sun_limit": "Die GPU unterstützt insgesamt acht gerichtete Lichtquellen, darunter höchstens eine weiche Sonne. Dupliziere ein hartes gerichtetes Licht für eine weitere entfernte Quelle."
|
||||
},
|
||||
"en": {
|
||||
"render.directional_sun_limit": "GPU lighting supports eight directional sources in total, including at most one soft Sun. Duplicate a hard directional light to add another distant source."
|
||||
},
|
||||
"he": {
|
||||
"render.directional_sun_limit": "תאורת GPU תומכת בשמונה מקורות אור כיווניים בסך הכול, ובהם לכל היותר שמש אחת בעלת צללים רכים. שכפלו אור כיווני בעל צללים חדים כדי להוסיף מקור מרוחק נוסף."
|
||||
},
|
||||
"ja": {
|
||||
"render.directional_sun_limit": "GPUでは指向性光源を合計8個まで使用でき、柔らかい影の太陽は1個までです。遠方の光源を追加するには、硬い影の指向性ライトを複製してください。"
|
||||
},
|
||||
"ko": {
|
||||
"render.directional_sun_limit": "GPU는 부드러운 태양 하나를 포함해 방향성 광원을 총 8개까지 지원합니다. 원거리 광원을 추가하려면 선명한 그림자의 방향성 조명을 복제하세요."
|
||||
},
|
||||
"vi": {
|
||||
"render.directional_sun_limit": "GPU hỗ trợ tổng cộng tám nguồn sáng định hướng, trong đó có tối đa một Mặt trời với bóng mềm. Nhân đôi đèn định hướng có bóng sắc để thêm nguồn sáng ở xa."
|
||||
},
|
||||
"zh": {
|
||||
"render.directional_sun_limit": "GPU 总共支持八个平行光源,其中最多一个为柔和太阳光。复制硬阴影平行光可添加另一个远处光源。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.gpu_path_not_started": "لم يبدأ إعداد متتبع المسارات على المعالج الرسومي بعد."
|
||||
},
|
||||
"de": {
|
||||
"render.gpu_path_not_started": "Die Einrichtung des GPU-Pfadtracers wurde noch nicht gestartet."
|
||||
},
|
||||
"en": {
|
||||
"render.gpu_path_not_started": "GPU path tracer setup has not started."
|
||||
},
|
||||
"he": {
|
||||
"render.gpu_path_not_started": "הגדרת מעקב המסלולים ב-GPU טרם התחילה."
|
||||
},
|
||||
"ja": {
|
||||
"render.gpu_path_not_started": "GPUパストレーサーのセットアップはまだ開始されていません。"
|
||||
},
|
||||
"ko": {
|
||||
"render.gpu_path_not_started": "GPU 경로 추적기 설정이 아직 시작되지 않았습니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.gpu_path_not_started": "Quá trình thiết lập bộ dò đường đi GPU chưa bắt đầu."
|
||||
},
|
||||
"zh": {
|
||||
"render.gpu_path_not_started": "GPU路径追踪器的设置尚未开始。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.spot_axis": "اتجاه انبعاث الضوء",
|
||||
"render.spot_gpu_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية. تتطلب نطاقات التلاشي الضيقة جداً التصيير باستخدام CPU.",
|
||||
"render.spot_help": "تُقاس الزوايا من اتجاه انبعاث الضوء. يتلاشى الضوء بين الزاويتين الداخلية والخارجية.",
|
||||
"render.spot_inner": "نصف الزاوية الداخلية",
|
||||
"render.spot_outer": "نصف الزاوية الخارجية"
|
||||
},
|
||||
"de": {
|
||||
"render.spot_axis": "Abstrahlrichtung",
|
||||
"render.spot_gpu_help": "Die Winkel werden von der Abstrahlrichtung aus gemessen. Zwischen innerem und äußerem Winkel wird das Licht schwächer. Sehr schmale Übergänge erfordern CPU-Rendering.",
|
||||
"render.spot_help": "Die Winkel werden von der Abstrahlrichtung aus gemessen. Zwischen innerem und äußerem Winkel wird das Licht schwächer.",
|
||||
"render.spot_inner": "Innerer Halbwinkel",
|
||||
"render.spot_outer": "Äußerer Halbwinkel"
|
||||
},
|
||||
"en": {
|
||||
"render.spot_axis": "Emission direction",
|
||||
"render.spot_gpu_help": "Angles are measured from the emission direction. Light fades between the inner and outer angles. Very narrow fade ranges require CPU rendering.",
|
||||
"render.spot_help": "Angles are measured from the emission direction. Light fades between the inner and outer angles.",
|
||||
"render.spot_inner": "Inner half-angle",
|
||||
"render.spot_outer": "Outer half-angle"
|
||||
},
|
||||
"he": {
|
||||
"render.spot_axis": "כיוון פליטת האור",
|
||||
"render.spot_gpu_help": "הזוויות נמדדות מכיוון פליטת האור. האור דועך בין הזווית הפנימית לחיצונית. טווחי דעיכה צרים מאוד דורשים רינדור באמצעות CPU.",
|
||||
"render.spot_help": "הזוויות נמדדות מכיוון פליטת האור. האור דועך בין הזווית הפנימית לחיצונית.",
|
||||
"render.spot_inner": "חצי זווית פנימית",
|
||||
"render.spot_outer": "חצי זווית חיצונית"
|
||||
},
|
||||
"ja": {
|
||||
"render.spot_axis": "光の照射方向",
|
||||
"render.spot_gpu_help": "角度は光の照射方向から測ります。内側の角度から外側の角度にかけて光が弱まります。減衰範囲が非常に狭い場合はCPUレンダリングが必要です。",
|
||||
"render.spot_help": "角度は光の照射方向から測ります。内側の角度から外側の角度にかけて光が弱まります。",
|
||||
"render.spot_inner": "内側の半角",
|
||||
"render.spot_outer": "外側の半角"
|
||||
},
|
||||
"ko": {
|
||||
"render.spot_axis": "빛의 방출 방향",
|
||||
"render.spot_gpu_help": "각도는 빛의 방출 방향을 기준으로 측정합니다. 안쪽 각도와 바깥쪽 각도 사이에서 빛이 약해집니다. 감쇠 범위가 매우 좁으면 CPU 렌더링이 필요합니다.",
|
||||
"render.spot_help": "각도는 빛의 방출 방향을 기준으로 측정합니다. 안쪽 각도와 바깥쪽 각도 사이에서 빛이 약해집니다.",
|
||||
"render.spot_inner": "안쪽 반각",
|
||||
"render.spot_outer": "바깥쪽 반각"
|
||||
},
|
||||
"vi": {
|
||||
"render.spot_axis": "Hướng phát sáng",
|
||||
"render.spot_gpu_help": "Các góc được đo từ hướng phát sáng. Ánh sáng yếu dần giữa góc trong và góc ngoài. Dải chuyển tiếp rất hẹp cần kết xuất bằng CPU.",
|
||||
"render.spot_help": "Các góc được đo từ hướng phát sáng. Ánh sáng yếu dần giữa góc trong và góc ngoài.",
|
||||
"render.spot_inner": "Nửa góc trong",
|
||||
"render.spot_outer": "Nửa góc ngoài"
|
||||
},
|
||||
"zh": {
|
||||
"render.spot_axis": "光线发射方向",
|
||||
"render.spot_gpu_help": "角度以光线发射方向为基准测量。光线在内角和外角之间逐渐减弱。极窄的衰减范围需要使用CPU渲染。",
|
||||
"render.spot_help": "角度以光线发射方向为基准测量。光线在内角和外角之间逐渐减弱。",
|
||||
"render.spot_inner": "内半角",
|
||||
"render.spot_outer": "外半角"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.adaptive.pilot_progress": "تمريرات تمهيدية تكيفية · {current}/{target}",
|
||||
"render.adaptive.preview_help": "توجّه الصور التمهيدية توزيع العينات ولا تدخل في الصورة النهائية. تستخدم تكرارات التصيير ميزانية منفصلة لكل بكسل؛ ولا يمثّل عدد التكرارات عددًا موحدًا من العينات لكل بكسل.",
|
||||
"render.adaptive.production_progress": "التصيير التكيفي · {current}/{target} تكرارات",
|
||||
"render.live.adaptive": "مباشر · {width} × {height} · {progress}"
|
||||
},
|
||||
"de": {
|
||||
"render.adaptive.pilot_progress": "Adaptive Pilotphase · {current}/{target} Durchgänge",
|
||||
"render.adaptive.preview_help": "Pilotbilder bestimmen die Verteilung und sind vom Endbild ausgeschlossen. Produktionsiterationen nutzen ein eigenes Budget pro Pixel; die Iterationszahl ist keine einheitliche Samplezahl pro Pixel.",
|
||||
"render.adaptive.production_progress": "Adaptive Produktion · {current}/{target} Iterationen",
|
||||
"render.live.adaptive": "Live · {width} × {height} · {progress}"
|
||||
},
|
||||
"en": {
|
||||
"render.adaptive.pilot_progress": "Adaptive pilots · {current}/{target} passes",
|
||||
"render.adaptive.preview_help": "Pilot images guide allocation and are excluded from the final image. Production iterations use a separate budget for each pixel; the iteration count is not uniform samples per pixel.",
|
||||
"render.adaptive.production_progress": "Adaptive production · {current}/{target} iterations",
|
||||
"render.live.adaptive": "Live · {width} × {height} · {progress}"
|
||||
},
|
||||
"he": {
|
||||
"render.adaptive.pilot_progress": "מעברים מקדימים אדפטיביים · {current}/{target}",
|
||||
"render.adaptive.preview_help": "התמונות המקדימות מנחות את הקצאת הדגימות ואינן נכללות בתמונה הסופית. איטרציות ההדמיה משתמשות בתקציב נפרד לכל פיקסל; מספר האיטרציות אינו מספר דגימות אחיד לכל פיקסל.",
|
||||
"render.adaptive.production_progress": "הדמיה אדפטיבית · {current}/{target} איטרציות",
|
||||
"render.live.adaptive": "חי · {width} × {height} · {progress}"
|
||||
},
|
||||
"ja": {
|
||||
"render.adaptive.pilot_progress": "適応パイロット · {current}/{target} パス",
|
||||
"render.adaptive.preview_help": "パイロット画像は配分の決定に使われ、最終画像には含まれません。本処理はピクセルごとに別の予算を使うため、反復回数は全ピクセル共通のサンプル数ではありません。",
|
||||
"render.adaptive.production_progress": "適応本処理 · {current}/{target} 回",
|
||||
"render.live.adaptive": "ライブ · {width} × {height} · {progress}"
|
||||
},
|
||||
"ko": {
|
||||
"render.adaptive.pilot_progress": "적응형 파일럿 · {current}/{target} 패스",
|
||||
"render.adaptive.preview_help": "파일럿 이미지는 예산 배분에 사용되며 최종 이미지에서 제외됩니다. 본 처리는 픽셀마다 별도의 예산을 사용하므로 반복 횟수는 모든 픽셀의 동일한 샘플 수를 뜻하지 않습니다.",
|
||||
"render.adaptive.production_progress": "적응형 본 처리 · {current}/{target} 회",
|
||||
"render.live.adaptive": "라이브 · {width} × {height} · {progress}"
|
||||
},
|
||||
"vi": {
|
||||
"render.adaptive.pilot_progress": "Các lượt thăm dò thích ứng · {current}/{target}",
|
||||
"render.adaptive.preview_help": "Ảnh thăm dò định hướng việc phân bổ mẫu và không được đưa vào ảnh cuối cùng. Các vòng kết xuất sử dụng ngân sách riêng cho từng pixel; số vòng không phải số mẫu đồng đều trên mỗi pixel.",
|
||||
"render.adaptive.production_progress": "Kết xuất thích ứng · {current}/{target} vòng lặp",
|
||||
"render.live.adaptive": "Trực tiếp · {width} × {height} · {progress}"
|
||||
},
|
||||
"zh": {
|
||||
"render.adaptive.pilot_progress": "自适应预采样 · {current}/{target} 轮",
|
||||
"render.adaptive.preview_help": "预采样图像用于分配预算,不计入最终图像。正式采样为每个像素使用独立预算;迭代次数不表示所有像素具有相同的采样数。",
|
||||
"render.adaptive.production_progress": "自适应正式采样 · {current}/{target} 轮迭代",
|
||||
"render.live.adaptive": "实时 · {width} × {height} · {progress}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.batch_current_settings_help": "يستخدم إعدادات الصورة الحالية مع كاميرا كل لقطة محفوظة، لقطة واحدة في كل مرة."
|
||||
},
|
||||
"de": {
|
||||
"render.batch_current_settings_help": "Verwendet die aktuellen Bildeinstellungen mit jeder gespeicherten Aufnahmekamera, eine Aufnahme nach der anderen."
|
||||
},
|
||||
"en": {
|
||||
"render.batch_current_settings_help": "Uses the current image settings with each saved shot camera, one shot at a time."
|
||||
},
|
||||
"he": {
|
||||
"render.batch_current_settings_help": "משתמש בהגדרות התמונה הנוכחיות עם המצלמה של כל שוט שמור, שוט אחד בכל פעם."
|
||||
},
|
||||
"ja": {
|
||||
"render.batch_current_settings_help": "現在の画像設定と各保存済みショットのカメラで、1ショットずつレンダリングします。"
|
||||
},
|
||||
"ko": {
|
||||
"render.batch_current_settings_help": "현재 이미지 설정과 저장된 각 샷의 카메라를 사용해 한 번에 한 샷씩 렌더링합니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.batch_current_settings_help": "Dùng thiết lập ảnh hiện tại với máy ảnh của từng góc chụp đã lưu, lần lượt từng góc chụp."
|
||||
},
|
||||
"zh": {
|
||||
"render.batch_current_settings_help": "使用当前图像设置和各个已保存镜头的相机,逐个渲染镜头。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"ar": {
|
||||
"cmd.render.coat.bind": "حفظ الطلاء الشفاف",
|
||||
"cmd.render.coat.unbind": "إزالة الطلاء الشفاف",
|
||||
"render.coat_discard": "تجاهل تعديل الطلاء",
|
||||
"render.coat_pending_edit": "لم يُحفظ تعديل الطلاء هذا. احفظه مع اللقطة أو تجاهل التعديل.",
|
||||
"render.coat_remove": "إزالة الطلاء المحفوظ",
|
||||
"render.coat_remove_note": "يزيل طلاء هذا الهدف من اللقطة. تبقى الهندسة والمظاهر والمواد الفيزيائية دون تغيير.",
|
||||
"render.coat_remove_title": "إزالة الطلاء الشفاف المحفوظ؟",
|
||||
"render.coat_revalidate_saved": "تغيّر الطلاء المحفوظ. تجاهل هذا التعديل وأعد فتح الهدف قبل تعديله مجددًا.",
|
||||
"render.coat_save": "حفظ الطلاء مع اللقطة",
|
||||
"render.coat_saved_help": "يضيف الطلاء الشفاف طبقة لامعة إلى الأسطح المعتمة. احفظه مع اللقطة. لا يمكن استخدام الزجاج على الهدف نفسه، ولا تجاوزات الانعكاس المخصص والتباين الاتجاهي على CPU."
|
||||
},
|
||||
"de": {
|
||||
"cmd.render.coat.bind": "Klarlack speichern",
|
||||
"cmd.render.coat.unbind": "Klarlack entfernen",
|
||||
"render.coat_discard": "Klarlackänderung verwerfen",
|
||||
"render.coat_pending_edit": "Diese Klarlackänderung ist ungespeichert. Mit der Aufnahme speichern oder verwerfen.",
|
||||
"render.coat_remove": "Gespeicherten Klarlack entfernen",
|
||||
"render.coat_remove_note": "Entfernt den Klarlack dieses Ziels aus der Aufnahme. Geometrie, Aussehen und physikalische Materialien bleiben unverändert.",
|
||||
"render.coat_remove_title": "Gespeicherten Klarlack entfernen?",
|
||||
"render.coat_revalidate_saved": "Der gespeicherte Klarlack wurde geändert. Diese Änderung verwerfen und das Ziel vor der nächsten Bearbeitung erneut öffnen.",
|
||||
"render.coat_save": "Klarlack in Aufnahme speichern",
|
||||
"render.coat_saved_help": "Klarlack fügt undurchsichtigen Flächen eine glänzende Schicht hinzu. Mit der Aufnahme speichern. Glas kann nicht dasselbe Ziel verwenden. Auf der CPU dürfen auch benutzerdefinierte Reflexions- und Anisotropieüberschreibungen dieses Ziel nicht verwenden."
|
||||
},
|
||||
"en": {
|
||||
"cmd.render.coat.bind": "Save Clear Coat",
|
||||
"cmd.render.coat.unbind": "Remove Clear Coat",
|
||||
"render.coat_discard": "Discard coat edit",
|
||||
"render.coat_pending_edit": "This coat edit is unsaved. Save it with the shot or discard it.",
|
||||
"render.coat_remove": "Remove saved coat",
|
||||
"render.coat_remove_note": "Removes this target’s coat from the shot. Geometry, appearances and physical materials stay unchanged.",
|
||||
"render.coat_remove_title": "Remove saved clear coat?",
|
||||
"render.coat_revalidate_saved": "The saved coat changed. Discard this edit and reopen the target before editing it again.",
|
||||
"render.coat_save": "Save coat to shot",
|
||||
"render.coat_saved_help": "Clear coat adds a glossy layer to opaque surfaces. Save it with the shot. Glass cannot share this target. CPU custom reflection and anisotropy overrides cannot share it either."
|
||||
},
|
||||
"he": {
|
||||
"cmd.render.coat.bind": "שמירת ציפוי שקוף",
|
||||
"cmd.render.coat.unbind": "הסרת ציפוי שקוף",
|
||||
"render.coat_discard": "ביטול עריכת הציפוי",
|
||||
"render.coat_pending_edit": "שינוי הציפוי הזה לא נשמר. יש לשמור אותו עם התצלום או לבטל את השינוי.",
|
||||
"render.coat_remove": "הסרת ציפוי שמור",
|
||||
"render.coat_remove_note": "מסיר את הציפוי של היעד הזה מהתצלום. הגאומטריה, המראה והחומרים הפיזיקליים אינם משתנים.",
|
||||
"render.coat_remove_title": "להסיר את הציפוי השקוף השמור?",
|
||||
"render.coat_revalidate_saved": "הציפוי השמור השתנה. יש לבטל את העריכה הזו ולפתוח מחדש את היעד לפני עריכה נוספת.",
|
||||
"render.coat_save": "שמירת הציפוי בתצלום",
|
||||
"render.coat_saved_help": "ציפוי שקוף מוסיף שכבה מבריקה למשטחים אטומים. יש לשמור אותו עם התצלום. זכוכית אינה יכולה להשתמש באותו יעד, וגם לא דריסות של השתקפות מותאמת אישית ואנאיזוטרופיה ב־CPU."
|
||||
},
|
||||
"ja": {
|
||||
"cmd.render.coat.bind": "クリアコートを保存",
|
||||
"cmd.render.coat.unbind": "クリアコートを削除",
|
||||
"render.coat_discard": "コートの編集を破棄",
|
||||
"render.coat_pending_edit": "クリアコートの変更は未保存です。ショットに保存するか破棄してください。",
|
||||
"render.coat_remove": "保存済みコートを削除",
|
||||
"render.coat_remove_note": "この対象のコートをショットから削除します。形状、外観、物理材料は変更されません。",
|
||||
"render.coat_remove_title": "保存済みクリアコートを削除しますか?",
|
||||
"render.coat_revalidate_saved": "保存済みコートが変更されています。この編集を破棄し、対象を開き直してから編集してください。",
|
||||
"render.coat_save": "コートをショットに保存",
|
||||
"render.coat_saved_help": "クリアコートは不透明な表面に光沢のある層を追加します。ショットに保存してください。同じ対象にガラスは使用できません。CPUのカスタム反射や異方性の上書きも、この対象と併用できません。"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.render.coat.bind": "클리어 코트 저장",
|
||||
"cmd.render.coat.unbind": "클리어 코트 제거",
|
||||
"render.coat_discard": "코트 편집 버리기",
|
||||
"render.coat_pending_edit": "이 코트 편집은 저장되지 않았습니다. 샷에 저장하거나 편집을 버리세요.",
|
||||
"render.coat_remove": "저장된 코트 제거",
|
||||
"render.coat_remove_note": "이 대상의 코트를 샷에서 제거합니다. 형상, 외관 및 물리 재료는 변경되지 않습니다.",
|
||||
"render.coat_remove_title": "저장된 클리어 코트를 제거할까요?",
|
||||
"render.coat_revalidate_saved": "저장된 코트가 변경되었습니다. 이 편집을 버리고 대상을 다시 연 뒤 편집하세요.",
|
||||
"render.coat_save": "샷에 코트 저장",
|
||||
"render.coat_saved_help": "클리어 코트는 불투명 표면에 광택 층을 추가합니다. 샷에 저장하세요. 같은 대상에 유리를 사용할 수 없습니다. CPU 사용자 지정 반사 또는 이방성 재정의도 이 대상을 함께 사용할 수 없습니다."
|
||||
},
|
||||
"vi": {
|
||||
"cmd.render.coat.bind": "Lưu lớp phủ bóng",
|
||||
"cmd.render.coat.unbind": "Xóa lớp phủ bóng",
|
||||
"render.coat_discard": "Hủy chỉnh sửa lớp phủ",
|
||||
"render.coat_pending_edit": "Chỉnh sửa lớp phủ này chưa được lưu. Hãy lưu cùng cảnh chụp hoặc hủy chỉnh sửa.",
|
||||
"render.coat_remove": "Xóa lớp phủ đã lưu",
|
||||
"render.coat_remove_note": "Xóa lớp phủ của đích này khỏi cảnh chụp. Hình học, diện mạo và vật liệu vật lý không thay đổi.",
|
||||
"render.coat_remove_title": "Xóa lớp phủ bóng đã lưu?",
|
||||
"render.coat_revalidate_saved": "Lớp phủ đã lưu đã thay đổi. Hãy hủy chỉnh sửa này và mở lại đích trước khi sửa tiếp.",
|
||||
"render.coat_save": "Lưu lớp phủ vào cảnh chụp",
|
||||
"render.coat_saved_help": "Lớp phủ bóng thêm một lớp sáng bóng lên bề mặt đục. Hãy lưu cùng cảnh chụp. Kính không thể dùng chung đích này. Ghi đè phản xạ tùy chỉnh và tính dị hướng CPU cũng không thể dùng chung đích."
|
||||
},
|
||||
"zh": {
|
||||
"cmd.render.coat.bind": "保存清漆",
|
||||
"cmd.render.coat.unbind": "移除清漆",
|
||||
"render.coat_discard": "放弃清漆编辑",
|
||||
"render.coat_pending_edit": "清漆编辑尚未保存。请保存到镜头或放弃编辑。",
|
||||
"render.coat_remove": "移除已保存清漆",
|
||||
"render.coat_remove_note": "从镜头中移除此目标的清漆。几何、外观和物理材料保持不变。",
|
||||
"render.coat_remove_title": "移除已保存的清漆?",
|
||||
"render.coat_revalidate_saved": "已保存的清漆发生了变化。请放弃此编辑,重新打开目标后再编辑。",
|
||||
"render.coat_save": "将清漆保存到镜头",
|
||||
"render.coat_saved_help": "清漆为不透明表面添加光泽层。请随镜头保存。同一目标不能使用玻璃,也不能使用CPU自定义反射或各向异性覆盖。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.check_glass_cpu": "يدعم CPU صفائح زجاج مستوية وصناديق محاذية للمحاور بعد التحقق منها. تتطلب الشبكات المغلقة العامة والتعشيق والتشتت الداخلي إضاءة غير مباشرة. لا تزال الظلال النافذة عبر الزجاج المغلق غير مدعومة.",
|
||||
"render.check_glass_gpu": "يدعم GPU أوساط زجاج منفصلة متحققًا منها، أو تعشيقًا متحققًا منه عند تفعيله صراحةً. يتطلب التعشيق أغلفة خارجية محدبة وأجسامًا داخلية محتواة بالكامل وحدودًا غير متلامسة.",
|
||||
"render.check_media_remaining": "لا تزال تأثيرات الزجاج الخشن وشبكات الكثافة والإزاحة والنقل الطيفي غير متاحة.",
|
||||
"render.check_media_scope": "يتطلب التشتت الداخلي النشط وسطًا زجاجيًا مغلقًا مع إيقاف نفاذ أشعة الظل المستقيمة وإزالة الضجيج. ويتطلب GPU أيضًا طبقة عينات فرينل واحدة. لا يمكن جمع الغلاف الجوي الخارجي النشط مع الزجاج أو إزالة ضجيج الأسطح.",
|
||||
"render.cpu_material_certification": "يتحقق فحص CPU من هندسة الزجاج المدعومة قبل التصيير. يستخدم الزجاج المغلق المسافة الهندسية."
|
||||
},
|
||||
"de": {
|
||||
"render.check_glass_cpu": "Die CPU unterstützt geprüfte ebene Glasscheiben und achsenparallele Boxen. Allgemeine geschlossene Netze, Verschachtelung und innere Streuung erfordern indirekte Beleuchtung. Durch geschlossenes Glas übertragene Schatten bleiben nicht unterstützt.",
|
||||
"render.check_glass_gpu": "Die GPU unterstützt geprüfte getrennte Glasmedien oder ausdrücklich aktivierte geprüfte Verschachtelung. Dafür müssen die äußeren Körper konvex und die inneren streng enthalten sein; Grenzen dürfen sich nicht berühren.",
|
||||
"render.check_media_remaining": "Raues Glas, Dichtegitter, Displacement und spektraler Lichttransport bleiben nicht verfügbar.",
|
||||
"render.check_media_scope": "Aktive innere Streuung benötigt ein geschlossenes Glasmedium ohne gerade Schattenübertragung und ohne Rauschreduzierung. Die GPU benötigt außerdem eine Fresnel-Schicht. Aktive äußere Atmosphäre kann nicht mit Glas oder Oberflächen-Rauschreduzierung kombiniert werden.",
|
||||
"render.cpu_material_certification": "Die CPU-Prüfung validiert unterstützte Glasgeometrie vor dem Rendern. Geschlossenes Glas verwendet geometrische Abstände."
|
||||
},
|
||||
"en": {
|
||||
"render.check_glass_cpu": "CPU supports certified planar Glass sheets and axis-aligned boxes. General closed meshes, nesting and interior scattering require Indirect lighting. Closed transmitted shadows remain unsupported.",
|
||||
"render.check_glass_gpu": "GPU supports certified disjoint Glass or explicit certified nesting. Nesting requires convex parents, strictly contained children and no touching boundaries.",
|
||||
"render.check_media_remaining": "Rough Glass, density grids, displacement and spectral transport remain unavailable.",
|
||||
"render.check_media_scope": "Active interior scattering needs a closed Glass medium, straight shadow transmission off and Denoise off. GPU also needs one Fresnel stratum. Active exterior Atmosphere cannot combine with Glass or surface Denoise.",
|
||||
"render.cpu_material_certification": "CPU Check validates supported Glass geometry before rendering. Closed Glass uses geometric distance."
|
||||
},
|
||||
"es": {
|
||||
"render.cpu_material_certification": "La comprobación de CPU valida la geometría de vidrio admitida antes de renderizar. El vidrio cerrado usa la distancia geométrica."
|
||||
},
|
||||
"fr": {
|
||||
"render.cpu_material_certification": "La vérification CPU valide la géométrie de verre prise en charge avant le rendu. Le verre fermé utilise la distance géométrique."
|
||||
},
|
||||
"he": {
|
||||
"render.check_glass_cpu": "ה־CPU תומך בלוחות זכוכית מישוריים ובתיבות מקבילות לצירים שעברו אימות. רשתות סגורות כלליות, קינון ופיזור פנימי דורשים תאורה עקיפה. העברת צל דרך זכוכית סגורה עדיין אינה נתמכת.",
|
||||
"render.check_glass_gpu": "ה־GPU תומך בנפחי זכוכית נפרדים שעברו אימות או בקינון מאומת שמופעל במפורש. קינון דורש גופים חיצוניים קמורים, גופים פנימיים המוכלים בתוכם ממש וגבולות שאינם נוגעים זה בזה.",
|
||||
"render.check_media_remaining": "זכוכית מחוספסת, רשתות צפיפות, הסטת משטח והעברת אור ספקטרלית עדיין אינן זמינות.",
|
||||
"render.check_media_scope": "פיזור פנימי פעיל דורש נפח זכוכית סגור, העברת קרני צל ישרות כבויה והסרת רעש כבויה. ה־GPU דורש גם שכבת פרנל אחת. אי אפשר לשלב אטמוספירה חיצונית פעילה עם זכוכית או עם הסרת רעש משטחית.",
|
||||
"render.cpu_material_certification": "בדיקת CPU מאמתת את גאומטריית הזכוכית הנתמכת לפני הרינדור. זכוכית סגורה משתמשת במרחק גאומטרי."
|
||||
},
|
||||
"ja": {
|
||||
"render.check_glass_cpu": "CPUは検証済みの平面ガラス薄板と軸に平行な箱に対応します。一般の閉じたメッシュ、入れ子、内部散乱には間接照明が必要です。閉じたガラスを通る透過影には未対応です。",
|
||||
"render.check_glass_gpu": "GPUは検証済みの分離したガラス、または明示的に有効にした検証済みの入れ子に対応します。入れ子には凸形状の親、厳密に内包された子、接触しない境界が必要です。",
|
||||
"render.check_media_remaining": "粗いガラス、密度グリッド、ディスプレイスメント、分光輸送には未対応です。",
|
||||
"render.check_media_scope": "有効な内部散乱には閉じたガラス媒質を使い、直線の影透過とノイズ除去をオフにします。GPUではフレネル層を1つにします。有効な外部の大気はガラスや表面ノイズ除去と併用できません。",
|
||||
"render.cpu_material_certification": "CPUの確認では、レンダリング前に対応するガラス形状を検証します。閉じたガラスには形状から得られる距離を使います。"
|
||||
},
|
||||
"ko": {
|
||||
"render.check_glass_cpu": "CPU는 검증된 평면 유리판과 축 정렬 상자를 지원합니다. 일반적인 닫힌 메시, 중첩 및 내부 산란에는 간접광이 필요합니다. 닫힌 유리를 통과하는 투과 그림자는 아직 지원하지 않습니다.",
|
||||
"render.check_glass_gpu": "GPU는 검증된 분리 유리 또는 명시적으로 활성화한 검증된 중첩을 지원합니다. 중첩에는 볼록한 바깥 형상, 완전히 내부에 포함된 안쪽 형상 및 서로 닿지 않는 경계가 필요합니다.",
|
||||
"render.check_media_remaining": "거친 유리, 밀도 격자, 디스플레이스먼트 및 분광 광전달은 아직 지원하지 않습니다.",
|
||||
"render.check_media_scope": "활성 내부 산란에는 닫힌 유리 매질이 필요하며 직선 그림자 투과와 노이즈 제거를 꺼야 합니다. GPU에는 프레넬 계층도 하나만 필요합니다. 활성 외부 대기는 유리 또는 표면 노이즈 제거와 함께 사용할 수 없습니다.",
|
||||
"render.cpu_material_certification": "CPU 검사는 렌더링 전에 지원되는 유리 형상을 검증합니다. 닫힌 유리는 기하학적 거리를 사용합니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.check_glass_cpu": "CPU hỗ trợ các tấm kính phẳng và hộp thẳng theo trục đã được kiểm chứng. Lưới kín tổng quát, cấu trúc lồng nhau và tán xạ bên trong cần Chiếu sáng gián tiếp. Chưa hỗ trợ bóng truyền qua kính kín.",
|
||||
"render.check_glass_gpu": "GPU hỗ trợ các khối kính tách rời đã được kiểm chứng hoặc cấu trúc lồng nhau được kiểm chứng và bật rõ ràng. Cấu trúc lồng nhau cần lớp bao lồi, vật thể con nằm hoàn toàn bên trong và các ranh giới không chạm nhau.",
|
||||
"render.check_media_remaining": "Chưa hỗ trợ kính nhám, lưới mật độ, dịch chuyển bề mặt và truyền sáng quang phổ.",
|
||||
"render.check_media_scope": "Tán xạ bên trong đang hoạt động cần môi trường kính kín, tắt truyền tia bóng thẳng và tắt Khử nhiễu. GPU cũng cần một tầng Fresnel. Không thể kết hợp Khí quyển bên ngoài đang hoạt động với kính hoặc Khử nhiễu bề mặt.",
|
||||
"render.cpu_material_certification": "Kiểm tra trên CPU xác thực hình học thủy tinh được hỗ trợ trước khi kết xuất. Thủy tinh kín sử dụng khoảng cách hình học."
|
||||
},
|
||||
"zh": {
|
||||
"render.check_glass_cpu": "CPU 支持经验证的平面玻璃薄片和轴对齐盒体。一般闭合网格、嵌套和内部散射需要间接光照。仍不支持穿过闭合玻璃的透射阴影。",
|
||||
"render.check_glass_gpu": "GPU 支持经验证的互不相交玻璃介质,或明确启用的经验证嵌套。嵌套要求外层物体为凸体、内层物体严格包含于其中,且边界互不接触。",
|
||||
"render.check_media_remaining": "仍不支持粗糙玻璃、密度网格、位移和光谱传输。",
|
||||
"render.check_media_scope": "启用内部散射需要闭合玻璃介质,并关闭直线阴影透射和降噪。GPU 还需要只使用一个菲涅耳分层。启用的外部大气不能与玻璃或表面降噪同时使用。",
|
||||
"render.cpu_material_certification": "CPU 检查会在渲染前验证所支持的玻璃几何体。封闭玻璃使用几何距离。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.cpu_opaque_hint": "يستخدم تتبع الأشعة على CPU خلفية معتمة.",
|
||||
"render.output_master_unavailable": "لتصدير EXR، اختر صورة مكتملة تحتوي على بيانات EXR خام. فعّل EXR الخام قبل تصيير صور جديدة.",
|
||||
"render.output_presentation_limits": "تدعم مؤثرات الصور على CPU وإخراج EXR صوراً بعرض وارتفاع لا يتجاوزان 8192 بكسل، وبإجمالي لا يتجاوز 16,777,216 بكسل.",
|
||||
"render.output_raytrace_only": "تتطلب مؤثرات الصور تتبع الأشعة على CPU أو تتبع المسارات على GPU.",
|
||||
"render.presentation_banner": "تعمل مؤثرات إخراج الصورة مع تتبع الأشعة على CPU وGPU. فتح هذه الصفحة لا يغيّر الطريقة مطلقًا."
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_opaque_hint": "CPU-Raytracing verwendet einen undurchsichtigen Hintergrund.",
|
||||
"render.output_master_unavailable": "Zum EXR-Export ein fertiges Bild mit EXR-Rohdaten auswählen. EXR-Rohdaten vor dem Rendern neuer Bilder aktivieren.",
|
||||
"render.output_presentation_limits": "CPU-Bildeffekte und EXR-Ausgabe unterstützen Bilder mit bis zu 8192 Pixeln Breite oder Höhe und insgesamt höchstens 16.777.216 Pixeln.",
|
||||
"render.output_raytrace_only": "Bildeffekte erfordern CPU-Raytracing oder GPU-Pathtracing.",
|
||||
"render.presentation_banner": "Bildeffekte funktionieren mit CPU- und GPU-Raytracing. Das Öffnen dieser Seite ändert die Methode nicht."
|
||||
},
|
||||
"en": {
|
||||
"render.cpu_opaque_hint": "CPU ray tracing uses an opaque background.",
|
||||
"render.output_master_unavailable": "Select a completed image with raw EXR data to export EXR. Enable raw EXR before rendering new images.",
|
||||
"render.output_presentation_limits": "CPU image effects and EXR output support images up to 8192 pixels wide or high, with at most 16,777,216 pixels in total.",
|
||||
"render.output_raytrace_only": "Image effects require CPU ray tracing or GPU path tracing.",
|
||||
"render.presentation_banner": "Presentation effects work with CPU and GPU ray tracing. Opening this page never changes the method."
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_opaque_hint": "עקיבת קרניים ב־CPU משתמשת ברקע אטום.",
|
||||
"render.output_master_unavailable": "כדי לייצא EXR, יש לבחור תמונה שהושלמה ומכילה נתוני EXR גולמיים. יש להפעיל EXR גולמי לפני רינדור תמונות חדשות.",
|
||||
"render.output_presentation_limits": "אפקטים של תמונה ב־CPU ופלט EXR תומכים בתמונות ברוחב ובגובה של עד 8192 פיקסלים, ועד 16,777,216 פיקסלים בסך הכול.",
|
||||
"render.output_raytrace_only": "אפקטים של תמונה דורשים עקיבת קרניים ב־CPU או עקיבת נתיבים ב־GPU.",
|
||||
"render.presentation_banner": "אפקטים של תצוגת התמונה פועלים בעקיבת קרניים ב־CPU וב־GPU. פתיחת דף זה לעולם אינה משנה את השיטה."
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_opaque_hint": "CPUレイトレーシングでは不透明な背景を使用します。",
|
||||
"render.output_master_unavailable": "EXRを書き出すには、RAW EXRデータを持つ完成画像を選択してください。新しい画像は、レンダリング前にRAW EXRを有効にしてください。",
|
||||
"render.output_presentation_limits": "CPUの画像効果とEXR出力は、幅・高さとも8192ピクセル以下、合計16,777,216ピクセル以下の画像に対応します。",
|
||||
"render.output_raytrace_only": "画像効果にはCPUレイトレーシングまたはGPUパストレーシングが必要です。",
|
||||
"render.presentation_banner": "画像効果はCPUとGPUのレイトレーシングで使用できます。このページを開いても方式は変わりません。"
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_opaque_hint": "CPU 레이 트레이싱은 불투명한 배경을 사용합니다.",
|
||||
"render.output_master_unavailable": "EXR을 내보내려면 원본 EXR 데이터가 있는 완성 이미지를 선택하세요. 새 이미지를 렌더링하기 전에 원본 EXR을 활성화하세요.",
|
||||
"render.output_presentation_limits": "CPU 이미지 효과와 EXR 출력은 너비와 높이가 각각 최대 8192픽셀이고 총 16,777,216픽셀 이하인 이미지를 지원합니다.",
|
||||
"render.output_raytrace_only": "이미지 효과에는 CPU 레이 트레이싱 또는 GPU 패스 트레이싱이 필요합니다.",
|
||||
"render.presentation_banner": "이미지 효과는 CPU 및 GPU 레이 트레이싱에서 사용할 수 있습니다. 이 페이지를 열어도 방식이 바뀌지 않습니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_opaque_hint": "Dò tia trên CPU sử dụng nền không trong suốt.",
|
||||
"render.output_master_unavailable": "Để xuất EXR, hãy chọn ảnh đã hoàn tất có dữ liệu EXR thô. Bật EXR thô trước khi kết xuất ảnh mới.",
|
||||
"render.output_presentation_limits": "Hiệu ứng ảnh trên CPU và đầu ra EXR hỗ trợ ảnh có chiều rộng và chiều cao tối đa 8192 pixel, với tổng cộng không quá 16.777.216 pixel.",
|
||||
"render.output_raytrace_only": "Hiệu ứng ảnh yêu cầu dò tia trên CPU hoặc dò đường đi trên GPU.",
|
||||
"render.presentation_banner": "Các hiệu ứng hình ảnh hoạt động với dò tia trên CPU và GPU. Mở trang này không bao giờ thay đổi phương pháp."
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_opaque_hint": "CPU光线追踪使用不透明背景。",
|
||||
"render.output_master_unavailable": "要导出EXR,请选择包含原始EXR数据的已完成图像。渲染新图像前请先启用原始EXR。",
|
||||
"render.output_presentation_limits": "CPU图像效果和EXR输出支持宽度、高度均不超过8192像素且总像素数不超过16,777,216的图像。",
|
||||
"render.output_raytrace_only": "图像效果需要CPU光线追踪或GPU路径追踪。",
|
||||
"render.presentation_banner": "图像效果支持CPU和GPU光线追踪。打开此页面不会更改渲染方式。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"ar": {
|
||||
"prefs.render_legacy_image_defaults": "استخدام الإعدادات الافتراضية السابقة لصور التصيير",
|
||||
"prefs.render_legacy_image_defaults_help": "الاحتفاظ بسير العمل اليدوي السابق: إعدادات جديدة بالتصيير النقطي على CPU، وأخذ العينات الثابت لتتبع الأشعة على CPU، وفتح النتائج يدويًا. إعادة ملاءمة العرض عند دخول التصيير، وتبديل اللقطات دون استعادة كاميراتها، واستخدام التأطير والتنقل السابقين. تعطيل التحديث التلقائي للصورة المباشرة. لا تتغير الإعدادات المحفوظة."
|
||||
},
|
||||
"de": {
|
||||
"prefs.render_legacy_image_defaults": "Bisherige Render-Bildvorgaben verwenden",
|
||||
"prefs.render_legacy_image_defaults_help": "Den bisherigen manuellen Ablauf beibehalten: neue Setups mit CPU-Raster, feste Abtastung beim CPU-Raytracing und manuell geöffnete Ergebnisse. Beim Wechsel zu Render die Ansicht neu einpassen, Aufnahmen ohne Wiederherstellung ihrer Kamera wechseln und die bisherige Bildausrichtung und Navigation verwenden. Automatische Live-Bildrückmeldungen deaktivieren. Gespeicherte Setups bleiben unverändert."
|
||||
},
|
||||
"en": {
|
||||
"prefs.render_legacy_image_defaults": "Use previous Render image defaults",
|
||||
"prefs.render_legacy_image_defaults_help": "Keep the previous manual workflow: new setups use CPU raster, CPU ray tracing keeps fixed sampling, and you open rendered results manually. Refit on Render entry, switch shots without restoring their cameras, and use the previous framing and navigation. Disable automatic live image feedback. Saved setups are unchanged."
|
||||
},
|
||||
"he": {
|
||||
"prefs.render_legacy_image_defaults": "שימוש בברירות המחדל הקודמות לתמונות הדמיה",
|
||||
"prefs.render_legacy_image_defaults_help": "שמירה על תהליך העבודה הידני הקודם: הגדרות חדשות ברסטר CPU, דגימה קבועה בעקיבת קרניים ב־CPU ופתיחת התוצאות ידנית. התאמת התצוגה מחדש בכניסה להדמיה, מעבר בין צילומים בלי לשחזר את המצלמות שלהם ושימוש במסגור ובניווט הקודמים. עדכון אוטומטי של התמונה החיה מושבת. הגדרות שמורות אינן משתנות."
|
||||
},
|
||||
"ja": {
|
||||
"prefs.render_legacy_image_defaults": "以前のレンダー画像の既定値を使用",
|
||||
"prefs.render_legacy_image_defaults_help": "以前の手動の画像ワークフローを使用します。新しいセットアップはCPUラスタ、CPUレイトレーシングは固定サンプリングとし、結果は手動で開きます。レンダーに入るたびにビューをフィットし、ショットを切り替えても保存カメラを復元せず、以前のフレーミングとナビゲーションを使用します。自動のライブ画像更新は無効にします。保存済みのセットアップは変更しません。"
|
||||
},
|
||||
"ko": {
|
||||
"prefs.render_legacy_image_defaults": "이전 렌더 이미지 기본값 사용",
|
||||
"prefs.render_legacy_image_defaults_help": "이전의 수동 이미지 작업 방식을 유지합니다. 새 설정은 CPU 래스터를 사용하고 CPU 광선 추적은 고정 샘플링을 사용하며 결과는 직접 엽니다. 렌더에 들어갈 때 뷰를 다시 맞추고, 샷을 전환해도 저장된 카메라를 복원하지 않으며, 이전의 프레이밍과 탐색 방식을 사용합니다. 자동 실시간 이미지 피드백을 끕니다. 저장된 설정은 변경하지 않습니다.",
|
||||
"render.method_raster": "CPU 래스터 (도식)",
|
||||
"render.result_provisional": "임시 이미지"
|
||||
},
|
||||
"vi": {
|
||||
"prefs.render_legacy_image_defaults": "Dùng mặc định ảnh Kết xuất trước đây",
|
||||
"prefs.render_legacy_image_defaults_help": "Giữ quy trình hình ảnh thủ công trước đây: thiết lập mới dùng raster CPU, dò tia CPU dùng lấy mẫu cố định và mở kết quả thủ công. Căn vừa lại khung nhìn khi vào Kết xuất, chuyển góc chụp mà không khôi phục camera đã lưu, đồng thời dùng cách lấy khung hình và điều hướng trước đây. Tắt phản hồi ảnh trực tiếp tự động. Các thiết lập đã lưu không thay đổi."
|
||||
},
|
||||
"zh": {
|
||||
"prefs.render_legacy_image_defaults": "使用以前的渲染图像默认值",
|
||||
"prefs.render_legacy_image_defaults_help": "保留以前的手动图像流程:新设置使用 CPU 光栅渲染,CPU 光线追踪使用固定采样,并手动打开结果。进入渲染时重新适配视图,切换镜头时不恢复其保存的相机,并使用以前的取景和导航方式。禁用自动实时图像反馈。已保存的设置保持不变。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.light_space_world": "أضواء بإحداثيات العالم",
|
||||
"render.light_space_world_help": "تستخدم مواضع الأضواء واتجاهاتها إحداثيات العالم. لا يؤدي تدوير الكاميرا حول النموذج إلى تحريك الأضواء."
|
||||
},
|
||||
"de": {
|
||||
"render.light_space_world": "Lichter in Weltkoordinaten",
|
||||
"render.light_space_world_help": "Lichtpositionen und -richtungen verwenden Weltkoordinaten. Beim Umkreisen mit der Kamera bleiben die Lichter an ihrer Position."
|
||||
},
|
||||
"en": {
|
||||
"render.light_space_world": "World-space lights",
|
||||
"render.light_space_world_help": "Light positions and directions use world coordinates. Orbiting the camera does not move them."
|
||||
},
|
||||
"he": {
|
||||
"render.light_space_world": "אורות בקואורדינטות העולם",
|
||||
"render.light_space_world_help": "מיקומי האורות וכיווניהם משתמשים בקואורדינטות העולם. סיבוב המצלמה סביב המודל אינו מזיז את האורות."
|
||||
},
|
||||
"ja": {
|
||||
"render.light_space_world": "ワールド座標のライト",
|
||||
"render.light_space_world_help": "ライトの位置と方向はワールド座標を使用します。カメラを周回させてもライトは移動しません。"
|
||||
},
|
||||
"ko": {
|
||||
"render.light_space_world": "월드 좌표의 조명",
|
||||
"render.light_space_world_help": "조명의 위치와 방향은 월드 좌표를 사용합니다. 카메라를 회전해도 조명은 움직이지 않습니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.light_space_world": "Đèn trong hệ tọa độ thế giới",
|
||||
"render.light_space_world_help": "Vị trí và hướng của đèn dùng hệ tọa độ thế giới. Xoay camera quanh mô hình không làm đèn di chuyển."
|
||||
},
|
||||
"zh": {
|
||||
"render.light_space_world": "世界坐标光源",
|
||||
"render.light_space_world_help": "光源的位置和方向使用世界坐标。环绕旋转相机不会移动光源。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"ar": {
|
||||
"cmd.view.render_live": "إيقاف التصيير المباشر مؤقتًا أو استئنافه"
|
||||
},
|
||||
"de": {
|
||||
"cmd.view.render_live": "Live-Rendering pausieren oder fortsetzen"
|
||||
},
|
||||
"en": {
|
||||
"cmd.view.render_live": "Pause or Resume Live Render"
|
||||
},
|
||||
"he": {
|
||||
"cmd.view.render_live": "השהיה או חידוש של הדמיה חיה"
|
||||
},
|
||||
"ja": {
|
||||
"cmd.view.render_live": "ライブレンダーを一時停止・再開"
|
||||
},
|
||||
"ko": {
|
||||
"cmd.view.render_live": "실시간 렌더링 일시 중지 또는 재개"
|
||||
},
|
||||
"vi": {
|
||||
"cmd.view.render_live": "Tạm dừng hoặc tiếp tục kết xuất trực tiếp"
|
||||
},
|
||||
"zh": {
|
||||
"cmd.view.render_live": "暂停或继续实时渲染"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.live.final_changed": "تغيّر طلب التصيير أثناء إيقاف التصيير المباشر. ابدأ الصورة النهائية من جديد.",
|
||||
"render.live.final_waiting": "جارٍ تحضير الصورة النهائية · تحرير موارد التصيير المباشر",
|
||||
"render.live.invalid_size": "يتطلب التصيير المباشر عرضًا وارتفاعًا موجبين للإخراج.",
|
||||
"render.live.pause": "إيقاف المباشر مؤقتًا",
|
||||
"render.live.paused": "مباشر · متوقف مؤقتًا",
|
||||
"render.live.paused_outdated": "مباشر · متوقف مؤقتًا · غير محدّث",
|
||||
"render.live.quality_help": "جودة تفاعلية: بحد أقصى 512 بكسل للضلع الأطول. تستخدم المصيّر المحدد ومؤثراته المدعومة. تبقى دقة الصورة النهائية وإعداداتها وتصديرها مستقلة.",
|
||||
"render.live.refused": "التصيير المباشر غير متاح: {reason}",
|
||||
"render.live.resume": "استئناف المباشر",
|
||||
"render.live.samples": "مباشر · {width} × {height} · {current}/{target} عينة",
|
||||
"render.live.stopped": "توقف التصيير المباشر",
|
||||
"render.live.updating": "مباشر · جودة تفاعلية (حتى 512 بكسل) · جارٍ التحديث",
|
||||
"render.live.waiting_canvas": "في انتظار لوحة التصيير",
|
||||
"render.live.worker_stopped": "توقفت مهمة التقاط التصيير المباشر قبل إرجاع نتيجة"
|
||||
},
|
||||
"de": {
|
||||
"render.live.final_changed": "Die Render-Anfrage hat sich beim Anhalten des Live-Renderings geändert. Starten Sie das finale Bild erneut.",
|
||||
"render.live.final_waiting": "Finales Bild vorbereiten · Live-Render-Ressourcen freigeben",
|
||||
"render.live.invalid_size": "Live-Rendering benötigt eine positive Ausgabebreite und -höhe.",
|
||||
"render.live.pause": "Live pausieren",
|
||||
"render.live.paused": "Live · Pausiert",
|
||||
"render.live.paused_outdated": "Live · Pausiert · Veraltet",
|
||||
"render.live.quality_help": "Interaktive Qualität: höchstens 512 Pixel an der längsten Kante. Verwendet den gewählten Renderer und seine unterstützten Effekte. Endauflösung, Einstellungen und Export bleiben getrennt.",
|
||||
"render.live.refused": "Live nicht verfügbar: {reason}",
|
||||
"render.live.resume": "Live fortsetzen",
|
||||
"render.live.samples": "Live · {width} × {height} · {current}/{target} Samples",
|
||||
"render.live.stopped": "Live-Rendering angehalten",
|
||||
"render.live.updating": "Live · Interaktive Qualität (bis 512 px) · Aktualisierung",
|
||||
"render.live.waiting_canvas": "Warten auf die Render-Ansicht",
|
||||
"render.live.worker_stopped": "Der Aufnahmeprozess für das Live-Rendering wurde vor der Rückgabe eines Ergebnisses beendet"
|
||||
},
|
||||
"en": {
|
||||
"render.live.final_changed": "The render request changed while live rendering was stopping. Start the final image again.",
|
||||
"render.live.final_waiting": "Preparing final image · releasing live-render resources",
|
||||
"render.live.invalid_size": "Live rendering needs a positive output width and height.",
|
||||
"render.live.pause": "Pause live",
|
||||
"render.live.paused": "Live · Paused",
|
||||
"render.live.paused_outdated": "Live · Paused · Out of date",
|
||||
"render.live.quality_help": "Interactive quality: at most 512 pixels on the longest edge. Uses the selected renderer and its supported effects. Final resolution, settings and export remain separate.",
|
||||
"render.live.refused": "Live unavailable: {reason}",
|
||||
"render.live.resume": "Resume live",
|
||||
"render.live.samples": "Live · {width} × {height} · {current}/{target} samples",
|
||||
"render.live.stopped": "Live rendering stopped",
|
||||
"render.live.updating": "Live · Interactive quality (up to 512 px) · Updating",
|
||||
"render.live.waiting_canvas": "Waiting for the Render canvas",
|
||||
"render.live.worker_stopped": "The live-render capture worker stopped before returning a result"
|
||||
},
|
||||
"he": {
|
||||
"render.live.final_changed": "בקשת ההדמיה השתנתה בזמן עצירת ההדמיה החיה. יש להתחיל את התמונה הסופית מחדש.",
|
||||
"render.live.final_waiting": "מכין תמונה סופית · משחרר משאבי הדמיה חיה",
|
||||
"render.live.invalid_size": "הדמיה חיה דורשת רוחב וגובה פלט גדולים מאפס.",
|
||||
"render.live.pause": "השהיית הדמיה חיה",
|
||||
"render.live.paused": "חי · מושהה",
|
||||
"render.live.paused_outdated": "חי · מושהה · לא מעודכן",
|
||||
"render.live.quality_help": "איכות אינטראקטיבית: לכל היותר 512 פיקסלים בצד הארוך. משתמשת במנוע ההדמיה שנבחר ובאפקטים הנתמכים בו. רזולוציית התמונה הסופית, ההגדרות והייצוא נשארים נפרדים.",
|
||||
"render.live.refused": "הדמיה חיה אינה זמינה: {reason}",
|
||||
"render.live.resume": "חידוש הדמיה חיה",
|
||||
"render.live.samples": "חי · {width} × {height} · {current}/{target} דגימות",
|
||||
"render.live.stopped": "ההדמיה החיה נעצרה",
|
||||
"render.live.updating": "חי · איכות אינטראקטיבית (עד 512 פיקסלים) · מתעדכן",
|
||||
"render.live.waiting_canvas": "ממתין למשטח ההדמיה",
|
||||
"render.live.worker_stopped": "תהליך הלכידה של ההדמיה החיה נעצר לפני שהחזיר תוצאה"
|
||||
},
|
||||
"ja": {
|
||||
"render.live.final_changed": "ライブレンダリングの停止中にリクエストが変更されました。最終画像をもう一度開始してください。",
|
||||
"render.live.final_waiting": "最終画像を準備中 · ライブレンダーのリソースを解放中",
|
||||
"render.live.invalid_size": "ライブレンダリングには正の出力幅と高さが必要です。",
|
||||
"render.live.pause": "ライブを一時停止",
|
||||
"render.live.paused": "ライブ · 一時停止中",
|
||||
"render.live.paused_outdated": "ライブ · 一時停止中 · 更新が必要",
|
||||
"render.live.quality_help": "インタラクティブ品質: 長辺は最大512ピクセルです。選択したレンダラーと対応する効果を使用します。最終解像度、設定、書き出しは別に保持します。",
|
||||
"render.live.refused": "ライブを利用できません: {reason}",
|
||||
"render.live.resume": "ライブを再開",
|
||||
"render.live.samples": "ライブ · {width} × {height} · {current}/{target} サンプル",
|
||||
"render.live.stopped": "ライブレンダリングを停止しました",
|
||||
"render.live.updating": "ライブ · インタラクティブ品質(最大512 px)· 更新中",
|
||||
"render.live.waiting_canvas": "レンダーキャンバスを待機中",
|
||||
"render.live.worker_stopped": "ライブレンダリングのキャプチャーワーカーが結果を返す前に停止しました"
|
||||
},
|
||||
"ko": {
|
||||
"render.live.final_changed": "라이브 렌더링을 중지하는 동안 렌더 요청이 변경되었습니다. 최종 이미지를 다시 시작하세요.",
|
||||
"render.live.final_waiting": "최종 이미지 준비 중 · 라이브 렌더 리소스 해제 중",
|
||||
"render.live.invalid_size": "라이브 렌더링에는 양수의 출력 너비와 높이가 필요합니다.",
|
||||
"render.live.pause": "라이브 일시 중지",
|
||||
"render.live.paused": "라이브 · 일시 중지됨",
|
||||
"render.live.paused_outdated": "라이브 · 일시 중지됨 · 업데이트 필요",
|
||||
"render.live.quality_help": "대화형 품질: 가장 긴 변은 최대 512픽셀입니다. 선택한 렌더러와 지원하는 효과를 사용합니다. 최종 해상도, 설정 및 내보내기는 별도로 유지됩니다.",
|
||||
"render.live.refused": "라이브 사용 불가: {reason}",
|
||||
"render.live.resume": "라이브 재개",
|
||||
"render.live.samples": "라이브 · {width} × {height} · {current}/{target} 샘플",
|
||||
"render.live.stopped": "라이브 렌더링 중지됨",
|
||||
"render.live.updating": "라이브 · 대화형 품질(최대 512 px) · 업데이트 중",
|
||||
"render.live.waiting_canvas": "렌더 캔버스 대기 중",
|
||||
"render.live.worker_stopped": "라이브 렌더 캡처 작업이 결과를 반환하기 전에 중지되었습니다"
|
||||
},
|
||||
"vi": {
|
||||
"render.live.final_changed": "Yêu cầu kết xuất đã thay đổi trong lúc dừng kết xuất trực tiếp. Hãy bắt đầu lại ảnh cuối cùng.",
|
||||
"render.live.final_waiting": "Đang chuẩn bị ảnh cuối · Giải phóng tài nguyên kết xuất trực tiếp",
|
||||
"render.live.invalid_size": "Kết xuất trực tiếp yêu cầu chiều rộng và chiều cao đầu ra lớn hơn không.",
|
||||
"render.live.pause": "Tạm dừng trực tiếp",
|
||||
"render.live.paused": "Trực tiếp · Tạm dừng",
|
||||
"render.live.paused_outdated": "Trực tiếp · Tạm dừng · Chưa cập nhật",
|
||||
"render.live.quality_help": "Chất lượng tương tác: tối đa 512 pixel ở cạnh dài nhất. Sử dụng bộ kết xuất đã chọn và các hiệu ứng được bộ đó hỗ trợ. Độ phân giải cuối, thiết lập và thao tác xuất vẫn độc lập.",
|
||||
"render.live.refused": "Kết xuất trực tiếp không khả dụng: {reason}",
|
||||
"render.live.resume": "Tiếp tục trực tiếp",
|
||||
"render.live.samples": "Trực tiếp · {width} × {height} · {current}/{target} mẫu",
|
||||
"render.live.stopped": "Kết xuất trực tiếp đã dừng",
|
||||
"render.live.updating": "Trực tiếp · Chất lượng tương tác (tối đa 512 px) · Đang cập nhật",
|
||||
"render.live.waiting_canvas": "Đang chờ khung vẽ Kết xuất",
|
||||
"render.live.worker_stopped": "Luồng thu nhận dữ liệu cho kết xuất trực tiếp đã dừng trước khi trả về kết quả"
|
||||
},
|
||||
"zh": {
|
||||
"render.live.final_changed": "停止实时渲染时渲染请求已更改。请重新开始最终图像渲染。",
|
||||
"render.live.final_waiting": "正在准备最终图像 · 释放实时渲染资源",
|
||||
"render.live.invalid_size": "实时渲染需要正的输出宽度和高度。",
|
||||
"render.live.pause": "暂停实时渲染",
|
||||
"render.live.paused": "实时 · 已暂停",
|
||||
"render.live.paused_outdated": "实时 · 已暂停 · 已过时",
|
||||
"render.live.quality_help": "交互质量:最长边最多为 512 像素。使用所选渲染器及其支持的效果。最终分辨率、设置和导出保持独立。",
|
||||
"render.live.refused": "实时渲染不可用:{reason}",
|
||||
"render.live.resume": "恢复实时渲染",
|
||||
"render.live.samples": "实时 · {width} × {height} · {current}/{target} 次采样",
|
||||
"render.live.stopped": "实时渲染已停止",
|
||||
"render.live.updating": "实时 · 交互质量(最多 512 px)· 正在更新",
|
||||
"render.live.waiting_canvas": "正在等待渲染画布",
|
||||
"render.live.worker_stopped": "实时渲染捕获工作线程在返回结果之前已停止"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"en": {
|
||||
"render.batch_owner_limit": "At most 32 document shot queues can be retained. Finish or cancel an existing queue first.",
|
||||
"render.batch_preparing": "Preparing next shot",
|
||||
"render.batch_waiting_worker": "Waiting for render preparation",
|
||||
"render.preparation_cancelled": "Render preparation cancelled",
|
||||
"render.preparation_checking": "Checking render",
|
||||
"render.preparation_retirement_failed": "Render preparation cleanup failed. This window cannot prepare another image.",
|
||||
"render.preparation_retiring": "Finishing render preparation",
|
||||
"render.preparation_unavailable": "Render preparation unavailable"
|
||||
},
|
||||
"de": {
|
||||
"render.batch_owner_limit": "Es können höchstens 32 Dokument-Bildfolgen vorgehalten werden. Schließen Sie zuerst eine vorhandene Folge ab oder brechen Sie sie ab.",
|
||||
"render.batch_preparing": "Nächste Aufnahme wird vorbereitet",
|
||||
"render.batch_waiting_worker": "Warten auf die Rendering-Vorbereitung",
|
||||
"render.preparation_cancelled": "Rendering-Vorbereitung abgebrochen",
|
||||
"render.preparation_checking": "Rendering wird geprüft",
|
||||
"render.preparation_retirement_failed": "Die Bereinigung der Rendering-Vorbereitung ist fehlgeschlagen. Dieses Fenster kann kein weiteres Bild vorbereiten.",
|
||||
"render.preparation_retiring": "Rendering-Vorbereitung wird abgeschlossen",
|
||||
"render.preparation_unavailable": "Rendering-Vorbereitung nicht verfügbar"
|
||||
},
|
||||
"ja": {
|
||||
"render.batch_owner_limit": "保持できるドキュメントのショットキューは最大32個です。既存のキューを完了するかキャンセルしてください。",
|
||||
"render.batch_preparing": "次のショットを準備中",
|
||||
"render.batch_waiting_worker": "レンダリングの準備待ち",
|
||||
"render.preparation_cancelled": "レンダリングの準備をキャンセルしました",
|
||||
"render.preparation_checking": "レンダリングを確認中",
|
||||
"render.preparation_retirement_failed": "レンダリング準備の後処理に失敗しました。このウィンドウでは別の画像を準備できません。",
|
||||
"render.preparation_retiring": "レンダリングの準備を終了中",
|
||||
"render.preparation_unavailable": "レンダリングの準備を利用できません"
|
||||
},
|
||||
"ko": {
|
||||
"render.batch_owner_limit": "문서 샷 대기열은 최대 32개까지 유지할 수 있습니다. 기존 대기열을 완료하거나 취소하세요.",
|
||||
"render.batch_preparing": "다음 샷 준비 중",
|
||||
"render.batch_waiting_worker": "렌더링 준비 대기 중",
|
||||
"render.preparation_cancelled": "렌더링 준비가 취소되었습니다",
|
||||
"render.preparation_checking": "렌더링 확인 중",
|
||||
"render.preparation_retirement_failed": "렌더링 준비 정리에 실패했습니다. 이 창에서는 다른 이미지를 준비할 수 없습니다.",
|
||||
"render.preparation_retiring": "렌더링 준비 마무리 중",
|
||||
"render.preparation_unavailable": "렌더링 준비를 사용할 수 없음"
|
||||
},
|
||||
"zh": {
|
||||
"render.batch_owner_limit": "最多可保留32个文档的镜头队列。请先完成或取消现有队列。",
|
||||
"render.batch_preparing": "正在准备下一个镜头",
|
||||
"render.batch_waiting_worker": "等待渲染准备",
|
||||
"render.preparation_cancelled": "渲染准备已取消",
|
||||
"render.preparation_checking": "正在检查渲染",
|
||||
"render.preparation_retirement_failed": "渲染准备清理失败。此窗口无法再准备其他图像。",
|
||||
"render.preparation_retiring": "正在完成渲染准备",
|
||||
"render.preparation_unavailable": "渲染准备不可用"
|
||||
},
|
||||
"ar": {
|
||||
"render.batch_owner_limit": "يمكن الاحتفاظ بقوائم لقطات 32 مستندًا كحد أقصى. أكمل قائمة موجودة أو ألغها أولًا.",
|
||||
"render.batch_preparing": "جارٍ إعداد اللقطة التالية",
|
||||
"render.batch_waiting_worker": "في انتظار إعداد التصيير",
|
||||
"render.preparation_cancelled": "أُلغي إعداد التصيير",
|
||||
"render.preparation_checking": "جارٍ التحقق من التصيير",
|
||||
"render.preparation_retirement_failed": "تعذّر تنظيف موارد إعداد التصيير. لا يمكن لهذه النافذة إعداد صورة أخرى.",
|
||||
"render.preparation_retiring": "جارٍ إنهاء إعداد التصيير",
|
||||
"render.preparation_unavailable": "إعداد التصيير غير متاح"
|
||||
},
|
||||
"he": {
|
||||
"render.batch_owner_limit": "ניתן לשמור תורי צילומים של 32 מסמכים לכל היותר. יש להשלים או לבטל תור קיים תחילה.",
|
||||
"render.batch_preparing": "מכין את הצילום הבא",
|
||||
"render.batch_waiting_worker": "ממתין להכנת הרינדור",
|
||||
"render.preparation_cancelled": "הכנת הרינדור בוטלה",
|
||||
"render.preparation_checking": "בודק רינדור",
|
||||
"render.preparation_retirement_failed": "ניקוי משאבי הכנת הרינדור נכשל. לא ניתן להכין תמונה נוספת בחלון זה.",
|
||||
"render.preparation_retiring": "מסיים את הכנת הרינדור",
|
||||
"render.preparation_unavailable": "הכנת הרינדור אינה זמינה"
|
||||
},
|
||||
"vi": {
|
||||
"render.batch_owner_limit": "Chỉ có thể giữ hàng đợi cảnh của tối đa 32 tài liệu. Hãy hoàn tất hoặc hủy một hàng đợi hiện có trước.",
|
||||
"render.batch_preparing": "Đang chuẩn bị cảnh tiếp theo",
|
||||
"render.batch_waiting_worker": "Đang chờ chuẩn bị kết xuất",
|
||||
"render.preparation_cancelled": "Đã hủy chuẩn bị kết xuất",
|
||||
"render.preparation_checking": "Đang kiểm tra kết xuất",
|
||||
"render.preparation_retirement_failed": "Không thể giải phóng tài nguyên chuẩn bị kết xuất. Cửa sổ này không thể chuẩn bị ảnh khác.",
|
||||
"render.preparation_retiring": "Đang hoàn tất chuẩn bị kết xuất",
|
||||
"render.preparation_unavailable": "Không thể chuẩn bị kết xuất"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.cpu_fixed_retained": "تم الاحتفاظ بأخذ العينات الثابت",
|
||||
"render.cpu_progressive_adopt": "استخدام أخذ العينات التدريجي",
|
||||
"render.cpu_progressive_adopt_help": "يستخدم أخذ العينات التدريجي عينة كاميرا واحدة لكل تمريرة. يبقى حجم الصورة والأضواء وإعدادات الانعكاس دون تغيير.",
|
||||
"render.cpu_progressive_always": "أخذ العينات التدريجي مفعّل دائمًا",
|
||||
"render.cpu_progressive_reflection_adopt": "اضبط محاولات الانعكاس المحددة على 1 قبل تفعيل أخذ العينات التدريجي."
|
||||
},
|
||||
"de": {
|
||||
"render.cpu_fixed_retained": "Feste Abtastung beibehalten",
|
||||
"render.cpu_progressive_adopt": "Progressive Abtastung verwenden",
|
||||
"render.cpu_progressive_adopt_help": "Die progressive Abtastung verwendet ein Kamerasample pro Durchlauf. Bildgröße, Lichtquellen und Reflexionseinstellungen bleiben unverändert.",
|
||||
"render.cpu_progressive_always": "Progressive Abtastung ist immer aktiv",
|
||||
"render.cpu_progressive_reflection_adopt": "Setzen Sie die Reflexionsversuche ausgewählter Körper auf 1, bevor Sie die progressive Abtastung übernehmen."
|
||||
},
|
||||
"en": {
|
||||
"render.cpu_fixed_retained": "Fixed sampling retained",
|
||||
"render.cpu_progressive_adopt": "Use progressive sampling",
|
||||
"render.cpu_progressive_adopt_help": "Adopting progressive uses one camera sample per pass. Image size, lights and reflection settings stay unchanged.",
|
||||
"render.cpu_progressive_always": "Progressive sampling is always on",
|
||||
"render.cpu_progressive_reflection_adopt": "Set selected reflection attempts to 1 before adopting progressive sampling."
|
||||
},
|
||||
"he": {
|
||||
"render.cpu_fixed_retained": "הדגימה הקבועה נשמרה",
|
||||
"render.cpu_progressive_adopt": "שימוש בדגימה הדרגתית",
|
||||
"render.cpu_progressive_adopt_help": "מעבר לדגימה הדרגתית משתמש בדגימת מצלמה אחת לכל מעבר. גודל התמונה, האורות והגדרות ההחזר נשארים ללא שינוי.",
|
||||
"render.cpu_progressive_always": "דגימה הדרגתית פעילה תמיד",
|
||||
"render.cpu_progressive_reflection_adopt": "יש לקבוע את מספר ניסיונות ההחזר שנבחרו ל־1 לפני המעבר לדגימה הדרגתית."
|
||||
},
|
||||
"ja": {
|
||||
"render.cpu_fixed_retained": "固定サンプリングを維持",
|
||||
"render.cpu_progressive_adopt": "プログレッシブサンプリングを使用",
|
||||
"render.cpu_progressive_adopt_help": "切り替えると各パスで1つのカメラサンプルを使用します。画像サイズ、照明、反射設定は変わりません。",
|
||||
"render.cpu_progressive_always": "プログレッシブサンプリングは常に有効です",
|
||||
"render.cpu_progressive_reflection_adopt": "切り替える前に、選択した反射の試行回数を1に設定してください。"
|
||||
},
|
||||
"ko": {
|
||||
"render.cpu_fixed_retained": "고정 샘플링 유지",
|
||||
"render.cpu_progressive_adopt": "점진적 샘플링 사용",
|
||||
"render.cpu_progressive_adopt_help": "전환하면 패스마다 카메라 샘플 하나를 사용합니다. 이미지 크기, 조명 및 반사 설정은 유지됩니다.",
|
||||
"render.cpu_progressive_always": "점진적 샘플링은 항상 켜져 있습니다",
|
||||
"render.cpu_progressive_reflection_adopt": "전환하기 전에 선택한 반사의 시도 횟수를 1로 설정하세요."
|
||||
},
|
||||
"vi": {
|
||||
"render.cpu_fixed_retained": "Đã giữ lấy mẫu cố định",
|
||||
"render.cpu_progressive_adopt": "Dùng lấy mẫu lũy tiến",
|
||||
"render.cpu_progressive_adopt_help": "Lấy mẫu lũy tiến sử dụng một mẫu máy ảnh cho mỗi lượt. Kích thước ảnh, nguồn sáng và thiết lập phản xạ không thay đổi.",
|
||||
"render.cpu_progressive_always": "Lấy mẫu lũy tiến luôn bật",
|
||||
"render.cpu_progressive_reflection_adopt": "Đặt số lần thử phản xạ đã chọn thành 1 trước khi chuyển sang lấy mẫu lũy tiến."
|
||||
},
|
||||
"zh": {
|
||||
"render.cpu_fixed_retained": "保留固定采样",
|
||||
"render.cpu_progressive_adopt": "使用渐进采样",
|
||||
"render.cpu_progressive_adopt_help": "启用后每轮使用一个相机样本。图像大小、灯光和反射设置保持不变。",
|
||||
"render.cpu_progressive_always": "渐进采样始终开启",
|
||||
"render.cpu_progressive_reflection_adopt": "启用渐进采样前,请将所选反射的尝试次数设为1。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.quality_budget_help": "يضبط الحجم والعينات والعمق والبذرة وتقليل التشويش. تُحفظ إعدادات الصورة الأخرى.",
|
||||
"render.quality_denoise_off": "تقليل التشويش: متوقف",
|
||||
"render.quality_denoise_on": "تقليل التشويش: مفعّل"
|
||||
},
|
||||
"de": {
|
||||
"render.quality_budget_help": "Legt Größe, Samples, Tiefe, Startwert und Rauschreduzierung fest. Andere Bildeinstellungen bleiben erhalten.",
|
||||
"render.quality_denoise_off": "Rauschreduzierung: Aus",
|
||||
"render.quality_denoise_on": "Rauschreduzierung: Ein"
|
||||
},
|
||||
"en": {
|
||||
"render.quality_budget_help": "Sets size, samples, depth, seed and noise reduction. Other image settings are kept.",
|
||||
"render.quality_denoise_off": "Noise reduction: Off",
|
||||
"render.quality_denoise_on": "Noise reduction: On"
|
||||
},
|
||||
"he": {
|
||||
"render.quality_budget_help": "קובע גודל, דגימות, עומק, זרע והפחתת רעש. הגדרות התמונה האחרות נשמרות.",
|
||||
"render.quality_denoise_off": "הפחתת רעש: כבויה",
|
||||
"render.quality_denoise_on": "הפחתת רעש: פעילה"
|
||||
},
|
||||
"ja": {
|
||||
"render.quality_budget_help": "サイズ、サンプル数、深度、シード、ノイズ低減を設定します。他の画像設定は維持されます。",
|
||||
"render.quality_denoise_off": "ノイズ低減:オフ",
|
||||
"render.quality_denoise_on": "ノイズ低減:オン"
|
||||
},
|
||||
"ko": {
|
||||
"render.quality_budget_help": "크기, 샘플 수, 깊이, 시드 및 노이즈 제거를 설정합니다. 다른 이미지 설정은 유지됩니다.",
|
||||
"render.quality_denoise_off": "노이즈 제거: 꺼짐",
|
||||
"render.quality_denoise_on": "노이즈 제거: 켜짐"
|
||||
},
|
||||
"vi": {
|
||||
"render.quality_budget_help": "Đặt kích thước, số mẫu, độ sâu, hạt giống và giảm nhiễu. Giữ nguyên các thiết lập ảnh khác.",
|
||||
"render.quality_denoise_off": "Giảm nhiễu: Tắt",
|
||||
"render.quality_denoise_on": "Giảm nhiễu: Bật"
|
||||
},
|
||||
"zh": {
|
||||
"render.quality_budget_help": "设置尺寸、采样数、深度、种子和降噪。其他图像设置保持不变。",
|
||||
"render.quality_denoise_off": "降噪:关闭",
|
||||
"render.quality_denoise_on": "降噪:开启"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.quality_summary": "{width} × {height} · {samples} عينة · العمق {depth} · البذرة {seed} · {denoise}"
|
||||
},
|
||||
"de": {
|
||||
"render.quality_summary": "{width} × {height} · {samples} Samples · Tiefe {depth} · Startwert {seed} · {denoise}"
|
||||
},
|
||||
"en": {
|
||||
"render.quality_summary": "{width} × {height} · {samples} samples · depth {depth} · seed {seed} · {denoise}"
|
||||
},
|
||||
"he": {
|
||||
"render.quality_summary": "{width} × {height} · {samples} דגימות · עומק {depth} · זרע {seed} · {denoise}"
|
||||
},
|
||||
"ja": {
|
||||
"render.quality_summary": "{width} × {height} · {samples} サンプル · 深度 {depth} · シード {seed} · {denoise}"
|
||||
},
|
||||
"ko": {
|
||||
"render.quality_summary": "{width} × {height} · 샘플 {samples}개 · 깊이 {depth} · 시드 {seed} · {denoise}"
|
||||
},
|
||||
"vi": {
|
||||
"render.quality_summary": "{width} × {height} · {samples} mẫu · độ sâu {depth} · hạt giống {seed} · {denoise}"
|
||||
},
|
||||
"zh": {
|
||||
"render.quality_summary": "{width} × {height} · {samples} 次采样 · 深度 {depth} · 种子 {seed} · {denoise}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"ar": {
|
||||
"render.live.source_current": "{method} · مباشر",
|
||||
"render.live.source_outdated": "{method} · الصورة السابقة",
|
||||
"render.live.source_outdated_help": "يعرض آخر صورة ملتقطة أثناء تحديث التصيير المباشر أو عند عدم توفره. قد تختلف عن المشهد أو إعدادات الصورة الحالية.",
|
||||
"render.live.source_settings_help": "افتح «الإخراج» لتعديل المصيّر وإعدادات الصورة."
|
||||
},
|
||||
"de": {
|
||||
"render.live.source_current": "{method} · Live",
|
||||
"render.live.source_outdated": "{method} · Vorheriges Bild",
|
||||
"render.live.source_outdated_help": "Zeigt das zuletzt aufgenommene Bild, während das Live-Rendering aktualisiert wird oder nicht verfügbar ist. Es kann von der aktuellen Szene oder den Bildeinstellungen abweichen.",
|
||||
"render.live.source_settings_help": "Ausgabe öffnen, um Renderer und Bildeinstellungen zu bearbeiten."
|
||||
},
|
||||
"en": {
|
||||
"render.live.source_current": "{method} · Live",
|
||||
"render.live.source_outdated": "{method} · Previous image",
|
||||
"render.live.source_outdated_help": "Shows the last captured image while live rendering updates or is unavailable. It may differ from the current scene or image settings.",
|
||||
"render.live.source_settings_help": "Open Output to edit the renderer and image settings."
|
||||
},
|
||||
"he": {
|
||||
"render.live.source_current": "{method} · חי",
|
||||
"render.live.source_outdated": "{method} · תמונה קודמת",
|
||||
"render.live.source_outdated_help": "מציג את התמונה האחרונה שנלכדה בזמן שההדמיה החיה מתעדכנת או אינה זמינה. היא עשויה להיות שונה מהסצנה או מהגדרות התמונה הנוכחיות.",
|
||||
"render.live.source_settings_help": "יש לפתוח את חלונית הפלט כדי לערוך את מנוע ההדמיה ואת הגדרות התמונה."
|
||||
},
|
||||
"ja": {
|
||||
"render.live.source_current": "{method} · ライブ",
|
||||
"render.live.source_outdated": "{method} · 前の画像",
|
||||
"render.live.source_outdated_help": "ライブレンダリングの更新中や利用できない間は、最後に取得した画像を表示します。現在のシーンや画像設定とは異なる場合があります。",
|
||||
"render.live.source_settings_help": "出力を開いてレンダラーと画像設定を編集します。"
|
||||
},
|
||||
"ko": {
|
||||
"render.live.source_current": "{method} · 라이브",
|
||||
"render.live.source_outdated": "{method} · 이전 이미지",
|
||||
"render.live.source_outdated_help": "라이브 렌더링이 업데이트 중이거나 사용할 수 없는 동안 마지막으로 캡처한 이미지를 표시합니다. 현재 장면 또는 이미지 설정과 다를 수 있습니다.",
|
||||
"render.live.source_settings_help": "출력을 열어 렌더러와 이미지 설정을 편집합니다."
|
||||
},
|
||||
"vi": {
|
||||
"render.live.source_current": "{method} · Trực tiếp",
|
||||
"render.live.source_outdated": "{method} · Ảnh trước",
|
||||
"render.live.source_outdated_help": "Hiển thị ảnh đã chụp gần nhất trong khi kết xuất trực tiếp đang cập nhật hoặc không khả dụng. Ảnh có thể khác cảnh hoặc thiết lập ảnh hiện tại.",
|
||||
"render.live.source_settings_help": "Mở mục Đầu ra để sửa bộ kết xuất và thiết lập ảnh."
|
||||
},
|
||||
"zh": {
|
||||
"render.live.source_current": "{method} · 实时",
|
||||
"render.live.source_outdated": "{method} · 上一幅图像",
|
||||
"render.live.source_outdated_help": "实时渲染更新或不可用时显示上次捕获的图像。它可能与当前场景或图像设置不同。",
|
||||
"render.live.source_settings_help": "打开输出以编辑渲染器和图像设置。"
|
||||
}
|
||||
}
|
||||
+13
-13
@@ -2789,9 +2789,9 @@
|
||||
"render.adaptive_absolute": "Ngưỡng lỗi RGB tuyệt đối",
|
||||
"render.adaptive_coverage": "Mục tiêu lỗi độ phủ (Coverage error)",
|
||||
"render.adaptive_enable": "Phân bổ mẫu bằng các mẫu thử độc lập",
|
||||
"render.adaptive_help": "Chỉ dành cho GPU. Các mẫu thử độc lập sẽ bị loại bỏ, sau đó phân bổ tối thiểu 16 mẫu chính thức trên mỗi pixel cho đến khi đạt giới hạn mẫu. Phương sai RGB và độ phủ chỉ dùng để định hướng phân bổ; các đường truyền tia hiếm có thể bị bỏ sót và pixel đạt giới hạn vẫn có thể còn nhiễu. Báo cáo cuối cùng ghi nhận khối lượng tính toán thực tế. Không đảm bảo tuyệt đối về độ nhiễu hay thời gian.",
|
||||
"render.adaptive_help": "Các mẫu thăm dò độc lập được loại bỏ, sau đó chọn ít nhất 16 mẫu sản xuất cho mỗi điểm ảnh, tối đa bằng Số mẫu mỗi điểm ảnh. Giới hạn tài nguyên CPU bao gồm cả hai giai đoạn. Phương sai RGB và độ phủ hướng dẫn phân bổ; điểm ảnh đạt trần vẫn có thể nhiễu. Báo cáo cuối đếm số mẫu thực tế.",
|
||||
"render.adaptive_minimum": "Số mẫu chính thức tối thiểu",
|
||||
"render.adaptive_pending": "GPU hỗ trợ phân bổ thích ứng qua mẫu thử độc lập và phân tầng Fresnel có điều kiện. Chưa hỗ trợ kiểm định hội tụ, đảm bảo sự kiện hiếm và truyền dẫn thích ứng trên CPU.",
|
||||
"render.adaptive_pending": "Phân bổ thích ứng bằng mẫu thăm dò độc lập có trên CPU và GPU. Các tầng Fresnel có điều kiện cần GPU. Phân bổ thống kê không chứng nhận sự hội tụ.",
|
||||
"render.adaptive_pilots": "Số mẫu thử trên mỗi pixel",
|
||||
"render.adaptive_relative": "Mục tiêu lỗi RGB tương đối",
|
||||
"render.add_ground": "Thêm mặt sàn khớp vào thiết lập",
|
||||
@@ -2852,7 +2852,7 @@
|
||||
"render.appearance_editor.replace": "Thay thuộc tính quang học đã lưu của thân này",
|
||||
"render.appearance_editor.revalidate": "Kiểm tra lại thân này",
|
||||
"render.appearance_editor.save": "Lưu các thuộc tính quang học này",
|
||||
"render.appearance_editor.save_reason": "Lưu các thuộc tính quang học này cần kính vật lý trên một thân GPU hiện hành. Lớp phủ và bản đồ ảnh cục bộ vẫn tạm thời.",
|
||||
"render.appearance_editor.save_reason": "Để lưu Kính, hãy bật nó trên thân CPU hoặc GPU hiện hành. Với lớp phủ bóng, dùng Lưu lớp phủ vào cảnh chụp. Các tệp ảnh cục bộ vẫn tạm thời.",
|
||||
"render.appearance_editor.saved": "Lưu cùng cảnh chụp này",
|
||||
"render.appearance_editor.saved_state": "Các thuộc tính quang học này được lưu cùng cảnh chụp này.",
|
||||
"render.appearance_editor.scatter_reason": "Tán xạ bên trong cần biên là khối đặc kín.",
|
||||
@@ -2948,7 +2948,7 @@
|
||||
"render.children.copy_request": "Sao chép JSON yêu cầu",
|
||||
"render.children.coverage_error": "Sai số độ phủ",
|
||||
"render.children.cpu": "Dò tia bằng CPU",
|
||||
"render.children.denoise": "Giảm nhiễu trong ảnh hoàn chỉnh",
|
||||
"render.children.denoise": "Giảm nhiễu ảnh",
|
||||
"render.children.depth": "Độ sâu đường đi",
|
||||
"render.children.distortion": "Méo",
|
||||
"render.children.edge_samples": "Mẫu cạnh",
|
||||
@@ -3080,7 +3080,7 @@
|
||||
"render.cpu_point_help": "Tối đa tám nguồn sáng điểm, được bổ sung vào ánh sáng định hướng đã chọn. Khoảng cách tính bằng mm; khoảng cách tối thiểu không được vượt quá khoảng cách tham chiếu.",
|
||||
"render.cpu_primary_samples": "Số mẫu cạnh trên mỗi pixel",
|
||||
"render.cpu_progressive": "Kết xuất lũy tiến trên CPU",
|
||||
"render.cpu_progressive_help": "Tích lũy bộ tích phân CPU hiện có theo từng lượt một mẫu chính. Các phản xạ đã chọn yêu cầu một mẫu; nguồn sáng vùng, chất điện môi và bất đẳng hướng yêu cầu chế độ 'Hình ảnh đơn'.",
|
||||
"render.cpu_progressive_help": "Tích lũy các mẫu máy ảnh CPU mới và hiển thị từng lượt hoàn chỉnh. Phản xạ được chọn dùng một lần thử cho mỗi mẫu máy ảnh; kính được chứng nhận và dị hướng phẳng giữ các giới hạn hiện có. Mẫu thăm dò và mẫu sản xuất thích ứng dùng chung ngân sách cấu hình CPU.",
|
||||
"render.cpu_progressive_limits": "Tối đa 1.048.576 pixel và tổng số tính toán (pixel × mẫu) không quá 16.777.216. Thay đổi số mẫu không bao giờ tự động đổi kích thước ảnh.",
|
||||
"render.cpu_progressive_seed": "Hạt giống lấy mẫu CPU",
|
||||
"render.cpu_progressive_spp": "Mẫu trên mỗi pixel",
|
||||
@@ -3256,7 +3256,7 @@
|
||||
"render.export_display": "Xuất: ảnh PNG hiển thị",
|
||||
"render.export_image_label": "Xuất ảnh",
|
||||
"render.export_linear": "Xuất: ảnh EXR tuyến tính thô",
|
||||
"render.exr_pending": "GPU có thể giữ lại tệp EXR tuyến tính thô. Tính năng nén, ảnh nhiều lớp và các kênh kết xuất phụ trợ vẫn chưa được triển khai.",
|
||||
"render.exr_pending": "Dò tia trên CPU và dò đường đi trên GPU có thể giữ lại EXR tuyến tính thô. Tính năng nén, ảnh nhiều lớp và các lượt kết xuất phụ trợ vẫn chưa khả dụng.",
|
||||
"render.film_grain": "Hạt nhiễu nhiếp ảnh (Film Grain)",
|
||||
"render.fit": "Thu phóng vừa sản phẩm",
|
||||
"render.fixed_camera": "Máy ảnh",
|
||||
@@ -3619,7 +3619,7 @@
|
||||
"render.method_cpu": "Dò tia trên CPU",
|
||||
"render.method_gpu": "Dò đường truyền tia trên GPU (Path Tracing)",
|
||||
"render.method_limit_raster": "Quét mảng (Raster) dùng bản xem trước studio đục cố định. Chọn phương thức dò tia rõ ràng để dùng các điều khiển quang học được hỗ trợ.",
|
||||
"render.method_raster": "Quét mảng CPU (Raster)",
|
||||
"render.method_raster": "Raster trên CPU (dạng sơ đồ)",
|
||||
"render.navigation_help": "Camera di chuyển tương tự như trong Thiết kế (Tùy chọn cài đặt › Điều hướng). Lưu thiết lập sẽ ghi lại camera hiện tại.",
|
||||
"render.nested_enable": "Chất điện môi lồng nhau đã xác thực",
|
||||
"render.nested_help": "Tối đa 16 lớp chứa nhau; mỗi khối cha phải là khối lồi và mọi khối con phải nằm hoàn toàn bên trong. Từ chối giao cắt, bề mặt tiếp xúc, tấm mỏng và bóng đổ thẳng. Trạng thái camera bên trong tuân theo hệ thống phân cấp đã xác thực. Không tạo bề mặt chất lỏng hoặc hợp nhất các ranh giới CAD tiếp xúc.",
|
||||
@@ -3647,7 +3647,7 @@
|
||||
"render.output_exr_unavailable": "Thiết lập đầu ra EXR không khả dụng ở đây. Xuất EXR tuyến tính cần ảnh GPU đã hoàn tất với bản gốc tuyến tính được giữ lại.",
|
||||
"render.output_fit": "Vừa khít",
|
||||
"render.output_format": "Định dạng",
|
||||
"render.output_format_status": "PNG là hình ảnh hiển thị hoàn chỉnh chính xác. EXR tùy chọn trên GPU giữ lại suất bức xạ FLOAT32 tuyến tính cảnh thô trước các bộ lọc hiển thị. Xuất không bao giờ thay thế bằng bộ nhớ đệm xem trước hoặc tác vụ khác.",
|
||||
"render.output_format_status": "PNG là ảnh hiển thị hoàn chỉnh chính xác. EXR tùy chọn trên CPU hoặc GPU giữ lại suất bức xạ FLOAT32 thô trong không gian tuyến tính của cảnh trước các bộ lọc hiển thị. Xuất không bao giờ thay thế ảnh bằng bộ nhớ đệm xem trước hoặc tác vụ khác.",
|
||||
"render.output_grading": "Chỉnh màu",
|
||||
"render.output_image": "Ảnh",
|
||||
"render.output_largest": "Tối đa {width} × {height}",
|
||||
@@ -3732,7 +3732,7 @@
|
||||
"render.quality_custom": "Tùy chỉnh",
|
||||
"render.quality_draft": "Bản nháp",
|
||||
"render.quality_high": "Cao",
|
||||
"render.quality_projection_help": "Các thiết lập sẵn Q bao gồm các yêu cầu tương lai. Hành động này chỉ chiếu định mức mẫu PNG cố định được hỗ trợ và lược bỏ rõ ràng việc dừng thích ứng, đầu ra tuyến tính thô và các giới hạn truyền dẫn độc lập.",
|
||||
"render.quality_projection_help": "Bản xem xét này chỉ thay đổi độ phân giải, ngân sách mẫu, độ sâu đường đi, hạt giống và giảm nhiễu. Các thiết lập khác được giữ nguyên; ngân sách không chứng nhận ảnh đã hội tụ.",
|
||||
"render.quality_review": "Xem xét định mức lấy mẫu",
|
||||
"render.queue_pending": "Không có hàng đợi sản xuất tự động hay bộ kết xuất hoạt hình trong giao diện này. Ảnh tĩnh đã hoàn thành vẫn có thể xuất riêng lẻ.",
|
||||
"render.radial_assign": "Gán diện mạo",
|
||||
@@ -3763,7 +3763,7 @@
|
||||
"render.result_navigation": "Thu phóng và lia theo tùy chọn Điều hướng. Nhấn phím cách + kéo rê để lia.",
|
||||
"render.result_open": "Mở hình ảnh đã hoàn thành trong khung vẽ chính.",
|
||||
"render.result_preview": "Xem trước trực tiếp",
|
||||
"render.result_provisional": "Hình ảnh CPU tạm thời",
|
||||
"render.result_provisional": "Ảnh tạm",
|
||||
"render.result_provisional_help": "Điểm kiểm tra trung gian này không thể xuất. Lệnh xuất giữ lại kết quả hoàn thành gần nhất; hủy bỏ sẽ quay lại kết quả đó.",
|
||||
"render.result_reduced_display": "Bản xem trước hiển thị bị thu nhỏ theo giới hạn bộ nhớ đệm/thiết bị của khung nhìn. Tệp PNG được giữ lại và tệp xuất vẫn giữ nguyên độ phân giải đầy đủ; chế độ kiểm tra gốc 100% không khả dụng cho chế độ xem thu nhỏ này.",
|
||||
"render.result_retained": "Đang hiển thị hình ảnh hoàn thành gần nhất; kết xuất mới hơn chưa thay thế nó.",
|
||||
@@ -3927,7 +3927,7 @@
|
||||
"render.stage_panels.prepare_plan": "Chuẩn bị kế hoạch tạo",
|
||||
"render.stage_panels.projects": "Chiếu",
|
||||
"render.stage_panels.quality_ack": "Tôi xác nhận ngân sách cố định và các mục bị lược bỏ",
|
||||
"render.stage_panels.quality_omissions": "Dừng thích ứng và mục tiêu sai số, độ sâu truyền dẫn riêng, quy tắc roulette Nga, đầu ra tuyến tính thô, bản gốc EXR và chứng nhận hội tụ.",
|
||||
"render.stage_panels.quality_omissions": "Ngân sách này không đặt mục tiêu sai số thích ứng, giới hạn truyền sáng độc lập, quy tắc roulette Nga hay việc lưu giữ bản gốc thô, và không bảo đảm ảnh hội tụ.",
|
||||
"render.stage_panels.reference": "Tham chiếu",
|
||||
"render.stage_panels.references": "Các tham chiếu",
|
||||
"render.stage_panels.remove_optics": "Xóa cài đặt quang học",
|
||||
@@ -3971,7 +3971,7 @@
|
||||
"render.stage_panels.subtitle_environment": "Môi trường HDR nguyên bản · toàn bộ cảnh",
|
||||
"render.stage_panels.subtitle_maps": "Bản đồ không gian đã tạo · xem trước tạm thời",
|
||||
"render.stage_panels.subtitle_physical": "Kế hoạch chỉ đọc · không tạo hay gán vật liệu",
|
||||
"render.stage_panels.subtitle_quality": "Ngân sách GPU cố định · lược bỏ các yêu cầu tương lai",
|
||||
"render.stage_panels.subtitle_quality": "Ngân sách lấy mẫu GPU · chỉ các trường được liệt kê",
|
||||
"render.stage_panels.subtitle_rig": "Bộ đèn chiếu sáng đã thiết kế · bộ phát sáng và tấm thụ động",
|
||||
"render.stage_panels.subtitle_saved_maps": "Bản đồ đã tạo · lưu cùng khung hình này",
|
||||
"render.stage_panels.subtitle_stage": "Mặt sàn và sân khấu vật lý · khớp với sản phẩm",
|
||||
@@ -3996,7 +3996,7 @@
|
||||
"render.studio_area_help": "Tối đa bốn hình chữ nhật một mặt. Nửa cạnh trực giao U và V xác định kích thước; U × V hướng về phía chiếu sáng. Tọa độ tính theo milimét không gian thế giới.",
|
||||
"render.studio_center": "Tâm X, Y, Z (mm)",
|
||||
"render.studio_denoise": "Giảm nhiễu trong ảnh thành phẩm",
|
||||
"render.studio_denoise_help": "Bộ lọc cuối cùng bảo toàn cạnh; chi tiết tinh xảo có thể bị làm mềm. Thủy tinh và bề mặt phản xạ gương sắc nét bỏ qua bộ lọc này.",
|
||||
"render.studio_denoise_help": "Ảnh lũy tiến và ảnh cuối sử dụng bộ lọc bảo toàn biên; chi tiết nhỏ có thể bị làm mềm. Kính và bề mặt phản xạ sắc nét không qua bộ lọc này.",
|
||||
"render.studio_direction": "Hướng về phía mặt trời (X, Y, Z)",
|
||||
"render.studio_environment": "Môi trường",
|
||||
"render.studio_environment_bounds": "Góc xoay môi trường phải hữu hạn trong khoảng ±1.000.000 radian và cường độ từ 0 đến 100.",
|
||||
|
||||
+13
-13
@@ -2789,9 +2789,9 @@
|
||||
"render.adaptive_absolute": "RGB 绝对误差下限",
|
||||
"render.adaptive_coverage": "覆盖率误差目标",
|
||||
"render.adaptive_enable": "使用独立预采样分配样本",
|
||||
"render.adaptive_help": "仅限 GPU。丢弃独立预采样后,每像素分配至少16个正式样本,最多达到每像素样本数上限。RGB 和覆盖率方差仅用于指导分配;可能遗漏罕见路径,达到上限后仍可能有噪声。最终报告记录实际工作量,不保证噪声或耗时。",
|
||||
"render.adaptive_help": "独立的先导样本会被丢弃,随后为每个像素分配至少16个、不超过“每像素样本数”的正式样本。CPU资源限制包含两个阶段。RGB与覆盖率方差决定分配,达到上限的像素仍可能有噪点。最终报告记录实际样本数。",
|
||||
"render.adaptive_minimum": "最少正式样本数",
|
||||
"render.adaptive_pending": "GPU 支持独立预采样自适应分配和条件菲涅耳分层。尚不支持认证收敛、罕见事件保证及 CPU 自适应光传输。",
|
||||
"render.adaptive_pending": "CPU和GPU均支持独立先导样本的自适应分配。条件菲涅耳分层需要GPU。统计分配不保证收敛。",
|
||||
"render.adaptive_pilots": "每像素预采样数",
|
||||
"render.adaptive_relative": "RGB 相对误差目标",
|
||||
"render.add_ground": "向设置添加适配地面",
|
||||
@@ -2852,7 +2852,7 @@
|
||||
"render.appearance_editor.replace": "替换此实体已保存的光学设置",
|
||||
"render.appearance_editor.revalidate": "重新验证此实体",
|
||||
"render.appearance_editor.save": "保存这些光学设置",
|
||||
"render.appearance_editor.save_reason": "保存需要当前 GPU 实体上的物理玻璃。清漆和本地图像贴图仍为临时设置。",
|
||||
"render.appearance_editor.save_reason": "要保存玻璃,请在当前CPU或GPU实体上启用它。清漆请使用“将清漆保存到镜头”。本地图像文件仍为临时设置。",
|
||||
"render.appearance_editor.saved": "随此镜头保存",
|
||||
"render.appearance_editor.saved_state": "这些光学设置已随此镜头保存。",
|
||||
"render.appearance_editor.scatter_reason": "内部散射需要封闭实体边界。",
|
||||
@@ -2948,7 +2948,7 @@
|
||||
"render.children.copy_request": "复制请求JSON",
|
||||
"render.children.coverage_error": "覆盖误差",
|
||||
"render.children.cpu": "CPU光线追踪",
|
||||
"render.children.denoise": "降低成品图像噪点",
|
||||
"render.children.denoise": "降低图像噪点",
|
||||
"render.children.depth": "路径深度",
|
||||
"render.children.distortion": "畸变",
|
||||
"render.children.edge_samples": "边缘采样",
|
||||
@@ -3080,7 +3080,7 @@
|
||||
"render.cpu_point_help": "最多可在所选平行光照明基础上添加8个点光源。距离单位为mm,最小距离不得大于参考距离。",
|
||||
"render.cpu_primary_samples": "每像素边缘采样",
|
||||
"render.cpu_progressive": "CPU渐进渲染",
|
||||
"render.cpu_progressive_help": "使用现有CPU积分器,每轮累积一个主采样。所选反射需要单采样;面光源、电介质和各向异性需要固定图像模式。",
|
||||
"render.cpu_progressive_help": "累积新的CPU相机样本并显示每个完整的渲染轮次。选定反射在每个相机样本中尝试一次;经认证的玻璃和平面各向异性保留原有限制。自适应先导样本和正式样本共享CPU配置预算。",
|
||||
"render.cpu_progressive_limits": "最多1,048,576像素,像素×采样总数不超过16,777,216。更改采样数不会自动调整图像尺寸。",
|
||||
"render.cpu_progressive_seed": "CPU采样种子",
|
||||
"render.cpu_progressive_spp": "每像素采样数",
|
||||
@@ -3256,7 +3256,7 @@
|
||||
"render.export_display": "导出:显示 PNG",
|
||||
"render.export_image_label": "导出图像",
|
||||
"render.export_linear": "导出:原始线性 EXR",
|
||||
"render.exr_pending": "GPU 可保留线性 EXR;压缩、多层和辅助通道仍未实现。",
|
||||
"render.exr_pending": "CPU 光线追踪和 GPU 路径追踪可保留原始线性 EXR;压缩、多层和辅助通道仍未实现。",
|
||||
"render.film_grain": "摄影颗粒",
|
||||
"render.fit": "适配产品视图",
|
||||
"render.fixed_camera": "相机",
|
||||
@@ -3619,7 +3619,7 @@
|
||||
"render.method_cpu": "CPU 光线追踪",
|
||||
"render.method_gpu": "GPU 路径追踪",
|
||||
"render.method_limit_raster": "光栅化使用固定的不透明工作室预览;要使用受支持的光学控件,请明确选择光线追踪方式。",
|
||||
"render.method_raster": "CPU 光栅",
|
||||
"render.method_raster": "CPU 光栅(示意图)",
|
||||
"render.navigation_help": "相机的操作与设计中相同(首选项 › 导航)。保存设置会记录当前相机。",
|
||||
"render.nested_enable": "经验证的嵌套介质",
|
||||
"render.nested_help": "最多16层包含关系。父体必须凸,子体必须严格位于内部。拒绝相交、接触、薄片和直线透射阴影。内部相机使用经验证的层次结构。不会创建液面或合并接触的CAD边界。",
|
||||
@@ -3647,7 +3647,7 @@
|
||||
"render.output_exr_unavailable": "此处无法设置 EXR 输出。线性 EXR 导出需要保留线性母版的已完成 GPU 图像。",
|
||||
"render.output_fit": "适合窗口",
|
||||
"render.output_format": "格式",
|
||||
"render.output_format_status": "PNG 是完成的显示图像。可选 GPU EXR 保留显示滤镜前的原始线性 FLOAT32 辐亮度;导出不会替换成预览缓存或其他任务。",
|
||||
"render.output_format_status": "PNG 是完成的显示图像。可选 CPU/GPU EXR 保留显示滤镜前的原始线性 FLOAT32 辐亮度;导出不会替换成预览缓存或其他任务。",
|
||||
"render.output_grading": "调色",
|
||||
"render.output_image": "图像",
|
||||
"render.output_largest": "{width} × {height} 最大",
|
||||
@@ -3732,7 +3732,7 @@
|
||||
"render.quality_custom": "自定义",
|
||||
"render.quality_draft": "草稿",
|
||||
"render.quality_high": "高",
|
||||
"render.quality_projection_help": "Q 预设包含未来需求。此操作仅映射受支持的固定样本 PNG 预算,明确省略自适应停止、原始线性输出和独立透射限制。",
|
||||
"render.quality_projection_help": "此检查仅更改分辨率、采样预算、路径深度、种子和降噪。其他设置保持不变,预算不保证收敛。",
|
||||
"render.quality_review": "审阅采样预算",
|
||||
"render.queue_pending": "此界面没有自动生产队列或动画渲染器;已完成的静止图像仍可单独导出。",
|
||||
"render.radial_assign": "指定外观",
|
||||
@@ -3763,7 +3763,7 @@
|
||||
"render.result_navigation": "平移和缩放遵循导航设置。按住空格键并拖动可平移。",
|
||||
"render.result_open": "在主画布中打开已完成的图像。",
|
||||
"render.result_preview": "实时预览",
|
||||
"render.result_provisional": "CPU临时图像",
|
||||
"render.result_provisional": "临时图像",
|
||||
"render.result_provisional_help": "此中间结果无法导出。导出始终使用最后完成的结果;取消后会返回该结果。",
|
||||
"render.result_reduced_display": "显示预览已缩小到视口设备或缓存限制。保留的 PNG 和导出仍为完整分辨率;此缩小视图不能进行原生 100% 检查。",
|
||||
"render.result_retained": "正在显示最后完成的图像;较新的渲染尚未替换它。",
|
||||
@@ -3927,7 +3927,7 @@
|
||||
"render.stage_panels.prepare_plan": "准备创建计划",
|
||||
"render.stage_panels.projects": "投影",
|
||||
"render.stage_panels.quality_ack": "我确认此固定预算及其省略内容",
|
||||
"render.stage_panels.quality_omissions": "自适应停止和误差目标、独立透射深度、俄罗斯轮盘规则、原始线性输出、EXR 母版及收敛认证。",
|
||||
"render.stage_panels.quality_omissions": "此预算不设置自适应误差目标、独立透射限制、俄罗斯轮盘规则或线性母版保留,也不保证收敛。",
|
||||
"render.stage_panels.reference": "参考",
|
||||
"render.stage_panels.references": "参考",
|
||||
"render.stage_panels.remove_optics": "移除光学设置",
|
||||
@@ -3971,7 +3971,7 @@
|
||||
"render.stage_panels.subtitle_environment": "原创 HDR 环境 · 整个场景",
|
||||
"render.stage_panels.subtitle_maps": "创作的空间贴图 · 临时预览",
|
||||
"render.stage_panels.subtitle_physical": "只读计划 · 未创建或分配材料",
|
||||
"render.stage_panels.subtitle_quality": "固定 GPU 预算 · 省略未来要求",
|
||||
"render.stage_panels.subtitle_quality": "GPU 采样预算 · 仅更改列出的设置",
|
||||
"render.stage_panels.subtitle_rig": "创作的灯光装置 · 发光体和被动卡片",
|
||||
"render.stage_panels.subtitle_saved_maps": "创作的贴图 · 保存在此镜头中",
|
||||
"render.stage_panels.subtitle_stage": "物理地面和舞台 · 适配产品",
|
||||
@@ -3996,7 +3996,7 @@
|
||||
"render.studio_area_help": "最多四个单面矩形。正交半边 U 和 V 定义尺寸;U × V 指向发光侧。坐标采用世界空间毫米。",
|
||||
"render.studio_center": "中心 X, Y, Z(mm)",
|
||||
"render.studio_denoise": "降低最终图像噪点",
|
||||
"render.studio_denoise_help": "最终滤波保留边缘,但可能柔化细节。玻璃与锐利镜面不参与滤波。",
|
||||
"render.studio_denoise_help": "渐进图像和最终图像使用保留边缘的滤波,但可能柔化细节。玻璃与锐利镜面不参与滤波。",
|
||||
"render.studio_direction": "朝向太阳的方向 (X, Y, Z)",
|
||||
"render.studio_environment": "环境",
|
||||
"render.studio_environment_bounds": "环境旋转必须为±1,000,000弧度以内的有限值,强度必须在0至100之间。",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! CPU render jobs can save optional `options.output` presentation: tone map, finishing,
|
||||
//! backdrop and raw linear EXR retention. Older designs omit it and retain the exact previous
|
||||
//! f64 PNG output; exposure remains owned by RenderSetup. The last released format refuses a
|
||||
//! design containing these values instead of dropping authored output effects or master intent.
|
||||
//! CPU jobs also save optional `options.lens` using the shared physical thin-lens
|
||||
//! aperture radius and axial focus distance. Positive aperture requires perspective
|
||||
//! plus explicit progressive camera sampling; absent/zero aperture keeps old pixels.
|
||||
//! CPU raytrace jobs also retain default-false `transparent_background` beside options.
|
||||
//! It exports straight-alpha primary geometry coverage; absent fields keep opaque PNGs.
|
||||
//! CPU jobs also save opt-in `options.denoise`; omission/false preserves the prior
|
||||
//! raw PNG calculation. Every complete published pass is filtered without changing
|
||||
//! raw film or the final unfiltered EXR. Older files need no rewrite for this flag.
|
||||
//! CPU jobs also preserve optional `options.path: {max_depth: 1..16}` for explicit
|
||||
//! progressive diffuse/specular transport and certified Glass boundaries. The depth
|
||||
//! counts shaded surfaces, optical boundaries and active scattering events. Traced shadows are required;
|
||||
//! selected reflection/anisotropy overrides and unsupported closed-Glass
|
||||
//! shadow connections refuse with authored intent retained. Omission keeps the legacy producer.
|
||||
//!
|
||||
//! CPU raytrace jobs save optional `options.aging` using the existing ordered
|
||||
//! SavedAgingStack schema and stable definition/feature/component/occurrence targets.
|
||||
//! Empty/absent stacks preserve prior saved JSON, CPU pixel arithmetic and work.
|
||||
//! Check and Start resolve stable targets against the captured scene; stale/hidden
|
||||
//! targets are refused rather than made global. Captured indices/names remain
|
||||
//! runtime-only. Aging changes the opaque BRDF before lighting and denoising guides;
|
||||
//! it does not change geometry, emission, physical materials or mass. Certified
|
||||
//! glass targets and passive stages remain excluded; independently authored anisotropic axes
|
||||
//! cannot be replaced by scalar aging roughness and are refused explicitly.
|
||||
//!
|
||||
//! Saves up to eight hard GPU directional sources alongside the existing optional
|
||||
//! finite Sun (eight combined). Older designs omit the new list and retain their
|
||||
//! exact implicit studio or finite-Sun lighting. The last released reader asks for
|
||||
//! an update rather than silently discarding these sources.
|
||||
//! Also saves up to eight GPU point/spot sources with exact world-mm positions,
|
||||
//! reference/minimum distances and optional world-axis/radian half cones. Missing
|
||||
//! punctual lists stay empty. CPU cone intent retains its wider f64 domain; GPU
|
||||
//! cone feathers below the documented precision bound refuse without rewriting.
|
||||
//!
|
||||
//! CPU jobs may also consume the existing portable saved `surfaces` bindings for
|
||||
//! opaque built-in base color, roughness, metallic and normal maps. No new map
|
||||
//! schema is added; raster and unsupported CPU optical combinations refuse the
|
||||
//! authored bindings instead of dropping them. Old files without maps keep the
|
||||
//! old CPU allocation/work/pixel path.
|
||||
//! CPU jobs also save optional `options.adaptive` using the existing independent-pilot
|
||||
//! policy. Explicit progressive production sampling is required; the unchanged CPU
|
||||
//! profile budgets pilots plus the production cap. Exact per-pixel counts and pilot
|
||||
//! statistics stay runtime-only; final SamplingReport and PreviewInfo.adaptive reuse
|
||||
//! the existing metadata contract without changing linear-master schema/channels.
|
||||
|
||||
//! CPU `options.path` may consume the existing saved ClosedMesh optical boundary
|
||||
//! after exact f64 edge, vertex-link, embedding/contact, outward and conservative
|
||||
//! nested-parent certification. Runtime-only ClosedMesh and thin-shadow lowering
|
||||
//! preserve stable authored optics. No new saved field or geometry repair is added.
|
||||
//! Absent-path sheet/box transport and default serialized bytes remain unchanged.
|
||||
//! Closed-shadow connections, Glass combined with external volume and selected
|
||||
//! reflection/anisotropy combinations refuse explicitly with authored values retained.
|
||||
//! Perspective CPU Path captures classify each physical eye/pupil cooperatively
|
||||
//! against the certified boundaries and initialize the nested Beer medium stack.
|
||||
//! Exact origin/near-clip boundary contacts refuse without changing saved cameras
|
||||
//! or optics. This runtime-only extension adds no serialized field.
|
||||
//! Existing saved OpticalMaterial.subsurface may also lower to optional captured
|
||||
//! CPU dielectric subsurface parameters with explicit progressive Path. Selected
|
||||
//! certified closed media use scalar free flight, HG directions and separate Beer
|
||||
//! absorption, including physical eye/pupil starts and nested current media. No
|
||||
//! refracting-boundary next-event approximation is added. Active scattering and
|
||||
//! surface denoising refuse together; unsupported thin/legacy routes retain intent.
|
||||
//! Some(rate0) remains serialized but adds no collision work/RNG or pixel change.
|
||||
//! CPU jobs may also preserve optional options.volume using the same bounded
|
||||
//! HomogeneousVolume world-box/extinction/RGB albedo/HG contract as GPU Atmosphere.
|
||||
//! Positive extinction requires explicit progressive Path and disables no existing
|
||||
//! option implicitly: Glass/SSS and surface denoising refuse with intent retained.
|
||||
//! Absent/zero volume keeps the original transport work/RNG/heap owner count/pixels.
|
||||
//! Inline intent's actual fixed retained-request size is prepaid even when absent;
|
||||
//! regrade owns its separate header reservation until its last status handle drops.
|
||||
//! Physical eye/pupil prefixes and the orthographic signed line/box union do not
|
||||
//! alter saved camera/framing/model bounds or its model near interval. Existing
|
||||
//! ray-distance/profile/task/frame/codec/host caps remain unchanged.
|
||||
|
||||
//! CPU jobs also retain optional `sun` and `sky` intent. `sun` reuses the shared
|
||||
//! finite-cap direction/angular-radius/radiance domain; `sky` reuses the portable
|
||||
//! gradient/rotation/intensity schema. Both require explicit CPU Path transport.
|
||||
//! Omitted fields preserve old designs and the absent producer allocation/work/RNG
|
||||
//! path. Constant, Sky and HDR sources are exclusive; finite Sun is additive.
|
||||
//! No generated sky image or runtime resource is serialized.
|
||||
//!
|
||||
//! CPU and GPU jobs also save `coats`, up to the existing 64 runtime coat target
|
||||
//! bound, with stable definition/feature/component/occurrence identities and exact
|
||||
//! weight, roughness and dielectric IOR. Empty/absent lists keep prior JSON and
|
||||
//! capture behavior. Coats require opaque targets; even a zero-weight authored coat
|
||||
//! cannot share a target with Glass. Capture resolves targets without changing
|
||||
//! appearances, engineering materials or geometry. Unsupported CPU selected
|
||||
//! reflection/anisotropy overrides refuse instead of dropping the coat.
|
||||
@@ -72,6 +72,7 @@ pub use print_face::PrintFace;
|
||||
pub mod project;
|
||||
mod project3d;
|
||||
pub mod render_aging;
|
||||
pub mod render_coat;
|
||||
pub mod render_job;
|
||||
pub mod render_optics;
|
||||
pub mod render_resources;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Portable opaque clear-coat assignments. Targets are stable definition/feature/
|
||||
//! component/occurrence identities; captured mesh ordinals never enter a design.
|
||||
use crate::{
|
||||
DocError, Result,
|
||||
render_surface::{RenderBodyTarget, bounded_sequence},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_render::clear_coat::ClearCoat;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Matches the existing runtime clear-coat override bound for either executor.
|
||||
pub const MAX_COAT_BINDINGS: usize = 64;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct CoatMaterial {
|
||||
pub weight: f64,
|
||||
pub roughness: f64,
|
||||
pub ior: f64,
|
||||
}
|
||||
impl PartialEq for CoatMaterial {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.weight.to_bits() == other.weight.to_bits()
|
||||
&& self.roughness.to_bits() == other.roughness.to_bits()
|
||||
&& self.ior.to_bits() == other.ior.to_bits()
|
||||
}
|
||||
}
|
||||
impl Default for CoatMaterial {
|
||||
fn default() -> Self {
|
||||
Self { weight: 1.0, roughness: 0.12, ior: 1.5 }
|
||||
}
|
||||
}
|
||||
impl CoatMaterial {
|
||||
pub fn validate(self) -> Result<()> {
|
||||
self.to_runtime().map(|_| ())
|
||||
}
|
||||
pub fn to_runtime(self) -> Result<ClearCoat> {
|
||||
ClearCoat::new(self.weight, self.roughness, self.ior).map_err(|e| DocError::Invalid(e.to_string()))
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SavedCoat {
|
||||
pub target: RenderBodyTarget,
|
||||
pub coat: CoatMaterial,
|
||||
}
|
||||
impl SavedCoat {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
self.target.validate()?;
|
||||
self.coat.validate()
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct CoatBindings(Vec<SavedCoat>);
|
||||
impl CoatBindings {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
pub fn iter(&self) -> impl Iterator<Item = &SavedCoat> {
|
||||
self.0.iter()
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.len() > MAX_COAT_BINDINGS || self.0.capacity() > MAX_COAT_BINDINGS {
|
||||
return Err(DocError::Invalid("at most 64 saved clear-coat targets".into()));
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for binding in self.iter() {
|
||||
binding.validate()?;
|
||||
if !seen.insert(&binding.target) {
|
||||
return Err(DocError::Invalid("duplicate saved clear-coat target".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Validate before mutation; replacement is explicit and stays on the same target.
|
||||
pub fn set(&mut self, binding: SavedCoat, replace: bool) -> Result<()> {
|
||||
binding.validate()?;
|
||||
if let Some(old) = self.0.iter_mut().find(|old| old.target == binding.target) {
|
||||
if old == &binding {
|
||||
return Ok(());
|
||||
}
|
||||
if !replace {
|
||||
return Err(DocError::Invalid("clear-coat target already has a binding; explicit replacement required".into()));
|
||||
}
|
||||
*old = binding;
|
||||
} else {
|
||||
if self.len() >= MAX_COAT_BINDINGS {
|
||||
return Err(DocError::Invalid("at most 64 saved clear-coat targets".into()));
|
||||
}
|
||||
self.0.try_reserve_exact(1).map_err(|_| DocError::Invalid("could not reserve saved clear-coat binding".into()))?;
|
||||
if self.0.capacity() > MAX_COAT_BINDINGS {
|
||||
return Err(DocError::Invalid("saved clear-coat capacity exceeds admission".into()));
|
||||
}
|
||||
self.0.push(binding);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn remove(&mut self, target: &RenderBodyTarget) -> bool {
|
||||
let before = self.len();
|
||||
self.0.retain(|binding| &binding.target != target);
|
||||
self.len() != before
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for CoatBindings {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
let result = Self(bounded_sequence::<D, SavedCoat, MAX_COAT_BINDINGS>(d)?);
|
||||
result.validate().map_err(serde::de::Error::custom)?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use solidcraft_render::{
|
||||
Camera,
|
||||
job::{
|
||||
CpuBudget, CpuProgressiveOptions, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions, RaytraceEnvironmentOptions,
|
||||
RaytraceOptions, RectangleLightOptions, StudioLighting, ThinLens, ToneMap,
|
||||
RaytraceOptions, RectangleLightOptions, StudioLighting, Sun, ThinLens, ToneMap,
|
||||
},
|
||||
lighting::environment::SkyGradient,
|
||||
};
|
||||
@@ -28,12 +28,15 @@ pub enum RenderJob {
|
||||
},
|
||||
CpuRaytrace {
|
||||
#[serde(default)]
|
||||
options: CpuJob,
|
||||
transparent_background: bool,
|
||||
// Keep numerical CPU intent out of every raster/GPU enum value too.
|
||||
#[serde(default)]
|
||||
options: Box<CpuJob>,
|
||||
},
|
||||
GpuPath {
|
||||
#[serde(default)]
|
||||
transparent_background: bool,
|
||||
// StudioLighting is a bounded four-light value; boxing keeps the other variants small.
|
||||
// StudioLighting contains bounded inline area/directional sources; boxing keeps the other variants small.
|
||||
#[serde(default)]
|
||||
options: Box<GpuJob>,
|
||||
},
|
||||
@@ -44,7 +47,19 @@ impl RenderJob {
|
||||
match self {
|
||||
Self::Raster { .. } if exposure_ev != 0.0 => Err(DocError::Invalid("saved raster rendering requires exposure_ev: 0".into())),
|
||||
Self::Raster { .. } => Ok(()),
|
||||
Self::CpuRaytrace { options } => options.to_runtime(exposure_ev).map(|_| ()),
|
||||
Self::CpuRaytrace { options, transparent_background } => {
|
||||
options.validate(exposure_ev)?;
|
||||
if let Some(output) = &options.output {
|
||||
output
|
||||
.presentation(exposure_ev, [0; 3], *transparent_background)
|
||||
.validate()
|
||||
.map_err(|error| DocError::Invalid(error.to_string()))?;
|
||||
}
|
||||
if let Some(lens) = options.lens {
|
||||
lens.validate_camera(camera).map_err(|error| DocError::Invalid(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::GpuPath { options, transparent_background } => {
|
||||
if *transparent_background && options.backdrop.is_some() {
|
||||
return Err(DocError::Invalid("choose a camera backdrop or transparent PNG, not both".into()));
|
||||
@@ -67,6 +82,24 @@ impl RenderJob {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct CpuJob {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub volume: Option<solidcraft_render::job::volume::HomogeneousVolume>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sun: Option<Sun>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sky: Option<SkyEnvironment>,
|
||||
#[serde(skip_serializing_if = "crate::render_aging::SavedAgingStack::is_empty")]
|
||||
pub aging: crate::render_aging::SavedAgingStack,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub adaptive: Option<solidcraft_render::job::adaptive::AdaptiveSampling>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<solidcraft_render::job::CpuPathOptions>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub denoise: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lens: Option<ThinLens>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<Box<solidcraft_render::job::OutputOptions>>,
|
||||
#[serde(skip_serializing_if = "CpuBudget::is_compact")]
|
||||
pub budget: CpuBudget,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -94,6 +127,15 @@ pub struct CpuJob {
|
||||
impl Default for CpuJob {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
volume: None,
|
||||
sun: None,
|
||||
sky: None,
|
||||
aging: Default::default(),
|
||||
adaptive: None,
|
||||
denoise: false,
|
||||
path: None,
|
||||
lens: None,
|
||||
output: None,
|
||||
budget: CpuBudget::Compact,
|
||||
progressive: None,
|
||||
primary_samples: 1,
|
||||
@@ -111,12 +153,28 @@ impl Default for CpuJob {
|
||||
}
|
||||
|
||||
impl CpuJob {
|
||||
/// Validate saved parameters without inventing captured targets.
|
||||
pub fn validate(&self, exposure_ev: f64) -> Result<()> {
|
||||
self.validated_parameters(exposure_ev).map(|_| ())
|
||||
}
|
||||
/// Validate portable map compatibility without claiming that stable aging
|
||||
/// identities have been resolved into captured runtime body indices.
|
||||
pub fn validate_surface_maps(&self, exposure_ev: f64) -> Result<()> {
|
||||
let options = self.validated_parameters(exposure_ev)?;
|
||||
solidcraft_render::job::textures::validate_cpu_options(&options).map_err(|e| DocError::Invalid(e.to_string()))
|
||||
}
|
||||
/// Pure lowering, valid on hosts without a CPU raytracer. Checks bounds before cloning lists.
|
||||
pub fn to_runtime(&self, exposure_ev: f64) -> Result<RaytraceOptions> {
|
||||
if self.aging.iter().any(|layer| layer.target.is_some()) {
|
||||
return Err(DocError::Invalid("targeted CPU aging requires stable occurrence resolution against the current scene".into()));
|
||||
}
|
||||
self.validated_parameters(exposure_ev)
|
||||
}
|
||||
fn validated_parameters(&self, exposure_ev: f64) -> Result<RaytraceOptions> {
|
||||
if let Some(hdr) = self.hdr {
|
||||
hdr.validate()?;
|
||||
if self.environment.is_some() {
|
||||
return Err(DocError::Invalid("choose either saved CPU HDR or constant environment".into()));
|
||||
if self.environment.is_some() || self.sky.is_some() {
|
||||
return Err(DocError::Invalid("choose either saved CPU HDR, gradient Sky or constant environment".into()));
|
||||
}
|
||||
}
|
||||
bounded(&self.lights, 8, "directional lights")?;
|
||||
@@ -128,7 +186,26 @@ impl CpuJob {
|
||||
// Preserve hidden editable intent in CpuJob, but do not submit tuning for
|
||||
// a light family that is explicitly absent. This does not rewrite the file.
|
||||
let active_area = self.area_lights.as_ref().is_some_and(|lights| !lights.is_empty());
|
||||
let aging = self.aging.parameters_only()?;
|
||||
let sky = self
|
||||
.sky
|
||||
.as_ref()
|
||||
.map(|sky| -> Result<_> {
|
||||
sky.validate()?;
|
||||
let SkyEnvironment::Sky { gradient, rotation_rad, intensity } = *sky;
|
||||
Ok(Box::new(solidcraft_render::job::CpuSky { gradient, rotation_rad, intensity }))
|
||||
})
|
||||
.transpose()?;
|
||||
let options = RaytraceOptions {
|
||||
volume: self.volume,
|
||||
sun: self.sun.map(Box::new),
|
||||
sky,
|
||||
aging: (!aging.is_empty()).then(|| Box::new(aging)),
|
||||
adaptive: self.adaptive.map(Box::new),
|
||||
denoise: self.denoise,
|
||||
path: self.path,
|
||||
lens: self.lens,
|
||||
output: self.output.clone(),
|
||||
budget: self.budget,
|
||||
progressive: self.progressive,
|
||||
primary_samples: self.primary_samples,
|
||||
@@ -346,3 +423,50 @@ fn punctual_lights<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resul
|
||||
fn area_lights<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Option<Vec<RectangleLightOptions>>, D::Error> {
|
||||
optional_lights::<D, RectangleLightOptions, 4>(d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cpu_volume_portable_tests {
|
||||
use super::*;
|
||||
use solidcraft_render::job::{CpuPathOptions, CpuProgressiveOptions, volume::HomogeneousVolume};
|
||||
#[test]
|
||||
fn cpu_volume_saved_parameters_roundtrip_lower_without_default_adoption_or_hidden_repairs() {
|
||||
let v = HomogeneousVolume {
|
||||
min_mm: [-2.0, -3.0, -4.0],
|
||||
max_mm: [5.0, 6.0, 7.0],
|
||||
extinction_per_mm: 0.3,
|
||||
scattering_albedo: [0.2, 0.4, 0.6],
|
||||
anisotropy: -0.37,
|
||||
};
|
||||
let old = serde_json::to_value(CpuJob::default()).unwrap();
|
||||
assert!(old.get("volume").is_none());
|
||||
let mut job = CpuJob {
|
||||
volume: Some(v),
|
||||
path: Some(CpuPathOptions { max_depth: 8 }),
|
||||
progressive: Some(CpuProgressiveOptions { samples_per_pixel: 4, seed: 9 }),
|
||||
..Default::default()
|
||||
};
|
||||
let json = serde_json::to_value(&job).unwrap();
|
||||
let reopened: CpuJob = serde_json::from_value(json.clone()).unwrap();
|
||||
assert_eq!(reopened, job);
|
||||
assert_eq!(reopened.to_runtime(0.0).unwrap().volume, Some(v));
|
||||
job.denoise = true;
|
||||
let before = job.clone();
|
||||
assert!(job.validate(0.0).is_err());
|
||||
assert_eq!(job, before);
|
||||
job.denoise = false;
|
||||
job.path = None;
|
||||
let before = job.clone();
|
||||
assert!(job.validate(0.0).is_err());
|
||||
assert_eq!(job, before);
|
||||
job.volume.as_mut().unwrap().extinction_per_mm = 0.0;
|
||||
job.denoise = true;
|
||||
job.validate(0.0).unwrap();
|
||||
let inactive = serde_json::to_value(&job).unwrap();
|
||||
assert_eq!(inactive["volume"]["anisotropy"], -0.37);
|
||||
assert_eq!(serde_json::from_value::<CpuJob>(inactive).unwrap(), job);
|
||||
job.volume = None;
|
||||
job.denoise = false;
|
||||
job.progressive = None;
|
||||
assert_eq!(serde_json::to_value(job).unwrap(), old);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,55 @@ impl Default for OpticalMaterial {
|
||||
}
|
||||
}
|
||||
impl OpticalMaterial {
|
||||
/// The CPU consumer is a certified subset of the existing saved optical contract.
|
||||
/// Keep authored unsupported values intact and refuse them, never drop effects.
|
||||
pub fn validate_for_cpu(self) -> Result<()> {
|
||||
self.validate_for_cpu_path(false)
|
||||
}
|
||||
/// General closed boundaries require the explicit finite progressive path.
|
||||
pub fn validate_for_cpu_path(self, path: bool) -> Result<()> {
|
||||
self.validate()?;
|
||||
if self.shadow_transmission && (!path || self.boundary != OpticalBoundary::ThinSheet) {
|
||||
return Err(DocError::Invalid(
|
||||
"CPU transmissive shadows require Indirect lighting and a thin sheet; closed refractive light connections remain unsupported".into(),
|
||||
));
|
||||
}
|
||||
if self.subsurface.is_some() && !path {
|
||||
return Err(DocError::Invalid("CPU subsurface scattering requires Indirect lighting and a certified closed medium".into()));
|
||||
}
|
||||
if self.absorption_per_mm.into_iter().any(|v| !(0.0..=100.0).contains(&v)) || !(0.0..=10000.0).contains(&self.thickness_mm) {
|
||||
return Err(DocError::Invalid("CPU glass absorption must be 0..100 per mm and virtual sheet thickness 0..10000 mm".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A closed optical boundary is accepted only if the CPU producer subsequently
|
||||
/// certifies the captured mesh as its exact axis-aligned-box subset. The saved
|
||||
/// boundary is not rewritten and no arbitrary closed-mesh support is implied.
|
||||
pub fn to_cpu(self, body_index: usize, body_name: &str) -> Result<solidcraft_render::job::DielectricOptions> {
|
||||
self.to_cpu_for_path(body_index, body_name, false)
|
||||
}
|
||||
pub fn to_cpu_for_path(self, body_index: usize, body_name: &str, path: bool) -> Result<solidcraft_render::job::DielectricOptions> {
|
||||
self.validate_for_cpu_path(path)?;
|
||||
if body_name.is_empty() || body_name.len() > 1024 || body_name.chars().any(char::is_control) {
|
||||
return Err(DocError::Invalid("CPU optical target requires a bounded captured body name".into()));
|
||||
}
|
||||
Ok(solidcraft_render::job::DielectricOptions {
|
||||
subsurface: self.subsurface,
|
||||
shadow_transmission: self.shadow_transmission,
|
||||
body_index,
|
||||
body_name: body_name.into(),
|
||||
boundary: match self.boundary {
|
||||
OpticalBoundary::ThinSheet => solidcraft_render::job::DielectricBoundary::ThinSheet,
|
||||
OpticalBoundary::ClosedMesh if path => solidcraft_render::job::DielectricBoundary::ClosedMesh,
|
||||
OpticalBoundary::ClosedBox | OpticalBoundary::ClosedMesh => solidcraft_render::job::DielectricBoundary::ClosedBox,
|
||||
},
|
||||
ior: self.ior,
|
||||
absorption_per_mm: self.absorption_per_mm,
|
||||
thickness_mm: self.thickness_mm,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(self) -> Result<()> {
|
||||
let error = |message: String| DocError::Invalid(message);
|
||||
OpaqueSurface::new(Vec3::ZERO, 0.0, 0.5, self.ior).map_err(|e| error(e.to_string()))?;
|
||||
@@ -90,6 +139,19 @@ impl OpticalBindings {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn validate_for_cpu(&self) -> Result<()> {
|
||||
self.validate_for_cpu_path(false)
|
||||
}
|
||||
pub fn validate_for_cpu_path(&self, path: bool) -> Result<()> {
|
||||
self.validate()?;
|
||||
if self.len() > 8 {
|
||||
return Err(DocError::Invalid("CPU supports at most eight certified optical targets".into()));
|
||||
}
|
||||
for binding in self.iter() {
|
||||
binding.optics.validate_for_cpu_path(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn set(&mut self, binding: SavedOptics, replace: bool) -> Result<()> {
|
||||
binding.validate()?;
|
||||
if let Some(old) = self.0.iter_mut().find(|old| old.target == binding.target) {
|
||||
@@ -166,4 +228,86 @@ mod tests {
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_optical_consumer_preserves_saved_boundary_and_refuses_unrepresented_effects() {
|
||||
let closed = OpticalMaterial { ior: 1.51, absorption_per_mm: [0.1, 0.02, 0.0], ..Default::default() };
|
||||
let original = serde_json::to_value(closed).unwrap();
|
||||
let cpu = closed.to_cpu(3, "Glass@part-2").unwrap();
|
||||
assert_eq!(cpu.boundary, solidcraft_render::job::DielectricBoundary::ClosedBox);
|
||||
assert_eq!((cpu.body_index, cpu.body_name.as_str()), (3, "Glass@part-2"));
|
||||
assert_eq!((cpu.ior, cpu.absorption_per_mm), (1.51, [0.1, 0.02, 0.0]));
|
||||
assert_eq!(serde_json::to_value(closed).unwrap(), original, "CPU specialization never rewrites the authored closed boundary");
|
||||
let thin = OpticalMaterial { boundary: OpticalBoundary::ThinSheet, thickness_mm: 2.0, ..closed };
|
||||
assert_eq!(thin.to_cpu(0, "Sheet").unwrap().boundary, solidcraft_render::job::DielectricBoundary::ThinSheet);
|
||||
for (value, reason) in [
|
||||
(OpticalMaterial { shadow_transmission: true, ..thin }, "transmissive shadows"),
|
||||
(OpticalMaterial { subsurface: Some(Subsurface { scattering_per_mm: 0.0, anisotropy: 0.0 }), ..closed }, "subsurface"),
|
||||
(OpticalMaterial { absorption_per_mm: [101.0, 0.0, 0.0], ..closed }, "absorption"),
|
||||
(OpticalMaterial { thickness_mm: 10001.0, ..thin }, "thickness"),
|
||||
] {
|
||||
let before = serde_json::to_value(value).unwrap();
|
||||
assert!(value.to_cpu(0, "Target").unwrap_err().to_string().contains(reason));
|
||||
assert_eq!(serde_json::to_value(value).unwrap(), before);
|
||||
}
|
||||
let mut optics = OpticalBindings::default();
|
||||
for feature in 1..=9 {
|
||||
optics
|
||||
.set(
|
||||
SavedOptics {
|
||||
target: RenderBodyTarget { definition: format!("Glass{feature}"), feature, component: 0, occurrence_path: vec![] },
|
||||
optics: closed,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
optics.validate().unwrap();
|
||||
assert!(optics.validate_for_cpu().is_err());
|
||||
let target = RenderBodyTarget { definition: "Glass".into(), feature: 1, component: 0, occurrence_path: vec![] };
|
||||
let mut setup = crate::render_settings::RenderSetup {
|
||||
job: Some(crate::render_job::RenderJob::CpuRaytrace { transparent_background: false, options: Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
setup.optics.set(SavedOptics { target, optics: closed }, false).unwrap();
|
||||
setup.validate().unwrap();
|
||||
let wire = serde_json::to_value(&setup).unwrap();
|
||||
assert_eq!(serde_json::from_value::<crate::render_settings::RenderSetup>(wire).unwrap(), setup);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cpu_general_glass_contract_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn saved_general_glass_requires_explicit_path_and_preserves_unsupported_choices_and_old_wire() {
|
||||
let optics = OpticalMaterial { ior: 1.51, absorption_per_mm: [0.1, 0.2, 0.3], boundary: OpticalBoundary::ClosedMesh, ..Default::default() };
|
||||
assert_eq!(optics.to_cpu(0, "Glass").unwrap().boundary, solidcraft_render::job::DielectricBoundary::ClosedBox);
|
||||
let general = optics.to_cpu_for_path(0, "Glass", true).unwrap();
|
||||
assert_eq!(general.boundary, solidcraft_render::job::DielectricBoundary::ClosedMesh);
|
||||
assert_eq!((general.ior, general.absorption_per_mm), (optics.ior, optics.absorption_per_mm));
|
||||
let scattering = OpticalMaterial { subsurface: Some(Subsurface { scattering_per_mm: 0.1, anisotropy: 0.3 }), ..optics };
|
||||
let before = serde_json::to_value(scattering).unwrap();
|
||||
assert!(scattering.to_cpu(0, "Glass").is_err(), "the legacy consumer remains explicit");
|
||||
assert_eq!(scattering.to_cpu_for_path(0, "Glass", true).unwrap().subsurface, scattering.subsurface);
|
||||
assert_eq!(serde_json::to_value(scattering).unwrap(), before);
|
||||
let encoded = serde_json::to_value(&general).unwrap();
|
||||
assert!(encoded.get("shadow_transmission").is_none());
|
||||
assert_eq!(serde_json::from_value::<solidcraft_render::job::DielectricOptions>(encoded).unwrap(), general);
|
||||
let thin = OpticalMaterial { boundary: OpticalBoundary::ThinSheet, shadow_transmission: true, ..optics };
|
||||
assert!(thin.to_cpu(0, "Glass").is_err());
|
||||
assert!(thin.to_cpu_for_path(0, "Glass", true).unwrap().shadow_transmission);
|
||||
for value in [
|
||||
OpticalMaterial { shadow_transmission: true, ..optics },
|
||||
OpticalMaterial {
|
||||
boundary: OpticalBoundary::ThinSheet,
|
||||
subsurface: Some(Subsurface { scattering_per_mm: 0.1, anisotropy: 0.0 }),
|
||||
..optics
|
||||
},
|
||||
] {
|
||||
let before = value;
|
||||
assert!(value.to_cpu_for_path(0, "Glass", true).is_err());
|
||||
assert_eq!(value, before);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ pub struct RenderSetup {
|
||||
pub surfaces: crate::render_surface::SurfaceBindings,
|
||||
#[serde(skip_serializing_if = "crate::render_optics::OpticalBindings::is_empty")]
|
||||
pub optics: crate::render_optics::OpticalBindings,
|
||||
#[serde(skip_serializing_if = "crate::render_coat::CoatBindings::is_empty")]
|
||||
pub coats: crate::render_coat::CoatBindings,
|
||||
#[serde(skip_serializing_if = "solidcraft_render::stage::Stage::is_empty")]
|
||||
pub stage: solidcraft_render::stage::Stage,
|
||||
}
|
||||
@@ -78,6 +80,7 @@ impl Default for RenderSetup {
|
||||
background: [236, 240, 245],
|
||||
job: None,
|
||||
optics: Default::default(),
|
||||
coats: Default::default(),
|
||||
surfaces: Default::default(),
|
||||
stage: Default::default(),
|
||||
}
|
||||
@@ -87,9 +90,13 @@ impl Default for RenderSetup {
|
||||
impl RenderSetup {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
let (limits, cpu) = match &self.job {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
(options.budget.image_limits(options.progressive.is_some()), Some(CpuSize { progressive: options.progressive }))
|
||||
}
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => (
|
||||
options.budget.image_limits(options.progressive.is_some()),
|
||||
Some(CpuSize {
|
||||
progressive: solidcraft_render::job::cpu_camera_sampling(options.progressive, options.adaptive, options.budget)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?,
|
||||
}),
|
||||
),
|
||||
Some(RenderJob::GpuPath { .. }) => (solidcraft_render::job::GPU_PATH_LIMITS, None),
|
||||
_ => (RenderLimits::default(), None),
|
||||
};
|
||||
@@ -111,8 +118,30 @@ impl RenderSetup {
|
||||
fn validate_with_limits(&self, limits: RenderLimits, cpu: Option<CpuSize>) -> Result<()> {
|
||||
self.surfaces.validate()?;
|
||||
self.optics.validate()?;
|
||||
if !self.optics.is_empty() && !matches!(self.job, Some(RenderJob::GpuPath { .. })) {
|
||||
return Err(DocError::Invalid("saved optics require an explicit GPU job".into()));
|
||||
self.coats.validate()?;
|
||||
if !self.coats.is_empty() && !matches!(self.job, Some(RenderJob::CpuRaytrace { .. } | RenderJob::GpuPath { .. })) {
|
||||
return Err(DocError::Invalid("saved clear coats require an explicit CPU or GPU raytrace job".into()));
|
||||
}
|
||||
if self.coats.iter().any(|coat| self.optics.iter().any(|optics| coat.target == optics.target)) {
|
||||
return Err(DocError::Invalid("clear coat and Glass cannot share a target, including zero-weight authored coats".into()));
|
||||
}
|
||||
match &self.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
self.optics.validate_for_cpu_path(options.path.is_some())?;
|
||||
if options.volume.is_some_and(|v| v.active()) && !self.optics.is_empty() {
|
||||
return Err(DocError::Invalid(
|
||||
"CPU Atmosphere cannot combine with saved Glass or subsurface scattering; settings are kept".into(),
|
||||
));
|
||||
}
|
||||
if options.denoise && self.optics.iter().any(|binding| binding.optics.subsurface.is_some_and(|s| s.active())) {
|
||||
return Err(DocError::Invalid(
|
||||
"CPU subsurface random walks have no surface-denoiser guides; disable denoising explicitly".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(RenderJob::GpuPath { .. }) => {}
|
||||
_ if !self.optics.is_empty() => return Err(DocError::Invalid("saved optics require an explicit CPU or GPU raytrace job".into())),
|
||||
_ => {}
|
||||
}
|
||||
if self.optics.iter().any(|o| self.surfaces.iter().any(|s| s.target == o.target)) {
|
||||
return Err(DocError::Invalid("saved opaque surface maps and optical boundaries cannot share a target".into()));
|
||||
@@ -121,8 +150,23 @@ impl RenderSetup {
|
||||
if !self.stage.is_empty() && matches!(self.job, Some(RenderJob::Raster { .. })) {
|
||||
return Err(DocError::Invalid("passive stage geometry requires raytracing; raster cannot silently discard it".into()));
|
||||
}
|
||||
if !self.surfaces.is_empty() && !matches!(self.job, Some(RenderJob::GpuPath { .. })) {
|
||||
return Err(DocError::Invalid("saved surfaces require an explicit GPU job; unsupported methods cannot discard bindings".into()));
|
||||
if !self.surfaces.is_empty() {
|
||||
match &self.job {
|
||||
Some(RenderJob::GpuPath { .. }) => {}
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
if !self.optics.is_empty() && options.path.is_none() {
|
||||
return Err(DocError::Invalid(
|
||||
"CPU surface maps cannot combine with saved Glass; preserve the bindings and select a compatible method".into(),
|
||||
));
|
||||
}
|
||||
options.validate_surface_maps(self.exposure_ev)?;
|
||||
}
|
||||
_ => {
|
||||
return Err(DocError::Invalid(
|
||||
"saved surfaces require CPU or GPU ray tracing; unsupported methods cannot discard bindings".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (width, height) = (u64::from(self.width), u64::from(self.height));
|
||||
if width == 0 || height == 0 {
|
||||
@@ -168,10 +212,22 @@ impl RenderSetup {
|
||||
));
|
||||
}
|
||||
job.validate(&self.camera, self.exposure_ev)?;
|
||||
if let RenderJob::CpuRaytrace { options } = job
|
||||
&& let Some(progressive) = options.progressive
|
||||
{
|
||||
progressive.validate_image_with_budget(self.width, self.height, options.budget).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()))?;
|
||||
}
|
||||
options
|
||||
.budget
|
||||
.validate_image(
|
||||
self.width,
|
||||
self.height,
|
||||
solidcraft_render::job::cpu_camera_sampling(options.progressive, options.adaptive, options.budget)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?,
|
||||
)
|
||||
.map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
if let Some(output) = &options.output {
|
||||
output.validate_image(self.width, self.height).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Desired mode/format/backend may be unavailable on this host. Saving intent is not preflight.
|
||||
@@ -229,7 +285,7 @@ impl RenderSettings {
|
||||
|
||||
pub fn validate_resources(&self, setup: &RenderSetup) -> Result<()> {
|
||||
match &setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
if let Some(hdr) = options.hdr {
|
||||
hdr.validate_resource(&self.resources)?;
|
||||
}
|
||||
@@ -312,11 +368,12 @@ mod tests {
|
||||
RenderSetup {
|
||||
exposure_ev: 1.25,
|
||||
job: Some(RenderJob::CpuRaytrace {
|
||||
options: CpuJob {
|
||||
transparent_background: false,
|
||||
options: Box::new(CpuJob {
|
||||
lights: Some(Vec::new()),
|
||||
hdr: Some(CpuHdrEnvironment { resource: id, rotation_rad: 0.75, intensity: 2.0, samples: 64 }),
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
@@ -564,7 +621,7 @@ mod tests {
|
||||
let cpu = |budget, width, height| RenderSetup {
|
||||
width,
|
||||
height,
|
||||
job: Some(RenderJob::CpuRaytrace { options: CpuJob { budget, ..Default::default() } }),
|
||||
job: Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::new(CpuJob { budget, ..Default::default() }) }),
|
||||
..Default::default()
|
||||
};
|
||||
let compact = cpu(CpuBudget::Compact, 3840, 2160);
|
||||
@@ -572,7 +629,7 @@ mod tests {
|
||||
assert!(message.contains("3840 × 2160 exceeds the image limits (4096 pixels per side, 4194304 pixels in all)"), "{message}");
|
||||
assert!(message.contains("the largest image at this aspect is 2730 × 1536"), "{message}");
|
||||
assert!(message.contains("choose the Studio CPU resource profile, or a smaller size"), "{message}");
|
||||
assert!(matches!(&compact.job, Some(RenderJob::CpuRaytrace { options }) if options.budget == CpuBudget::Compact));
|
||||
assert!(matches!(&compact.job, Some(RenderJob::CpuRaytrace { options, .. }) if options.budget == CpuBudget::Compact));
|
||||
let message = compact.validate_for_cpu(CpuBudget::Compact).unwrap_err().to_string();
|
||||
assert!(message.contains("choose the Studio CPU resource profile"), "{message}");
|
||||
|
||||
@@ -631,7 +688,7 @@ mod tests {
|
||||
let saved = serde_json::to_value(&reopened.render).unwrap();
|
||||
assert!(saved["setups"]["Legacy"].get("job").is_none());
|
||||
assert_eq!(saved["setups"]["Legacy"]["environment_rotation"], 0.75);
|
||||
let RenderJob::CpuRaytrace { options } = settings.setups["CPU"].job.as_ref().unwrap() else { panic!("CPU intent changed") };
|
||||
let RenderJob::CpuRaytrace { options, .. } = settings.setups["CPU"].job.as_ref().unwrap() else { panic!("CPU intent changed") };
|
||||
let runtime = options.to_runtime(settings.setups["CPU"].exposure_ev).unwrap();
|
||||
assert_eq!(runtime.exposure_ev, 1.25);
|
||||
assert_eq!(runtime.area_samples, Some(64));
|
||||
@@ -655,11 +712,11 @@ mod tests {
|
||||
for options in [json!({}), json!({"lights":null,"punctual_lights":null,"area_lights":null,"environment":null})] {
|
||||
let setup: RenderSetup = serde_json::from_value(json!({"job":{"method":"cpu_raytrace","options":options}})).unwrap();
|
||||
setup.validate().unwrap();
|
||||
assert_eq!(setup.job, Some(RenderJob::CpuRaytrace { options: CpuJob::default() }));
|
||||
assert_eq!(setup.job, Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::default() }));
|
||||
}
|
||||
let empty: RenderSetup = serde_json::from_value(json!({"job":{"method":"cpu_raytrace","options":{"lights":[]}}})).unwrap();
|
||||
empty.validate().unwrap();
|
||||
assert_ne!(empty.job, Some(RenderJob::CpuRaytrace { options: CpuJob::default() }));
|
||||
assert_ne!(empty.job, Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::default() }));
|
||||
for (lighting, explicit) in [(serde_json::Value::Null, false), (json!({"environment":[0,0,0],"area_lights":[]}), true)] {
|
||||
let setup: RenderSetup = serde_json::from_value(json!({"job":{"method":"gpu_path","options":{"lighting":lighting}}})).unwrap();
|
||||
setup.validate().unwrap();
|
||||
@@ -681,7 +738,7 @@ mod tests {
|
||||
let document = Document { render: Arc::new(settings), ..Default::default() };
|
||||
let reopened = Document::from_json(&document.to_json()).unwrap();
|
||||
assert_eq!(document, reopened);
|
||||
let RenderJob::CpuRaytrace { options } = reopened.render.setups["Film"].job.as_ref().unwrap() else { panic!("method changed") };
|
||||
let RenderJob::CpuRaytrace { options, .. } = reopened.render.setups["Film"].job.as_ref().unwrap() else { panic!("method changed") };
|
||||
let runtime = options.to_runtime(1.5).unwrap();
|
||||
assert_eq!(runtime.progressive.unwrap().seed, u32::MAX);
|
||||
assert_eq!(runtime.exposure_ev, 1.5);
|
||||
@@ -698,6 +755,104 @@ mod tests {
|
||||
assert!(serde_json::from_value::<RenderSettings>(incompatible).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_transparent_intent_roundtrips_offline_and_keeps_opaque_defaults() {
|
||||
use serde_json::json;
|
||||
let opaque: RenderSetup = serde_json::from_value(json!({"job":{"method":"cpu_raytrace"}})).unwrap();
|
||||
assert!(matches!(opaque.job, Some(RenderJob::CpuRaytrace { transparent_background: false, .. })));
|
||||
let setup: RenderSetup =
|
||||
serde_json::from_value(json!({"width":8,"height":8,"job":{"method":"cpu_raytrace","transparent_background":true,"options":{
|
||||
"primary_samples":4,"output":{"linear_exr":true}}}}))
|
||||
.unwrap();
|
||||
setup.validate().unwrap();
|
||||
let document = Document {
|
||||
render: Arc::new(RenderSettings { setups: std::collections::BTreeMap::from([("Cutout".into(), setup)]), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(document, Document::from_json(&document.to_json()).unwrap());
|
||||
for output in [json!({"backdrop":{}}), json!({"finishing":{"bloom":{"strength":1}}}), json!({"finishing":{"photographic":{"grain":0.03}}})] {
|
||||
let settings = json!({"setups":{"Cutout":{"job":{"method":"cpu_raytrace","transparent_background":true,"options":{"output":output}}}}});
|
||||
let error = serde_json::from_value::<RenderSettings>(settings.clone()).unwrap_err();
|
||||
assert!(error.to_string().contains("opaque"), "valid opaque-only effects remain authored and refused");
|
||||
let mut saved: serde_json::Value = serde_json::from_str(&Document::default().to_json()).unwrap();
|
||||
saved["render"] = settings;
|
||||
let error = Document::from_json(&saved.to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("opaque"), "the full document loader must enforce the same saved output contract");
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<RenderSetup>(json!({"job":{"method":"cpu_raytrace","options":{"transparent_background":true}}})).is_err(),
|
||||
"one alpha authority beside the numerical job"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_physical_lens_roundtrips_offline_and_keeps_disabled_legacy_intent() {
|
||||
use serde_json::json;
|
||||
let camera = Camera { fov: 0.7, distance: 20.0, ..Default::default() };
|
||||
let saved = json!({"setups":{"Focus":{"width":64,"height":32,"camera":camera,
|
||||
"job":{"method":"cpu_raytrace","options":{"progressive":{"samples_per_pixel":8,"seed":81},
|
||||
"lens":{"aperture_radius_mm":0.5,"focus_distance_mm":20.0}}}}}});
|
||||
let settings: RenderSettings = serde_json::from_value(saved.clone()).unwrap();
|
||||
let document = Document { render: Arc::new(settings), ..Default::default() };
|
||||
assert_eq!(document, Document::from_json(&document.to_json()).unwrap());
|
||||
let RenderJob::CpuRaytrace { options, .. } = document.render.setups["Focus"].job.as_ref().unwrap() else { panic!("CPU method changed") };
|
||||
assert_eq!(options.to_runtime(1.5).unwrap().lens, options.lens);
|
||||
let mut fixed = saved.clone();
|
||||
fixed["setups"]["Focus"]["job"]["options"].as_object_mut().unwrap().remove("progressive");
|
||||
assert!(serde_json::from_value::<RenderSettings>(fixed.clone()).is_err());
|
||||
fixed["setups"]["Focus"]["job"]["options"]["lens"]["aperture_radius_mm"] = json!(0);
|
||||
fixed["setups"]["Focus"]["camera"]["fov"] = json!(0);
|
||||
let zero: RenderSettings = serde_json::from_value(fixed).unwrap();
|
||||
assert_eq!(serde_json::to_value(&zero).unwrap()["setups"]["Focus"]["job"]["options"]["lens"]["aperture_radius_mm"].as_f64(), Some(0.0));
|
||||
for lens in [
|
||||
json!({"aperture_radius_mm":-1,"focus_distance_mm":20}),
|
||||
json!({"aperture_radius_mm":0.5,"focus_distance_mm":0}),
|
||||
json!({"aperture_radius_mm":0.5,"focus_distance_mm":20,"f_stop":2}),
|
||||
] {
|
||||
let mut invalid = saved.clone();
|
||||
invalid["setups"]["Focus"]["job"]["options"]["lens"] = lens;
|
||||
assert!(serde_json::from_value::<RenderSettings>(invalid).is_err());
|
||||
}
|
||||
let mut ortho = saved;
|
||||
ortho["setups"]["Focus"]["camera"]["fov"] = json!(0);
|
||||
assert!(serde_json::from_value::<RenderSettings>(ortho).is_err());
|
||||
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("lens").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_output_presentation_roundtrips_offline_without_another_exposure_authority() {
|
||||
use serde_json::json;
|
||||
assert!(serde_json::to_value(CpuJob::default()).unwrap().get("output").is_none());
|
||||
let saved = json!({"setups":{"CPU output":{"width":64,"height":32,"exposure_ev":1.5,
|
||||
"job":{"method":"cpu_raytrace","options":{"progressive":{"samples_per_pixel":7,"seed":81},
|
||||
"output":{"tone_map":"aces_fitted","finishing":{"rgb_gain":[1.2,0.8,1.1],"photographic":{"grain":0.03,"seed":42},
|
||||
"bloom":{"strength":0.2,"threshold":0.5,"radius":2}},"backdrop":{"mode":"radial"},"linear_exr":true}}}}}});
|
||||
let settings: RenderSettings = serde_json::from_value(saved).unwrap();
|
||||
let setup = &settings.setups["CPU output"];
|
||||
let RenderJob::CpuRaytrace { options, .. } = setup.job.as_ref().unwrap() else { panic!("CPU job expected") };
|
||||
let runtime = options.to_runtime(setup.exposure_ev).unwrap();
|
||||
assert_eq!(runtime.exposure_ev, 1.5);
|
||||
assert_eq!(runtime.output, options.output);
|
||||
let encoded = serde_json::to_value(&settings).unwrap();
|
||||
assert!(encoded["setups"]["CPU output"]["job"]["options"]["output"].get("exposure_ev").is_none());
|
||||
assert_eq!(settings, serde_json::from_value::<RenderSettings>(encoded.clone()).unwrap());
|
||||
for bad in [json!({"exposure_ev":1}), json!({"samples":4}), json!({"linear_exr":"yes"}), json!({"finishing":{"rgb_gain":[-1,1,1]}})] {
|
||||
let mut invalid = encoded.clone();
|
||||
invalid["setups"]["CPU output"]["job"]["options"]["output"] = bad;
|
||||
assert!(serde_json::from_value::<RenderSettings>(invalid).is_err());
|
||||
}
|
||||
let wide = json!({"width":16384,"height":1,"job":{"method":"cpu_raytrace","options":{"budget":"workstation"}}});
|
||||
serde_json::from_value::<RenderSetup>(wide.clone()).unwrap().validate().unwrap();
|
||||
for output in [json!({"tone_map":"aces_fitted"}), json!({"linear_exr":true})] {
|
||||
let mut invalid = wide.clone();
|
||||
invalid["job"]["options"]["output"] = output;
|
||||
assert!(
|
||||
serde_json::from_value::<RenderSetup>(invalid).unwrap().validate().is_err(),
|
||||
"active output must respect its existing shared envelope"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_jobs_reject_unbounded_invalid_and_nonportable_inputs() {
|
||||
use serde_json::json;
|
||||
@@ -801,6 +956,7 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
mod cpu_budget_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use solidcraft_render::job::{CpuBudget, CpuProgressiveOptions};
|
||||
|
||||
#[test]
|
||||
@@ -821,11 +977,12 @@ mod cpu_budget_tests {
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
job: Some(RenderJob::CpuRaytrace {
|
||||
options: CpuJob {
|
||||
transparent_background: false,
|
||||
options: Box::new(CpuJob {
|
||||
budget: CpuBudget::Studio,
|
||||
progressive: Some(CpuProgressiveOptions { samples_per_pixel: 128, seed: 82 }),
|
||||
..Default::default()
|
||||
},
|
||||
}),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -833,7 +990,7 @@ mod cpu_budget_tests {
|
||||
let text = serde_json::to_string(&setup).unwrap();
|
||||
let reopened: RenderSetup = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(setup, reopened);
|
||||
let Some(RenderJob::CpuRaytrace { options }) = &mut setup.job else {
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else {
|
||||
panic!("CPU fixture");
|
||||
};
|
||||
options.budget = CpuBudget::Compact;
|
||||
@@ -845,4 +1002,372 @@ mod cpu_budget_tests {
|
||||
let legacy_large = RenderSetup { width: 3840, height: 2160, ..Default::default() };
|
||||
assert!(legacy_large.validate().is_err(), "large intent must name an explicit CPU job profile");
|
||||
}
|
||||
#[test]
|
||||
fn cpu_path_is_explicit_bounded_offline_intent_and_roundtrips_without_changing_legacy_jobs() {
|
||||
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] {
|
||||
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();
|
||||
std::sync::Arc::make_mut(&mut document.render).setups.insert("GI".into(), setup);
|
||||
let bytes = serde_json::to_vec(&document).unwrap();
|
||||
let reopened: crate::Document = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(document, reopened);
|
||||
}
|
||||
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":8}}),
|
||||
json!({"path":{"max_depth":8},"progressive":{"samples_per_pixel":7,"seed":9},"shadows":false}),
|
||||
] {
|
||||
let options: CpuJob = serde_json::from_value(raw).unwrap();
|
||||
assert!(options.to_runtime(0.0).is_err());
|
||||
}
|
||||
assert!(serde_json::from_value::<CpuJob>(json!({"path":{"max_depth":8,"unknown":true}})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn cpu_denoise_saved_flag_roundtrips_offline_and_keeps_false_omitted() {
|
||||
use solidcraft_render::job::CpuBudget;
|
||||
let mut setup = RenderSetup {
|
||||
width: 16,
|
||||
height: 8,
|
||||
job: Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::new(CpuJob { denoise: true, ..Default::default() }) }),
|
||||
..Default::default()
|
||||
};
|
||||
setup.validate().unwrap();
|
||||
let wire = serde_json::to_value(&setup).unwrap();
|
||||
assert_eq!(wire["job"]["options"]["denoise"], true);
|
||||
assert_eq!(serde_json::from_value::<RenderSetup>(wire).unwrap(), setup);
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else { panic!("CPU job expected") };
|
||||
assert!(options.to_runtime(1.5).unwrap().denoise);
|
||||
options.denoise = false;
|
||||
assert!(serde_json::to_value(&setup).unwrap()["job"]["options"].get("denoise").is_none());
|
||||
let default: CpuJob = serde_json::from_value(serde_json::json!({})).unwrap();
|
||||
let explicit: CpuJob = serde_json::from_value(serde_json::json!({"denoise":false})).unwrap();
|
||||
assert_eq!(default, explicit);
|
||||
assert!(!default.to_runtime(0.0).unwrap().denoise);
|
||||
assert!(serde_json::from_value::<CpuJob>(serde_json::json!({"denoise":"true"})).is_err());
|
||||
setup.width = 8193;
|
||||
setup.height = 1;
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else { panic!("CPU job expected") };
|
||||
options.budget = CpuBudget::Workstation;
|
||||
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"));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn composed_cpu_path_preserves_saved_optics_alpha_filter_lens_and_master_intent() {
|
||||
use crate::render_optics::{OpticalMaterial, SavedOptics};
|
||||
use crate::render_surface::RenderBodyTarget;
|
||||
use solidcraft_render::job::{CpuPathOptions, CpuProgressiveOptions, OutputOptions, ThinLens};
|
||||
let mut setup = RenderSetup {
|
||||
camera: Camera { fov: 0.6, distance: 123.0, ..Default::default() },
|
||||
width: 8,
|
||||
height: 8,
|
||||
job: Some(RenderJob::CpuRaytrace {
|
||||
transparent_background: true,
|
||||
options: Box::new(CpuJob {
|
||||
denoise: true,
|
||||
path: Some(CpuPathOptions { max_depth: 3 }),
|
||||
progressive: Some(CpuProgressiveOptions { samples_per_pixel: 3, seed: 81 }),
|
||||
lens: Some(ThinLens { aperture_radius_mm: 1.0, focus_distance_mm: 123.0 }),
|
||||
output: Some(Box::new(OutputOptions { linear_exr: true, ..Default::default() })),
|
||||
..Default::default()
|
||||
}),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
setup.validate().unwrap();
|
||||
setup
|
||||
.optics
|
||||
.set(
|
||||
SavedOptics {
|
||||
target: RenderBodyTarget { definition: "Glass".into(), feature: 1, component: 0, occurrence_path: Vec::new() },
|
||||
optics: OpticalMaterial::default(),
|
||||
},
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let before = setup.clone();
|
||||
setup.validate().unwrap();
|
||||
assert_eq!(setup, before, "validation must not lower or rewrite authored optics");
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else { panic!("CPU fixture") };
|
||||
options.path = None;
|
||||
setup.validate().unwrap();
|
||||
assert!(matches!(&setup.job, Some(RenderJob::CpuRaytrace { transparent_background:true, options })
|
||||
if options.denoise && options.output.as_ref().unwrap().linear_exr && options.lens.is_some()));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn gpu_hard_directional_intent_roundtrips_and_hostile_lists_refuse_at_document_load() {
|
||||
use serde_json::json;
|
||||
let light = json!({"direction":[0.0,0.0,2.0],"color":[0.25,0.5,1.0],"intensity":0.0});
|
||||
let settings = json!({"setups":{"Lights":{"job":{"method":"gpu_path","options":{"lighting":{"directional_lights":vec![light.clone();8]}}}}}});
|
||||
let saved: RenderSettings = serde_json::from_value(settings.clone()).unwrap();
|
||||
let doc = crate::Document { render: std::sync::Arc::new(saved.clone()), ..Default::default() };
|
||||
assert_eq!(crate::Document::from_json(&doc.to_json()).unwrap(), doc);
|
||||
let encoded = serde_json::to_value(&saved).unwrap();
|
||||
assert_eq!(encoded["setups"]["Lights"]["job"]["options"]["lighting"]["directional_lights"], json!(vec![light.clone(); 8]));
|
||||
for list in [
|
||||
json!(vec![light.clone(); 9]),
|
||||
json!([{"direction":[0,0,0],"color":[1,1,1],"intensity":1}]),
|
||||
json!([{"direction":[0,0,1],"color":[1,1,1],"intensity":65}]),
|
||||
] {
|
||||
let mut bad = settings.clone();
|
||||
bad["setups"]["Lights"]["job"]["options"]["lighting"]["directional_lights"] = list;
|
||||
assert!(serde_json::from_value::<RenderSettings>(bad).is_err());
|
||||
}
|
||||
let mut over_combined = settings;
|
||||
over_combined["setups"]["Lights"]["job"]["options"]["lighting"]["sun"] =
|
||||
json!({"direction":[0,0,1],"angular_radius_rad":0.02,"radiance":[1,1,1]});
|
||||
assert!(serde_json::from_value::<RenderSettings>(over_combined.clone()).is_err());
|
||||
let mut document = serde_json::to_value(crate::Document::default()).unwrap();
|
||||
document["render"] = over_combined;
|
||||
assert!(crate::Document::from_json(&serde_json::to_string(&document).unwrap()).is_err());
|
||||
let old: RenderSettings = serde_json::from_value(json!({"setups":{"Old":{"job":{"method":"gpu_path","options":{}}}}})).unwrap();
|
||||
let Some(RenderJob::GpuPath { options, .. }) = &old.setups["Old"].job else { panic!("gpu job") };
|
||||
assert!(options.lighting.is_none());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn saved_cpu_aging_is_optional_bounded_and_preserves_stable_targets_without_runtime_ordinals() {
|
||||
use crate::render_aging::SavedAgingStack;
|
||||
use crate::render_surface::RenderBodyTarget;
|
||||
use solidcraft_render::job::aging::{AgingKind, AgingLayer};
|
||||
let old = CpuJob::default();
|
||||
assert!(serde_json::to_value(&old).unwrap().get("aging").is_none());
|
||||
let mut layers = SavedAgingStack::default();
|
||||
layers.push(AgingLayer::preset(AgingKind::Dust)).unwrap();
|
||||
let target = RenderBodyTarget { definition: "Aged panel".into(), feature: 19, component: 0, occurrence_path: Vec::new() };
|
||||
layers.get_mut(0).unwrap().target = Some(target.clone());
|
||||
let job = CpuJob { aging: layers.clone(), progressive: Some(Default::default()), denoise: true, ..Default::default() };
|
||||
job.validate(0.5).unwrap();
|
||||
assert!(job.to_runtime(0.5).unwrap_err().to_string().contains("stable occurrence"));
|
||||
let setup = RenderSetup {
|
||||
width: 8,
|
||||
height: 8,
|
||||
exposure_ev: 0.5,
|
||||
job: Some(RenderJob::CpuRaytrace { transparent_background: true, options: Box::new(job) }),
|
||||
..Default::default()
|
||||
};
|
||||
setup.validate().unwrap();
|
||||
let wire = serde_json::to_value(&setup).unwrap();
|
||||
assert_eq!(wire["job"]["options"]["aging"][0]["target"]["definition"], "Aged panel");
|
||||
assert!(wire["job"]["options"]["aging"][0]["target"].get("body_index").is_none());
|
||||
assert_eq!(serde_json::from_value::<RenderSetup>(wire.clone()).unwrap(), setup);
|
||||
let mut legacy = wire.clone();
|
||||
legacy["job"]["options"]["aging"][0]["target"] = serde_json::json!({"body_index":0,"body_name":"Aged panel"});
|
||||
assert!(serde_json::from_value::<RenderSetup>(legacy).unwrap_err().to_string().contains("explicit rebinding"));
|
||||
let mut nine = wire;
|
||||
nine["job"]["options"]["aging"] = serde_json::json!(vec![serde_json::json!({}); 9]);
|
||||
assert!(serde_json::from_value::<RenderSetup>(nine).is_err());
|
||||
layers.get_mut(0).unwrap().target = None;
|
||||
let runtime = CpuJob { aging: layers, ..Default::default() }.to_runtime(0.5).unwrap();
|
||||
assert!(runtime.aging.unwrap().active());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cpu_adaptive_saved_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use solidcraft_render::job::{CpuBudget, CpuProgressiveOptions};
|
||||
#[test]
|
||||
fn saved_cpu_adaptive_intent_roundtrips_offline_and_pays_pilots_inside_the_existing_profile() {
|
||||
let setup: RenderSetup = serde_json::from_value(json!({"width":128,"height":64,
|
||||
"job":{"method":"cpu_raytrace","transparent_background":true,"options":{
|
||||
"progressive":{"samples_per_pixel":48,"seed":81},"adaptive":{"pilot_samples":16,"min_samples":16},
|
||||
"denoise":true,"path":{"max_depth":3},"output":{"linear_exr":true}}}}))
|
||||
.unwrap();
|
||||
setup.validate().unwrap();
|
||||
let value = serde_json::to_value(&setup).unwrap();
|
||||
assert_eq!(serde_json::from_value::<RenderSetup>(value.clone()).unwrap(), setup);
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = setup.job.as_ref() else { panic!("CPU job expected") };
|
||||
let runtime = options.to_runtime(0.0).unwrap();
|
||||
assert_eq!(runtime.adaptive.as_deref().copied(), options.adaptive);
|
||||
assert_eq!(runtime.preview_target().unwrap(), 64);
|
||||
assert_eq!(runtime.camera_sampling().unwrap(), Some(CpuProgressiveOptions { samples_per_pixel: 64, seed: 81 }));
|
||||
let mut too_large = setup.clone();
|
||||
too_large.width = 1024;
|
||||
too_large.height = 1024;
|
||||
assert!(too_large.validate().is_err(), "64 total passes exceed Compact's existing camera-sample bound");
|
||||
let mut cap = value.clone();
|
||||
cap["job"]["options"]["progressive"]["samples_per_pixel"] = json!(64);
|
||||
assert!(
|
||||
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)] {
|
||||
let mut runtime = runtime.clone();
|
||||
runtime.budget = budget;
|
||||
runtime.progressive = Some(CpuProgressiveOptions { samples_per_pixel: maximum - 16, seed: 81 });
|
||||
assert_eq!(runtime.preview_target().unwrap(), maximum);
|
||||
runtime.progressive.as_mut().unwrap().samples_per_pixel += 1;
|
||||
assert!(runtime.validate().is_err(), "{budget:?} must include the pilot stage in its unchanged cap");
|
||||
}
|
||||
let mut fixed = value.clone();
|
||||
fixed["job"]["options"].as_object_mut().unwrap().remove("progressive");
|
||||
assert!(serde_json::from_value::<RenderSetup>(fixed).unwrap().validate().is_err());
|
||||
let mut hostile = value;
|
||||
hostile["job"]["options"]["adaptive"]["certified_convergence"] = json!(true);
|
||||
assert!(serde_json::from_value::<RenderSetup>(hostile).is_err());
|
||||
let old = serde_json::to_value(CpuJob::default()).unwrap();
|
||||
assert!(old.get("adaptive").is_none(), "default-absent saved jobs keep their prior schema bytes");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_coat_tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
Document,
|
||||
render_coat::{CoatMaterial, SavedCoat},
|
||||
render_surface::RenderBodyTarget,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn target(feature: u64) -> RenderBodyTarget {
|
||||
RenderBodyTarget { definition: format!("Coated{feature}"), feature, component: 0, occurrence_path: vec![] }
|
||||
}
|
||||
fn document(gpu: bool) -> Document {
|
||||
let mut doc = Document::default();
|
||||
let job = if gpu {
|
||||
RenderJob::GpuPath { transparent_background: false, options: Default::default() }
|
||||
} else {
|
||||
RenderJob::CpuRaytrace { transparent_background: false, options: Default::default() }
|
||||
};
|
||||
Arc::make_mut(&mut doc.render).setups.insert("Coated".into(), RenderSetup { width: 8, height: 8, job: Some(job), ..Default::default() });
|
||||
doc
|
||||
}
|
||||
#[test]
|
||||
fn saved_coat_document_roundtrip_preserves_every_number_and_old_omission_for_both_methods() {
|
||||
for gpu in [false, true] {
|
||||
let mut doc = document(gpu);
|
||||
let old = doc.to_json();
|
||||
let old_value: Value = serde_json::from_str(&old).unwrap();
|
||||
assert!(old_value["render"]["setups"]["Coated"].get("coats").is_none());
|
||||
assert_eq!(Document::from_json(&old).unwrap().to_json(), old);
|
||||
let coat = CoatMaterial { weight: -0.0, roughness: 0.22, ior: 1.74 };
|
||||
Arc::make_mut(&mut doc.render).setups.get_mut("Coated").unwrap().coats.set(SavedCoat { target: target(1), coat }, false).unwrap();
|
||||
let encoded = doc.to_json();
|
||||
let loaded = Document::from_json(&encoded).unwrap();
|
||||
assert_eq!(loaded.render, doc.render);
|
||||
let saved = loaded.render.setups["Coated"].coats.iter().next().unwrap();
|
||||
assert_eq!(saved.coat, coat);
|
||||
assert_eq!(saved.coat.weight.to_bits(), (-0.0_f64).to_bits());
|
||||
let wire = serde_json::to_value(saved).unwrap();
|
||||
assert!(wire.get("body_index").is_none() && wire.get("body_name").is_none());
|
||||
assert!(wire["target"].get("feature").is_some());
|
||||
let setup = Arc::make_mut(&mut doc.render).setups.get_mut("Coated").unwrap();
|
||||
let changed = SavedCoat { target: target(1), coat: CoatMaterial { weight: 0.0, ..coat } };
|
||||
assert!(setup.coats.set(changed.clone(), false).is_err(), "authored signed zero is not silently replaced");
|
||||
setup.coats.set(changed, true).unwrap();
|
||||
assert_eq!(setup.coats.iter().next().unwrap().coat.weight.to_bits(), 0.0_f64.to_bits());
|
||||
assert!(setup.coats.remove(&target(1)));
|
||||
assert_eq!(doc.to_json(), old, "removing the only coat restores exact legacy document bytes");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn malformed_saved_coats_refuse_through_the_full_document_loader() {
|
||||
let mut doc = document(false);
|
||||
Arc::make_mut(&mut doc.render)
|
||||
.setups
|
||||
.get_mut("Coated")
|
||||
.unwrap()
|
||||
.coats
|
||||
.set(SavedCoat { target: target(1), coat: Default::default() }, false)
|
||||
.unwrap();
|
||||
let base: Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
for (key, value) in
|
||||
[("weight", json!(-0.01)), ("weight", json!(1.01)), ("roughness", json!(0.02)), ("ior", json!(3.01)), ("unknown", json!(true))]
|
||||
{
|
||||
let mut bad = base.clone();
|
||||
bad["render"]["setups"]["Coated"]["coats"][0]["coat"][key] = value;
|
||||
assert!(Document::from_json(&bad.to_string()).is_err(), "invalid coat {key}");
|
||||
}
|
||||
let mut duplicate = base.clone();
|
||||
let row = duplicate["render"]["setups"]["Coated"]["coats"][0].clone();
|
||||
duplicate["render"]["setups"]["Coated"]["coats"] = json!([row, row]);
|
||||
assert!(Document::from_json(&duplicate.to_string()).is_err());
|
||||
for key in ["body_index", "runtime_path"] {
|
||||
let mut bad = base.clone();
|
||||
bad["render"]["setups"]["Coated"]["coats"][0][key] = json!(0);
|
||||
assert!(Document::from_json(&bad.to_string()).is_err());
|
||||
}
|
||||
let mut too_many = base;
|
||||
too_many["render"]["setups"]["Coated"]["coats"] =
|
||||
json!((1..=65).map(|feature| SavedCoat { target: target(feature), coat: Default::default() }).collect::<Vec<_>>());
|
||||
assert!(Document::from_json(&too_many.to_string()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn saved_coat_uses_existing_sixty_four_target_bound_and_rejects_glass_even_at_zero_weight() {
|
||||
use crate::render_optics::{OpticalMaterial, SavedOptics};
|
||||
for gpu in [false, true] {
|
||||
let mut doc = document(gpu);
|
||||
let setup = Arc::make_mut(&mut doc.render).setups.get_mut("Coated").unwrap();
|
||||
for feature in 1..=64 {
|
||||
setup.coats.set(SavedCoat { target: target(feature), coat: CoatMaterial { weight: 0.0, ..Default::default() } }, false).unwrap();
|
||||
}
|
||||
setup.validate().unwrap();
|
||||
assert_eq!(setup.coats.len(), 64);
|
||||
let before = setup.coats.clone();
|
||||
assert!(setup.coats.set(SavedCoat { target: target(65), coat: Default::default() }, false).is_err());
|
||||
assert_eq!(setup.coats, before);
|
||||
setup.optics.set(SavedOptics { target: target(1), optics: OpticalMaterial::default() }, false).unwrap();
|
||||
assert!(setup.validate().unwrap_err().to_string().contains("cannot share"));
|
||||
setup.optics.remove(&target(1));
|
||||
setup.job = Some(RenderJob::Raster { transparent_background: false });
|
||||
assert!(setup.validate().unwrap_err().to_string().contains("raytrace"));
|
||||
assert_eq!(setup.coats, before, "unsupported method retains all authored rows");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cpu_celestial_saved_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn saved_cpu_finite_sun_and_sky_roundtrip_exactly_and_require_explicit_path() {
|
||||
let value = json!({"width":16,"height":12,"job":{"method":"cpu_raytrace","transparent_background":true,"options":{
|
||||
"path":{"max_depth":4},"progressive":{"samples_per_pixel":32,"seed":81},"adaptive":{"pilot_samples":16,"min_samples":16},
|
||||
"sun":{"direction":[0.2,-0.3,0.9],"angular_radius_rad":0.02,"radiance":[301.25,202.5,103.75]},
|
||||
"sky":{"kind":"sky","gradient":{"zenith":[0.25,0.5,1.0],"horizon":[0.75,0.5,0.25],"ground":[0.125,0.25,0.5]},"rotation_rad":0.731,"intensity":75},
|
||||
"denoise":true,"output":{"linear_exr":true},"lights":[]}}});
|
||||
let setup: RenderSetup = serde_json::from_value(value.clone()).unwrap();
|
||||
setup.validate().unwrap();
|
||||
let wire = serde_json::to_value(&setup).unwrap();
|
||||
assert_eq!(serde_json::from_value::<RenderSetup>(wire.clone()).unwrap(), setup);
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &setup.job else { panic!("CPU expected") };
|
||||
let runtime = options.to_runtime(0.0).unwrap();
|
||||
assert_eq!(runtime.sun.as_deref().copied(), options.sun);
|
||||
assert_eq!(runtime.sky.as_deref().unwrap().intensity, 75.0);
|
||||
assert_eq!(runtime.sky.as_deref().unwrap().rotation_rad, 0.731);
|
||||
for (field, replacement) in [
|
||||
("path", serde_json::Value::Null),
|
||||
("sun", json!({"direction":[0,0,1],"angular_radius_rad":0,"radiance":[1,1,1]})),
|
||||
("environment", json!({"color":[1,1,1],"intensity":1})),
|
||||
] {
|
||||
let mut invalid = wire.clone();
|
||||
invalid["job"]["options"][field] = replacement;
|
||||
assert!(serde_json::from_value::<RenderSetup>(invalid).unwrap().validate().is_err(), "{field} must not be silently replaced");
|
||||
}
|
||||
for (field, value) in [("intensity", json!(100.001)), ("rotation_rad", json!(1e6 + 1.0))] {
|
||||
let mut invalid = wire.clone();
|
||||
invalid["job"]["options"]["sky"][field] = value;
|
||||
assert!(serde_json::from_value::<RenderSetup>(invalid).unwrap().validate().is_err());
|
||||
}
|
||||
let defaults = serde_json::to_value(CpuJob::default()).unwrap();
|
||||
assert!(defaults.get("sun").is_none() && defaults.get("sky").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,4 +250,66 @@ mod tests {
|
||||
target.occurrence_path = vec![2, 2];
|
||||
assert!(target.validate().is_err());
|
||||
}
|
||||
#[test]
|
||||
fn saved_cpu_surface_maps_roundtrip_for_opaque_direct_and_path_and_preserve_unsupported_intent() {
|
||||
use crate::render_settings::{CpuJob, RenderJob, RenderSetup};
|
||||
let mut options = CpuJob::default();
|
||||
let mut setup = RenderSetup {
|
||||
job: Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::new(options.clone()) }),
|
||||
..Default::default()
|
||||
};
|
||||
setup.surfaces.set(fixture(), false).unwrap();
|
||||
setup.validate().unwrap();
|
||||
let mut direct_document = crate::Document::default();
|
||||
std::sync::Arc::make_mut(&mut direct_document.render).setups.insert("Maps".into(), setup.clone());
|
||||
assert_eq!(crate::Document::from_json(&direct_document.to_json()).unwrap(), direct_document);
|
||||
options.progressive = Some(solidcraft_render::job::CpuProgressiveOptions { samples_per_pixel: 16, seed: 9 });
|
||||
options.shadows = true;
|
||||
options.path = Some(solidcraft_render::job::CpuPathOptions { max_depth: 3 });
|
||||
setup.job = Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::new(options.clone()) });
|
||||
setup.validate().unwrap();
|
||||
let mut document = crate::Document::default();
|
||||
std::sync::Arc::make_mut(&mut document.render).setups.insert("Maps".into(), setup.clone());
|
||||
assert_eq!(crate::Document::from_json(&document.to_json()).unwrap(), document);
|
||||
options.path = None;
|
||||
let saved_options_wire = serde_json::to_value(&options).unwrap();
|
||||
let mut runtime = options.to_runtime(0.0).unwrap();
|
||||
runtime.reflections = Some(solidcraft_render::job::ReflectionOptions {
|
||||
bodies: vec![solidcraft_render::job::ReflectionBody { body: "Panel".into(), mode: solidcraft_render::job::ReflectionMode::Mirror }],
|
||||
samples: 1,
|
||||
max_bounces: 2,
|
||||
});
|
||||
runtime.validate().unwrap();
|
||||
let runtime_wire = serde_json::to_value(&runtime).unwrap();
|
||||
let error = solidcraft_render::job::textures::validate_cpu_options(&runtime).unwrap_err();
|
||||
assert!(error.message.contains("selected reflections"));
|
||||
assert_eq!(serde_json::to_value(&runtime).unwrap(), runtime_wire, "map refusal preserves the exact runtime selected override");
|
||||
assert_eq!(serde_json::to_value(&options).unwrap(), saved_options_wire, "runtime overrides do not rewrite saved CPU fields");
|
||||
setup.job = Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::new(options.clone()) });
|
||||
setup.validate().unwrap();
|
||||
let mut unknown = serde_json::to_value(&setup).unwrap();
|
||||
unknown["job"]["options"]["reflections"] = serde_json::to_value(&runtime.reflections).unwrap();
|
||||
let error = serde_json::from_value::<RenderSetup>(unknown).unwrap_err();
|
||||
assert!(error.to_string().contains("reflections"), "saved CPU intent must not silently discard runtime-only selected overrides");
|
||||
// Prove the saved field is supported in fixed mode before checking
|
||||
// its incompatibility with still-authored progressive sampling.
|
||||
let mut fixed = options.clone();
|
||||
fixed.progressive = None;
|
||||
fixed.primary_samples = 4;
|
||||
fixed.validate_surface_maps(0.0).unwrap();
|
||||
let surfaces = setup.surfaces.clone();
|
||||
options.primary_samples = 4;
|
||||
setup.job = Some(RenderJob::CpuRaytrace { transparent_background: false, options: Box::new(options) });
|
||||
let wire = serde_json::to_value(&setup).unwrap();
|
||||
assert!(setup.validate().unwrap_err().to_string().contains("CPU progressive requires"));
|
||||
assert_eq!(serde_json::to_value(&setup).unwrap(), wire);
|
||||
assert_eq!(setup.surfaces, surfaces);
|
||||
assert_eq!(setup.surfaces.len(), 1);
|
||||
for job in [None, Some(RenderJob::Raster { transparent_background: false })] {
|
||||
setup.job = job;
|
||||
assert!(setup.validate().is_err());
|
||||
assert_eq!(setup.surfaces, surfaces);
|
||||
assert_eq!(setup.surfaces.len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ mod parameters;
|
||||
mod plastic;
|
||||
mod print_face;
|
||||
mod render;
|
||||
pub(crate) use render::{PreparedCapture, prepare_capture_with_metadata, render_cancel_params};
|
||||
pub(crate) use render_ui::batch_request;
|
||||
mod render_ui;
|
||||
#[cfg(test)]
|
||||
mod script_tests;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -18,7 +18,7 @@ pub(super) struct Override {
|
||||
ior: f64,
|
||||
}
|
||||
pub(super) fn apply(scene: &mut ProductScene, overrides: &[Override], command: &str) -> Result<()> {
|
||||
if overrides.len() > 64 {
|
||||
if overrides.len() > solidcraft_doc::render_coat::MAX_COAT_BINDINGS {
|
||||
return Err(bad(command, "at most 64 runtime clear-coat overrides are supported"));
|
||||
}
|
||||
let mut ordinals = std::collections::BTreeSet::new();
|
||||
@@ -31,28 +31,33 @@ pub(super) fn apply(scene: &mut ProductScene, overrides: &[Override], command: &
|
||||
return Err(bad(command, "clear coats require bounded names and distinct captured body ordinals"));
|
||||
}
|
||||
let coat = ClearCoat::new(input.weight, input.roughness, input.ior).map_err(|e| bad(command, e.to_string()))?;
|
||||
let body = scene
|
||||
.meshes
|
||||
.get_mut(input.body_index)
|
||||
.filter(|m| m.name == input.body_name)
|
||||
.ok_or_else(|| bad(command, "clear-coat body ordinal and exact name must both match render.scene"))?;
|
||||
// A zero-weight override is a true no-op, including legacy appearances.
|
||||
if coat.weight() == 0.0 {
|
||||
continue;
|
||||
}
|
||||
if body.transmission.is_some() || body.faces.iter().any(|f| f.transmission.is_some()) {
|
||||
return Err(bad(command, "clear coat and transmission cannot share a body"));
|
||||
}
|
||||
let surface = |color: [u8; 3], existing: Option<OpaqueSurface>| -> Result<OpaqueSurface> {
|
||||
let [r, g, b] = color.map(srgb8_to_linear);
|
||||
let base =
|
||||
existing.map(Ok).unwrap_or_else(|| OpaqueSurface::new(Vec3::new(r, g, b), 0.0, 0.5, 1.5)).map_err(|e| bad(command, e.to_string()))?;
|
||||
Ok(base.with_clear_coat(coat))
|
||||
};
|
||||
body.surface = Some(surface(body.color, body.surface)?);
|
||||
for face in &mut body.faces {
|
||||
face.surface = Some(surface(face.color, face.surface)?);
|
||||
}
|
||||
apply_one(scene, input.body_index, &input.body_name, coat, command)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn apply_one(scene: &mut ProductScene, body_index: usize, body_name: &str, coat: ClearCoat, command: &str) -> Result<()> {
|
||||
let body = scene
|
||||
.meshes
|
||||
.get_mut(body_index)
|
||||
.filter(|m| m.name == body_name)
|
||||
.ok_or_else(|| bad(command, "clear-coat body ordinal and exact name must both match render.scene"))?;
|
||||
// A zero-weight override is a true no-op, including legacy appearances.
|
||||
if coat.weight() == 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
if body.transmission.is_some() || body.faces.iter().any(|f| f.transmission.is_some()) {
|
||||
return Err(bad(command, "clear coat and transmission cannot share a body"));
|
||||
}
|
||||
let surface = |color: [u8; 3], existing: Option<OpaqueSurface>| -> Result<OpaqueSurface> {
|
||||
let [r, g, b] = color.map(srgb8_to_linear);
|
||||
let base =
|
||||
existing.map(Ok).unwrap_or_else(|| OpaqueSurface::new(Vec3::new(r, g, b), 0.0, 0.5, 1.5)).map_err(|e| bad(command, e.to_string()))?;
|
||||
Ok(base.with_clear_coat(coat))
|
||||
};
|
||||
body.surface = Some(surface(body.color, body.surface)?);
|
||||
for face in &mut body.faces {
|
||||
face.surface = Some(surface(face.color, face.surface)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -85,3 +90,149 @@ mod tests {
|
||||
assert!(apply(&mut original.clone(), &duplicate, "render.start").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stable_tests {
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
use serde_json::json;
|
||||
use solidcraft_doc::render_coat::{CoatBindings, CoatMaterial, SavedCoat};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn stable_coats_save_reopen_replace_and_undo_offline_for_cpu_and_gpu_without_changing_sources() {
|
||||
for method in ["cpu_raytrace", "gpu_path"] {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":3,"body_name":"Housing"})).unwrap();
|
||||
s.execute("material.assign", &json!({"bodies":["Housing"],"material":"Steel"})).unwrap();
|
||||
s.execute("render.settings.set", &json!({"name":"Coat","set":{"width":8,"height":8,"job":{"method":method}}})).unwrap();
|
||||
let physical = s.execute("component.properties", &json!({})).unwrap();
|
||||
let model = s.model.state();
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let evaluations = s.evaluations;
|
||||
let coat = json!({"weight":0.63,"roughness":0.61,"ior":1.7});
|
||||
s.execute("render.coat.bind", &json!({"name":"Coat","body_index":0,"body_name":"Housing","coat":coat,"expected_revision":s.revision}))
|
||||
.unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.evaluations, evaluations);
|
||||
assert!(Arc::ptr_eq(&model, &s.model.state()));
|
||||
assert_eq!(s.doc.features, before.features);
|
||||
assert_eq!(s.doc.materials, before.materials);
|
||||
assert_eq!(physical, s.execute("component.properties", &json!({})).unwrap());
|
||||
let saved = s.doc.clone();
|
||||
let loaded = solidcraft_doc::Document::from_json(&saved.to_json()).unwrap();
|
||||
assert_eq!(loaded.render, saved.render);
|
||||
let original = crate::product_scene::extract(&s).unwrap();
|
||||
let mut scene = original.clone();
|
||||
let faces_capacity = scene.meshes[0].faces.capacity();
|
||||
let cpu = solidcraft_render::job::RaytraceOptions::default();
|
||||
super::super::coat_bindings::apply(
|
||||
&s,
|
||||
&mut scene,
|
||||
&saved.render.setups["Coat"].coats,
|
||||
(method == "cpu_raytrace").then_some(&cpu),
|
||||
"check",
|
||||
)
|
||||
.unwrap();
|
||||
let actual = scene.meshes[0].surface.unwrap().clear_coat().unwrap();
|
||||
assert_eq!((actual.weight(), actual.roughness(), actual.ior()), (0.63, 0.61, 1.7));
|
||||
assert!(Arc::ptr_eq(&original.meshes[0].mesh, &scene.meshes[0].mesh));
|
||||
assert_eq!(original.meshes[0].faces.len(), scene.meshes[0].faces.len());
|
||||
assert_eq!(faces_capacity, scene.meshes[0].faces.capacity());
|
||||
assert_eq!(s.doc, saved, "captured coat never changes the source appearance");
|
||||
for face in &scene.meshes[0].faces {
|
||||
assert_eq!(face.surface.unwrap().clear_coat(), Some(actual));
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, saved);
|
||||
let replacement = json!({"name":"Coat","body_index":0,"body_name":"Housing","coat":{"weight":0.4,"roughness":0.3,"ior":1.8},"expected_revision":s.revision});
|
||||
assert!(s.execute("render.coat.bind", &replacement).is_err());
|
||||
assert_eq!(s.doc, saved);
|
||||
let mut explicit = replacement;
|
||||
explicit["replace"] = json!(true);
|
||||
s.execute("render.coat.bind", &explicit).unwrap();
|
||||
assert_eq!(s.doc.render.setups["Coat"].coats.iter().next().unwrap().coat.ior, 1.8);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, saved);
|
||||
let target = saved.render.setups["Coat"].coats.iter().next().unwrap().target.clone();
|
||||
s.execute("render.coat.unbind", &json!({"name":"Coat","target":target,"expected_revision":s.revision})).unwrap();
|
||||
assert!(s.doc.render.setups["Coat"].coats.is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, saved);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn stable_coat_source_replacement_hidden_and_glass_guards_never_guess_or_mutate_a_binding() {
|
||||
for method in ["cpu_raytrace", "gpu_path"] {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":4,"width":4,"height":2,"body_name":"Housing"})).unwrap();
|
||||
s.execute(
|
||||
"render.coat.bind",
|
||||
&json!({"name":"Coat","set":{"width":4,"height":4,"job":{"method":method}},
|
||||
"body_index":0,"body_name":"Housing","coat":{"weight":0},"expected_revision":s.revision}),
|
||||
)
|
||||
.unwrap();
|
||||
let saved = s.doc.render.setups["Coat"].coats.clone();
|
||||
let target = saved.iter().next().unwrap().target.clone();
|
||||
let original = crate::product_scene::extract(&s).unwrap();
|
||||
let mut zero = original.clone();
|
||||
super::super::coat_bindings::apply(&s, &mut zero, &saved, None, "check").unwrap();
|
||||
assert_eq!(zero.meshes[0].surface, original.meshes[0].surface);
|
||||
assert!(Arc::ptr_eq(&zero.meshes[0].mesh, &original.meshes[0].mesh));
|
||||
let before = s.doc.clone();
|
||||
assert!(
|
||||
s.execute(
|
||||
"render.optics.bind",
|
||||
&json!({"name":"Coat","body_index":0,"body_name":"Housing","optics":{},"expected_revision":s.revision})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(s.doc, before, "zero weight is authored opaque intent, not permission to silently drop it on Glass");
|
||||
let mut glass = original;
|
||||
glass.meshes[0].transmission = Some(solidcraft_render::transmission::Transmission::new(Vec3::ZERO, None).unwrap());
|
||||
assert!(super::super::coat_bindings::apply(&s, &mut glass, &saved, None, "check").unwrap_err().to_string().contains("opaque"));
|
||||
s.execute("browser.visibility", &json!({"items":["Housing"],"visible":false})).unwrap();
|
||||
let mut hidden = crate::product_scene::extract(&s).unwrap();
|
||||
assert!(super::super::coat_bindings::apply(&s, &mut hidden, &saved, None, "check").unwrap_err().to_string().contains("hidden"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("timeline.delete", &json!({"feature":target.feature.to_string()})).unwrap();
|
||||
s.execute("solid.box", &json!({"length":4,"width":4,"height":2,"body_name":"Housing"})).unwrap();
|
||||
let mut replaced = crate::product_scene::extract(&s).unwrap();
|
||||
assert_eq!((replaced.meshes.len(), replaced.meshes[0].name.as_str()), (1, "Housing"));
|
||||
assert!(super::super::coat_bindings::apply(&s, &mut replaced, &saved, None, "check").unwrap_err().to_string().contains("changed"));
|
||||
assert_eq!(s.doc.render.setups["Coat"].coats, saved);
|
||||
// Empty means the original capture path, even when no old target survives.
|
||||
super::super::coat_bindings::apply(&s, &mut replaced, &CoatBindings::default(), None, "check").unwrap();
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn stable_cpu_coats_refuse_selected_transport_but_leave_authored_values_and_scene_sources_intact() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":4,"width":4,"height":2,"body_name":"Housing"})).unwrap();
|
||||
let target = super::super::surface_bindings::targets(&s, "check").unwrap().into_keys().next().unwrap();
|
||||
let mut bindings = CoatBindings::default();
|
||||
bindings.set(SavedCoat { target, coat: CoatMaterial::default() }, false).unwrap();
|
||||
let scene = crate::product_scene::extract(&s).unwrap();
|
||||
let options = solidcraft_render::job::RaytraceOptions {
|
||||
reflections: Some(solidcraft_render::job::ReflectionOptions {
|
||||
samples: 1,
|
||||
max_bounces: 1,
|
||||
bodies: vec![solidcraft_render::job::ReflectionBody {
|
||||
body: "Housing".into(),
|
||||
mode: solidcraft_render::job::ReflectionMode::RoughSpecular,
|
||||
}],
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let before = s.doc.clone();
|
||||
let mut captured = scene.clone();
|
||||
let error = super::super::coat_bindings::apply(&s, &mut captured, &bindings, Some(&options), "check").unwrap_err().to_string();
|
||||
assert!(error.contains("selected reflection"));
|
||||
assert_eq!(captured.meshes[0].surface, scene.meshes[0].surface);
|
||||
assert_eq!(bindings.iter().next().unwrap().coat, CoatMaterial::default());
|
||||
assert_eq!(s.doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Stable opaque clear-coat bindings shared by CPU and GPU scene capture.
|
||||
//! Lowering changes only already-sized captured surface fields, never render options.
|
||||
use crate::{Result, Session, params::bad};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{
|
||||
render_coat::{CoatBindings, SavedCoat},
|
||||
render_settings::RenderJob,
|
||||
render_surface::RenderBodyTarget,
|
||||
};
|
||||
use solidcraft_render::{ProductScene, job::RaytraceOptions};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
/// No bindings is the original zero-allocation capture path. Even zero-weight
|
||||
/// authored bindings require a current opaque target; they cannot mask Glass.
|
||||
pub(super) fn apply(s: &Session, scene: &mut ProductScene, bindings: &CoatBindings, cpu: Option<&RaytraceOptions>, command: &str) -> Result<()> {
|
||||
if bindings.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
bindings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
let identities = super::surface_bindings::targets(s, command)?;
|
||||
// The shared resolver indexes authored source identity. This additional
|
||||
// captured-name index is bounded by the 64 requested targets, not scene size.
|
||||
let mut visible = BTreeMap::new();
|
||||
for binding in bindings.iter() {
|
||||
let name = identities.get(&binding.target).ok_or_else(|| bad(command, "saved clear-coat target changed; explicit rebinding required"))?;
|
||||
if visible.insert(name.as_str(), None).is_some() {
|
||||
return Err(bad(command, "ambiguous stable clear-coat world target"));
|
||||
}
|
||||
}
|
||||
for (index, body) in scene.meshes.iter().enumerate() {
|
||||
if let Some(slot) = visible.get_mut(body.name.as_str())
|
||||
&& slot.replace(index).is_some()
|
||||
{
|
||||
return Err(bad(command, "ambiguous captured clear-coat target"));
|
||||
}
|
||||
}
|
||||
let mut resolved = Vec::new();
|
||||
resolved.try_reserve_exact(bindings.len()).map_err(|_| bad(command, "could not reserve clear-coat capture metadata"))?;
|
||||
if resolved.capacity() != bindings.len() {
|
||||
return Err(bad(command, "clear-coat capture capacity exceeds admission"));
|
||||
}
|
||||
for binding in bindings.iter() {
|
||||
let name = identities.get(&binding.target).ok_or_else(|| bad(command, "saved clear-coat target changed; explicit rebinding required"))?;
|
||||
let index = visible.get(name.as_str()).copied().flatten().ok_or_else(|| bad(command, "saved clear-coat target is hidden or absent"))?;
|
||||
let body = scene.meshes.get(index).ok_or_else(|| bad(command, "clear-coat target disappeared"))?;
|
||||
if body.transmission.is_some() || body.faces.iter().any(|face| face.transmission.is_some()) {
|
||||
return Err(bad(command, "saved clear coat requires an opaque target, including zero-weight authored coats"));
|
||||
}
|
||||
if cpu.is_some_and(|options| {
|
||||
options.dielectrics.iter().any(|entry| entry.body_index == index)
|
||||
|| options.reflections.as_ref().is_some_and(|overrides| overrides.bodies.iter().any(|entry| entry.body == *name))
|
||||
|| options.anisotropic_faces.iter().flatten().any(|entry| entry.body_index == index)
|
||||
}) {
|
||||
return Err(bad(command, "CPU clear-coat targets cannot share Glass, selected reflection or anisotropy overrides"));
|
||||
}
|
||||
resolved.push((index, name.as_str(), binding.coat.to_runtime().map_err(|e| bad(command, e.to_string()))?));
|
||||
}
|
||||
drop(visible);
|
||||
for (index, name, coat) in resolved {
|
||||
super::clear_coat::apply_one(scene, index, name, coat, command)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn bind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.coat.bind";
|
||||
let input: crate::schema::render::CoatBindParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let coat = input.coat.material();
|
||||
coat.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.expected_revision != s.revision {
|
||||
return Err(bad(command, "clear-coat binding review is stale"));
|
||||
}
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
if s.doc.render.setups.get(&input.name).is_some_and(|old| old.coats != setup.coats) {
|
||||
return Err(bad(command, "setup patch cannot replace saved clear coats; use explicit bind/unbind"));
|
||||
}
|
||||
if !matches!(setup.job, Some(RenderJob::CpuRaytrace { .. } | RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "saved clear coats require an explicit CPU or GPU raytrace setup"));
|
||||
}
|
||||
let mut scene = crate::product_scene::extract(s)?;
|
||||
let name = &scene
|
||||
.meshes
|
||||
.get(input.body_index)
|
||||
.filter(|body| body.name == input.body_name)
|
||||
.ok_or_else(|| bad(command, "clear-coat target ordinal and exact name do not match"))?
|
||||
.name;
|
||||
let identities = super::surface_bindings::targets(s, command)?;
|
||||
let mut candidates = identities.iter().filter(|(_, world)| *world == name);
|
||||
let target =
|
||||
candidates.next().map(|(target, _)| target.clone()).ok_or_else(|| bad(command, "clear-coat target has no stable source identity"))?;
|
||||
if candidates.next().is_some() {
|
||||
return Err(bad(command, "ambiguous clear-coat target identity"));
|
||||
}
|
||||
setup.coats.set(SavedCoat { target, coat }, input.replace).map_err(|e| bad(command, e.to_string()))?;
|
||||
setup.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
// Verify the actual opaque source even for inactive authored values. Runtime
|
||||
// options are not invented while saving an offline stable numerical setup.
|
||||
apply(s, &mut scene, &setup.coats, None, command)?;
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
settings.setups.insert(input.name.clone(), setup.clone());
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings != *s.doc.render {
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
}
|
||||
Ok(json!({"name":input.name,"setup":setup,"geometry_and_engineering_unchanged":true}))
|
||||
}
|
||||
|
||||
pub(super) fn unbind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.coat.unbind";
|
||||
let input: crate::schema::render::CoatUnbindParams = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let target: RenderBodyTarget = serde_json::from_value(input.target).map_err(|e| bad(command, e.to_string()))?;
|
||||
target.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.expected_revision != s.revision {
|
||||
return Err(bad(command, "clear-coat removal review is stale"));
|
||||
}
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
let setup = settings.setups.get_mut(&input.name).ok_or_else(|| bad(command, "unknown clear-coat setup"))?;
|
||||
let removed = setup.coats.remove(&target);
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings != *s.doc.render {
|
||||
s.doc_mut().render = Arc::new(settings);
|
||||
}
|
||||
Ok(json!({"name":input.name,"removed":removed}))
|
||||
}
|
||||
@@ -13,9 +13,9 @@ fn empty(p: &Value, command: &str) -> Result<()> {
|
||||
}
|
||||
pub(super) fn aging(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
empty(p, "render.aging.describe")?;
|
||||
Ok(json!({"maximum_layers":8,"storage":"GPU saved job.options.aging","order":"bottom to top; later layers modify earlier surface results",
|
||||
Ok(json!({"maximum_layers":8,"storage":"CPU/GPU saved job.options.aging","order":"bottom to top; later layers modify earlier surface results",
|
||||
"presets":AgingKind::ALL.map(AgingLayer::preset),"target":"all eligible opaque bodies or stable definition/feature/component/occurrence identity; capture indices are resolved only for the runtime job; passive stages and glass are excluded",
|
||||
"coordinate_space":"world millimetres, explicit origin and 4096-cell periodic field; not object-following motion",
|
||||
"coordinate_space":"world millimetres, explicit origin; 4096-cell periodic noise with directional Scratch bands; not object-following motion",
|
||||
"preserves":["engineering geometry","mass and physical material cards","pose","source textures","visibility"],
|
||||
"limitations":["authored optical weathering, not measured corrosion or age prediction","no geometric grooves or edge-curvature detection","no aging on glass or participating-medium boundaries","stack changes transport and requires a new render"],
|
||||
"airborne":"use the separately validated finite homogeneous GPU medium; it is not deposited surface dust or a physical lunar atmosphere"}))
|
||||
|
||||
@@ -29,9 +29,11 @@ pub(super) fn set(s: &mut crate::Session, params: &serde_json::Value) -> Result<
|
||||
.transpose()?;
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
let (angle, intensity, maximum) = match &mut setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
if let Some(hdr) = &mut options.hdr {
|
||||
(Some(&mut hdr.rotation_rad), &mut hdr.intensity, 64.0)
|
||||
} else if let Some(SkyEnvironment::Sky { rotation_rad, intensity, .. }) = &mut options.sky {
|
||||
(Some(rotation_rad), intensity, 100.0)
|
||||
} else if let Some(environment) = &mut options.environment {
|
||||
(None, &mut environment.intensity, 64.0)
|
||||
} else {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ use solidcraft_render::{
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct Start {
|
||||
aging: solidcraft_doc::render_aging::SavedAgingStack,
|
||||
studio_weights: Option<[f64; 3]>,
|
||||
progressive: bool,
|
||||
denoise: bool,
|
||||
@@ -74,14 +75,10 @@ struct ProgressiveStart {
|
||||
denoise: bool,
|
||||
}
|
||||
|
||||
struct Prepared {
|
||||
replace: bool,
|
||||
scene: solidcraft_render::ProductScene,
|
||||
request: ImageRequest,
|
||||
reply: Value,
|
||||
executor: solidcraft_render::ExecutorId,
|
||||
options: solidcraft_render::job::JobOptions,
|
||||
environment: Option<solidcraft_render::lighting::environment_input::EnvironmentInput>,
|
||||
pub(crate) struct Prepared {
|
||||
pub(crate) replace: bool,
|
||||
pub(crate) submission: Option<solidcraft_render::job::PreparedSubmission>,
|
||||
pub(crate) reply: Value,
|
||||
}
|
||||
|
||||
fn lower_led(input: &mut Start, command: &str) -> Result<()> {
|
||||
@@ -179,12 +176,13 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
|
||||
let mut hdr = None;
|
||||
let mut lowered = match job {
|
||||
RenderJob::Raster { transparent_background } => json!({"transparent_background":transparent_background}),
|
||||
RenderJob::CpuRaytrace { options } => {
|
||||
RenderJob::CpuRaytrace { mut options, transparent_background } => {
|
||||
hdr = options.hdr.map(SavedHdr::Cpu);
|
||||
let aging = std::mem::take(&mut options.aging);
|
||||
let raytrace = options.to_runtime(settings.exposure_ev).map_err(|e| bad(command, e.to_string()))?;
|
||||
// The legacy CPU path reads EV from RaytraceOptions. There is one saved authority.
|
||||
settings.exposure_ev = 0.0;
|
||||
json!({"integrator":"raytrace","raytrace":raytrace})
|
||||
json!({"integrator":"raytrace","raytrace":raytrace,"aging":aging,"transparent_background":transparent_background})
|
||||
}
|
||||
RenderJob::GpuPath { transparent_background, options } => {
|
||||
hdr = options.hdr.map(SavedHdr::Gpu);
|
||||
@@ -209,14 +207,26 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
|
||||
Ok(Some(Normalized { params: lowered, hdr, surfaces, optics }))
|
||||
}
|
||||
|
||||
fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
|
||||
pub(crate) fn prepare(s: &Session, params: &Value, command: &str, cancel: &solidcraft_render::job::CancelToken) -> Result<Prepared> {
|
||||
prepare_with_metadata(s, params, command, cancel, None)
|
||||
}
|
||||
pub(crate) fn prepare_with_metadata(
|
||||
s: &Session,
|
||||
params: &Value,
|
||||
command: &str,
|
||||
cancel: &solidcraft_render::job::CancelToken,
|
||||
retained: Option<&solidcraft_render::job::RetainedMetadata>,
|
||||
) -> Result<Prepared> {
|
||||
cancel.check().map_err(job_error)?;
|
||||
let normalized = normalize_saved_job(s, params, command)?;
|
||||
let saved_hdr = normalized.as_ref().and_then(|normalized| normalized.hdr.as_ref());
|
||||
let params = normalized.as_ref().map_or(params, |normalized| &normalized.params);
|
||||
if params.get("progressive").and_then(Value::as_bool) == Some(true) {
|
||||
let hdr = match saved_hdr {
|
||||
Some(SavedHdr::Gpu(hdr)) => Some(*hdr),
|
||||
Some(SavedHdr::Cpu(_)) => return Err(bad(command, "saved CPU HDR cannot enter a GPU request")),
|
||||
Some(SavedHdr::Cpu(_)) => {
|
||||
return Err(bad(command, "saved CPU HDR cannot enter a GPU request"));
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
return prepare_progressive(
|
||||
@@ -227,6 +237,8 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
|
||||
hdr,
|
||||
normalized.as_ref().map(|n| &n.surfaces),
|
||||
normalized.as_ref().map(|n| &n.optics),
|
||||
cancel,
|
||||
retained,
|
||||
);
|
||||
}
|
||||
let gpu_raytrace =
|
||||
@@ -242,12 +254,42 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
|
||||
));
|
||||
}
|
||||
let mut input: Start = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let coats = input.settings.as_mut().map_or_else(Default::default, |settings| std::mem::take(&mut settings.coats));
|
||||
// A checked file HDR uses the runtime request lane, but its optical targets
|
||||
// remain stable authored identities. Extract them before validating job:null
|
||||
// settings, just as for stable coats; never discard or turn them into ordinals
|
||||
// in the UI. Exact visible identities are resolved from the captured scene.
|
||||
let inline_optics = input.settings.as_mut().map_or_else(Default::default, |settings| std::mem::take(&mut settings.optics));
|
||||
let normalized_optics = normalized.as_ref().map(|n| &n.optics).filter(|bindings| !bindings.is_empty());
|
||||
if normalized_optics.is_some() && !inline_optics.is_empty() {
|
||||
return Err(bad(command, "choose normalized or inline stable optics, not both"));
|
||||
}
|
||||
let saved_optics = normalized_optics.or((!inline_optics.is_empty()).then_some(&inline_optics));
|
||||
if let Some(bindings) = saved_optics {
|
||||
if input.integrator != Integrator::Raytrace {
|
||||
return Err(bad(command, "stable optics require CPU or GPU ray tracing"));
|
||||
}
|
||||
let options = input.raytrace.get_or_insert_default();
|
||||
bindings.validate_for_cpu_path(options.path.is_some()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if options.volume.is_some_and(|volume| volume.active()) {
|
||||
return Err(bad(command, "CPU Atmosphere cannot combine with saved Glass or subsurface scattering; settings are kept"));
|
||||
}
|
||||
if options.denoise && bindings.iter().any(|binding| binding.optics.subsurface.is_some_and(|scatter| scatter.active())) {
|
||||
return Err(bad(command, "CPU subsurface random walks have no surface-denoiser guides; disable denoising explicitly"));
|
||||
}
|
||||
if !options.dielectrics.is_empty() {
|
||||
return Err(bad(command, "saved optics cannot combine with runtime dielectric overrides"));
|
||||
}
|
||||
}
|
||||
if !coats.is_empty() && input.integrator != Integrator::Raytrace {
|
||||
return Err(bad(command, "stable clear coats require CPU or GPU ray tracing"));
|
||||
}
|
||||
validate_studio_weights(input.studio_weights, command)?;
|
||||
if input.integrator == Integrator::Raytrace && input.studio_weights.is_some() {
|
||||
return Err(bad(command, "studio_weights apply to raster or the implicit GPU studio; use explicit CPU light intensities"));
|
||||
}
|
||||
if input.progressive {
|
||||
return prepare_progressive(s, params, command, false, None, None, None);
|
||||
return prepare_progressive(s, params, command, false, None, None, None, cancel, retained);
|
||||
}
|
||||
if input.denoise {
|
||||
return Err(bad(command, "denoise requires GPU progressive rendering"));
|
||||
@@ -266,14 +308,36 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
|
||||
}
|
||||
host.validate_integrator(input.integrator).map_err(job_error)?;
|
||||
lower_led(&mut input, command)?;
|
||||
if !input.aging.is_empty() {
|
||||
if input.integrator != Integrator::Raytrace {
|
||||
return Err(bad(command, "stable CPU aging requires the CPU raytrace integrator"));
|
||||
}
|
||||
input.aging.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
input.raytrace.get_or_insert_with(RaytraceOptions::default);
|
||||
}
|
||||
let saved_surfaces = normalized.as_ref().map(|n| &n.surfaces).filter(|bindings| !bindings.is_empty());
|
||||
if saved_surfaces.is_some() {
|
||||
if input.integrator != Integrator::Raytrace {
|
||||
return Err(bad(command, "saved opaque surfaces require CPU or GPU ray tracing"));
|
||||
}
|
||||
solidcraft_render::job::textures::validate_cpu_options(input.raytrace.as_ref().unwrap_or(&RaytraceOptions::default())).map_err(job_error)?;
|
||||
if normalized.as_ref().is_some_and(|n| !n.optics.is_empty()) && input.raytrace.as_ref().is_none_or(|options| options.path.is_none()) {
|
||||
return Err(bad(command, "CPU maps with Glass require Indirect lighting and separate targets"));
|
||||
}
|
||||
}
|
||||
if input.integrator == Integrator::Raster && input.raytrace.is_some() {
|
||||
return Err(bad(command, "raytrace options require integrator: raytrace"));
|
||||
}
|
||||
if input.integrator == Integrator::Raytrace && input.transparent_background {
|
||||
return Err(bad(command, "raytrace supports opaque backgrounds only"));
|
||||
}
|
||||
if let Some(options) = &input.raytrace {
|
||||
options.retained_bytes().map_err(job_error)?;
|
||||
if !input.aging.is_empty() {
|
||||
if options.aging.as_ref().is_some_and(|stack| !stack.is_empty()) {
|
||||
return Err(bad(command, "choose stable authored aging or captured runtime aging, not both"));
|
||||
}
|
||||
if input.aging.iter().any(|layer| layer.active()) && options.anisotropic_faces.iter().any(Option::is_some) {
|
||||
return Err(bad(command, "CPU aging cannot replace independently authored anisotropic roughness axes"));
|
||||
}
|
||||
}
|
||||
options.budget.validate_execution().map_err(job_error)?;
|
||||
// Check the complete image envelope before any source file is read.
|
||||
let dimensions = if let Some(name) = &input.setup {
|
||||
@@ -285,7 +349,29 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
|
||||
let defaults = RenderSetup::default();
|
||||
(defaults.width, defaults.height)
|
||||
};
|
||||
options.budget.validate_image(dimensions.0, dimensions.1, options.progressive).map_err(job_error)?;
|
||||
options.budget.validate_image(dimensions.0, dimensions.1, options.camera_sampling().map_err(job_error)?).map_err(job_error)?;
|
||||
if let Some(lens) = options.lens {
|
||||
let camera = input
|
||||
.setup
|
||||
.as_ref()
|
||||
.and_then(|name| s.doc.render.setups.get(name))
|
||||
.map(|setup| setup.camera)
|
||||
.or_else(|| input.settings.as_ref().map(|setup| setup.camera))
|
||||
.or(s.camera)
|
||||
.unwrap_or_default();
|
||||
lens.validate_camera(&camera).map_err(job_error)?;
|
||||
}
|
||||
if let Some(output) = &options.output {
|
||||
output.validate_image(dimensions.0, dimensions.1).map_err(job_error)?;
|
||||
output.presentation(options.exposure_ev, [0; 3], input.transparent_background).validate().map_err(job_error)?;
|
||||
}
|
||||
}
|
||||
// Refuse conflicting sources before VFS/resource access. Sky is a direct
|
||||
// radiance consumer and never a substitute for unread HDR bytes.
|
||||
if (saved_hdr.is_some() || input.environment.is_some())
|
||||
&& input.raytrace.as_ref().is_some_and(|options| options.sky.is_some() || options.environment.is_some())
|
||||
{
|
||||
return Err(bad(command, "choose either CPU HDR input, gradient Sky or constant environment"));
|
||||
}
|
||||
let environment = if let Some(saved_hdr) = saved_hdr {
|
||||
let SavedHdr::Cpu(hdr) = saved_hdr else {
|
||||
@@ -336,6 +422,23 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
|
||||
None
|
||||
};
|
||||
let mut scene = crate::product_scene::extract(s)?;
|
||||
if let Some(bindings) = saved_optics {
|
||||
let options = input.raytrace.as_mut().ok_or_else(|| bad(command, "saved CPU optics require a CPU raytrace request"))?;
|
||||
if !options.dielectrics.is_empty() {
|
||||
return Err(bad(command, "saved optics cannot combine with runtime dielectric overrides"));
|
||||
}
|
||||
options.dielectrics = super::optical_bindings::resolve_cpu_path(s, &scene, bindings, options.path.is_some(), command)?;
|
||||
options.validate().map_err(job_error)?;
|
||||
}
|
||||
super::coat_bindings::apply(s, &mut scene, &coats, input.raytrace.as_ref(), command)?;
|
||||
if !input.aging.is_empty() {
|
||||
let options = input.raytrace.as_mut().ok_or_else(|| bad(command, "CPU aging requires a CPU raytrace request"))?;
|
||||
if options.aging.as_ref().is_some_and(|stack| !stack.is_empty()) {
|
||||
return Err(bad(command, "choose stable authored aging or captured runtime aging, not both"));
|
||||
}
|
||||
options.aging = Some(Box::new(super::effects::resolve_aging(s, &scene, &input.aging, command)?));
|
||||
options.validate().map_err(job_error)?;
|
||||
}
|
||||
if environment.is_some() {
|
||||
input.raytrace.as_ref().unwrap_or(&RaytraceOptions::default()).validate_environment_reflections(&scene).map_err(job_error)?;
|
||||
}
|
||||
@@ -360,7 +463,13 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
|
||||
if input.integrator == Integrator::Raytrace {
|
||||
settings.validate_for_cpu(budget).map_err(|e| bad(command, e.to_string()))?;
|
||||
budget.validate_execution().map_err(job_error)?;
|
||||
budget.validate_image(settings.width, settings.height, input.raytrace.as_ref().and_then(|o| o.progressive)).map_err(job_error)?;
|
||||
budget
|
||||
.validate_image(
|
||||
settings.width,
|
||||
settings.height,
|
||||
input.raytrace.as_ref().map(|o| o.camera_sampling()).transpose().map_err(job_error)?.flatten(),
|
||||
)
|
||||
.map_err(job_error)?;
|
||||
} else {
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
}
|
||||
@@ -407,10 +516,16 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
|
||||
if resolved.executor != expected_executor {
|
||||
return Err(bad(command, "resolved executor is not installed as an image job host"));
|
||||
}
|
||||
let textures = if let Some(bindings) = saved_surfaces {
|
||||
let overrides = super::surface_bindings::resolve(s, &scene, bindings, command)?;
|
||||
super::textures::capture(&scene, &overrides, command)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let request = ImageRequest {
|
||||
studio_weights: validate_studio_weights(input.studio_weights, command)?,
|
||||
environment: None,
|
||||
textures: None,
|
||||
textures,
|
||||
integrator: input.integrator,
|
||||
raytrace: input.raytrace.take(),
|
||||
camera: settings.camera,
|
||||
@@ -419,12 +534,18 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
|
||||
background: settings.background,
|
||||
transparent_background: input.transparent_background,
|
||||
};
|
||||
let mut admission = solidcraft_render::job::admit(&scene, &request).map_err(job_error)?;
|
||||
let environment_bytes = environment.as_ref().map_or(Ok(0), |e| e.required_bytes()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let submission =
|
||||
host.prepare_submission_with_metadata(scene, request, Default::default(), environment, cancel.clone(), retained).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)?;
|
||||
let retained_environment =
|
||||
if environment_info.is_some() { std::mem::size_of::<solidcraft_render::lighting::environment_input::EnvironmentInfo>() as u64 } else { 0 };
|
||||
let retained_request_bytes = retained_lights.checked_add(retained_environment).ok_or_else(|| bad(command, "request metadata overflow"))?;
|
||||
let retained_request_bytes = retained_lights
|
||||
.checked_add(retained_environment)
|
||||
.and_then(|bytes| bytes.checked_add(request.retained_resource_metadata_bytes()))
|
||||
.ok_or_else(|| bad(command, "request metadata overflow"))?;
|
||||
|
||||
admission.estimated_total_bytes = admission
|
||||
.estimated_total_bytes
|
||||
@@ -437,10 +558,38 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
|
||||
let transparency = match input.integrator {
|
||||
Integrator::Raster => "sorted_alpha_approximation",
|
||||
Integrator::Raytrace if refraction => "physical_transmission",
|
||||
Integrator::Raytrace if input.transparent_background => "coverage_alpha",
|
||||
Integrator::Raytrace => "opaque_only",
|
||||
};
|
||||
let reply = json!({"ignored_controls":ignored,"stage_panels":settings.stage.len(),"stage_transport":"CPU direct/specular only; no diffuse interreflection", "cpu_budget":budget,"cpu_limits":budget.limits(),"environment":environment_info,"retained_request_bytes":retained_request_bytes,"resolved":resolved,"admission":admission,"request":request,"scene_generation":s.revision,"host":host.kind(),"transparency":transparency,"refraction":refraction,"dielectric_boundaries":if refraction {vec!["thin_sheet","closed_box"]} else {Vec::<&str>::new()}});
|
||||
Ok(Prepared { replace: input.replace, scene, request, reply, environment, executor: resolved.executor, options: Default::default() })
|
||||
let path = request.raytrace.as_ref().is_some_and(|options| options.path.is_some());
|
||||
let stage_transport = if path && refraction {
|
||||
"CPU finite-depth diffuse/specular and dielectric path lighting"
|
||||
} else if path {
|
||||
"CPU finite-depth opaque diffuse/specular path lighting"
|
||||
} else {
|
||||
"CPU direct/specular only; no diffuse interreflection"
|
||||
};
|
||||
let dielectric_boundaries = match (refraction, path) {
|
||||
(true, true) => vec!["thin_sheet", "closed_box", "closed_mesh"],
|
||||
(true, false) => vec!["thin_sheet", "closed_box"],
|
||||
(false, _) => Vec::new(),
|
||||
};
|
||||
let mut reply = json!({"ignored_controls":ignored,"stage_panels":settings.stage.len(),"stage_transport":stage_transport, "cpu_budget":budget,"cpu_limits":budget.limits(),"environment":environment_info,"textures":request.textures.as_ref().map(|maps|maps.summary()),"retained_request_bytes":retained_request_bytes,"resolved":resolved,"admission":admission,"request":request,"scene_generation":s.revision,"host":host.kind(),"transparency":transparency,"refraction":refraction,"dielectric_boundaries":dielectric_boundaries});
|
||||
if !coats.is_empty() {
|
||||
reply["coats"] = json!(coats);
|
||||
}
|
||||
if request.raytrace.as_ref().is_some_and(|options| options.denoise) {
|
||||
reply["denoising"] = json!({
|
||||
"method":"edge_aware_atrous", "backend":"native_cpu", "passes":3, "steps":[1,2,4],
|
||||
"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",
|
||||
"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"
|
||||
});
|
||||
}
|
||||
Ok(Prepared { replace: input.replace, submission: Some(submission), reply })
|
||||
}
|
||||
|
||||
fn prepare_progressive(
|
||||
@@ -451,6 +600,8 @@ fn prepare_progressive(
|
||||
saved_hdr: Option<GpuHdrEnvironment>,
|
||||
saved_surfaces: Option<&solidcraft_doc::render_surface::SurfaceBindings>,
|
||||
saved_optics: Option<&solidcraft_doc::render_optics::OpticalBindings>,
|
||||
cancel: &solidcraft_render::job::CancelToken,
|
||||
retained: Option<&solidcraft_render::job::RetainedMetadata>,
|
||||
) -> Result<Prepared> {
|
||||
let cpu_raytrace = params
|
||||
.get("raytrace")
|
||||
@@ -465,6 +616,19 @@ fn prepare_progressive(
|
||||
return Err(bad(command, "progressive rendering does not accept CPU raytrace, environment or LED options"));
|
||||
}
|
||||
let mut input: ProgressiveStart = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let coats = input.settings.as_mut().map_or_else(Default::default, |settings| std::mem::take(&mut settings.coats));
|
||||
let inline_optics = input.settings.as_mut().map_or_else(Default::default, |settings| std::mem::take(&mut settings.optics));
|
||||
let normalized_optics = saved_optics.filter(|bindings| !bindings.is_empty());
|
||||
if normalized_optics.is_some() && !inline_optics.is_empty() {
|
||||
return Err(bad(command, "choose normalized or inline stable optics, not both"));
|
||||
}
|
||||
let saved_optics = normalized_optics.or((!inline_optics.is_empty()).then_some(&inline_optics));
|
||||
if let Some(bindings) = saved_optics {
|
||||
bindings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if !input.raytrace.dielectrics.is_empty() || !input.raytrace.clear_coats.is_empty() {
|
||||
return Err(bad(command, "saved optical bindings cannot be mixed with transient optical overrides"));
|
||||
}
|
||||
}
|
||||
validate_studio_weights(input.studio_weights, command)?;
|
||||
if input.studio_weights.is_some() && (input.lighting.is_some() || input.environment.is_some() || saved_hdr.is_some()) {
|
||||
return Err(bad(command, "studio_weights require the implicit GPU studio without environment lighting"));
|
||||
@@ -503,7 +667,11 @@ fn prepare_progressive(
|
||||
}
|
||||
input.raytrace.dielectrics = super::optical_bindings::resolve(s, &scene, bindings, command)?;
|
||||
}
|
||||
if !coats.is_empty() && !input.raytrace.clear_coats.is_empty() {
|
||||
return Err(bad(command, "stable clear coats cannot combine with runtime clear-coat overrides"));
|
||||
}
|
||||
super::transmission::apply(&mut scene, &input.raytrace.dielectrics, command)?;
|
||||
super::coat_bindings::apply(s, &mut scene, &coats, None, command)?;
|
||||
super::clear_coat::apply(&mut scene, &input.raytrace.clear_coats, command)?;
|
||||
let settings = if let Some(name) = &input.setup {
|
||||
s.doc.render.setups.get(name).cloned().ok_or_else(|| bad(command, "no saved render setup with that name"))?
|
||||
@@ -626,15 +794,22 @@ fn prepare_progressive(
|
||||
transparent_background: input.transparent_background,
|
||||
textures: super::textures::capture(&scene, &input.raytrace.textures, command)?,
|
||||
};
|
||||
let mut admission = host.admit(&scene, &request, options).map_err(job_error)?;
|
||||
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 request = submission.request();
|
||||
let mut admission = submission.admission();
|
||||
let retained_options_bytes = options.retained_bytes().map_err(job_error)?;
|
||||
admission.estimated_total_bytes = admission
|
||||
.estimated_total_bytes
|
||||
.checked_add(request.retained_resource_metadata_bytes())
|
||||
.and_then(|bytes| bytes.checked_add(retained_options_bytes))
|
||||
.filter(|bytes| *bytes <= host.job_limit_bytes())
|
||||
.ok_or_else(|| bad(command, "GPU work and retained metadata exceed the per-job limit"))?;
|
||||
let refraction = input.progressive && scene.has_transmission();
|
||||
let reply = json!({"stage_panels":settings.stage.len(),"resolved":resolved,"admission":admission,"request":request,"textures":request.textures.as_ref().map(|resources|resources.summary()),"scene_generation":s.revision,"host":host.kind(),"transparency":if refraction {"physical_transmission"} else if input.progressive {"opaque_only"} else {"sorted_alpha_approximation"},"options":options,"environment":request.environment.as_ref().map(|source|source.info()),"refraction":refraction,"dielectric_boundaries": if resolved.executor.as_str() == "gpu-path" {vec!["thin_sheet","closed_box","closed_mesh"]} else {Vec::<&str>::new()}});
|
||||
let reply = json!({"stage_panels":settings.stage.len(),"resolved":resolved,"admission":admission,"request":request,"textures":request.textures.as_ref().map(|resources|resources.summary()),"scene_generation":s.revision,"host":host.kind(),"transparency":if refraction {"physical_transmission"} else if input.transparent_background {"coverage_alpha"} else if input.progressive {"opaque_only"} else {"sorted_alpha_approximation"},"options":options,"retained_options_bytes":retained_options_bytes,"environment":request.environment.as_ref().map(|source|source.info()),"refraction":refraction,"dielectric_boundaries": if resolved.executor.as_str() == "gpu-path" {vec!["thin_sheet","closed_box","closed_mesh"]} else {Vec::<&str>::new()}});
|
||||
let mut reply = reply;
|
||||
if !coats.is_empty() {
|
||||
reply["coats"] = json!(coats);
|
||||
}
|
||||
if options.denoise {
|
||||
reply["denoising"] = json!({
|
||||
"method":"edge_aware_atrous", "passes":3, "steps":[1,2,4],
|
||||
@@ -644,7 +819,7 @@ fn prepare_progressive(
|
||||
"raw_linear_accumulator_preserved":true, "output_stage":"linear_before_tone_map"
|
||||
});
|
||||
}
|
||||
Ok(Prepared { replace: input.replace, scene, request, reply, executor: resolved.executor, options, environment: None })
|
||||
Ok(Prepared { replace: input.replace, submission: Some(submission), reply })
|
||||
}
|
||||
|
||||
/// The setup's controls the raster image executor doesn't apply: those not at their neutral value.
|
||||
@@ -663,24 +838,18 @@ fn ignored_controls(settings: &RenderSetup) -> Vec<&'static str> {
|
||||
}
|
||||
|
||||
pub(super) fn preflight(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
prepare(s, params, "render.preflight").map(|p| p.reply)
|
||||
prepare(s, params, "render.preflight", &solidcraft_render::job::CancelToken::default()).map(|p| p.reply)
|
||||
}
|
||||
|
||||
pub(super) fn start(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let p = prepare(s, params, "render.start")?;
|
||||
let p = prepare(s, params, "render.start", &solidcraft_render::job::CancelToken::default())?;
|
||||
let status = s
|
||||
.render_jobs
|
||||
.start_with_environment(
|
||||
.start_prepared(
|
||||
s.revision,
|
||||
p.scene,
|
||||
p.request,
|
||||
p.submission.ok_or_else(|| bad("render.start", "missing prepared submission"))?,
|
||||
p.replace,
|
||||
crate::render_jobs::Execution {
|
||||
executor: p.executor,
|
||||
options: p.options,
|
||||
environment: p.environment,
|
||||
shot: params.get("setup").and_then(Value::as_str).map(str::to_owned),
|
||||
},
|
||||
params.get("setup").and_then(Value::as_str).map(str::to_owned),
|
||||
)
|
||||
.map_err(job_error)?;
|
||||
let mut status = json!(status);
|
||||
@@ -755,9 +924,18 @@ pub(super) fn copy(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
Ok(json!({"frame":frame.info}))
|
||||
}
|
||||
|
||||
pub(super) fn cancel(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
pub(crate) fn cancel_params(params: &Value) -> Result<Option<JobId>> {
|
||||
let empty = Value::Object(Default::default());
|
||||
let params = if params.is_null() { &empty } else { params };
|
||||
let input: ResultJob = serde_json::from_value(params.clone()).map_err(|e| bad("render.cancel", e.to_string()))?;
|
||||
let job = input.job.or(s.render_queue_state().job).ok_or_else(|| bad("render.cancel", "job is required without an active shot queue"))?;
|
||||
Ok(input.job)
|
||||
}
|
||||
pub(super) fn cancel(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let requested = cancel_params(params)?;
|
||||
if requested.is_none() && s.cancel_native_preparation() {
|
||||
return Ok(json!({"preparing":true,"cancel_requested":true,"queue":s.render_queue_state()}));
|
||||
}
|
||||
let job = requested.or(s.render_queue_state().job).ok_or_else(|| bad("render.cancel", "job is required without an active shot queue"))?;
|
||||
let status = s.render_jobs.status(job).map_err(job_error)?;
|
||||
s.cancel_render_batch(job);
|
||||
if status.progress.phase.terminal() {
|
||||
|
||||
@@ -9,17 +9,17 @@ use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{
|
||||
render_resources::{CpuHdrEnvironment, GpuHdrEnvironment, HdrId, HdrResource},
|
||||
render_settings::RenderJob,
|
||||
render_settings::{CpuJob, RenderJob, RenderSetup},
|
||||
};
|
||||
use solidcraft_render::{
|
||||
job::{AreaLight, AreaLights, StudioLighting, Sun},
|
||||
job::{AreaLight, AreaLights, RectangleLightOptions, StudioLighting, Sun},
|
||||
texture_input::MapRole,
|
||||
};
|
||||
|
||||
use crate::schema::TypedContract;
|
||||
use crate::schema::render_library::{
|
||||
LibraryDescribeParams, LibraryListParams, LibraryPage, LibraryPreview, LibraryPreviewParams, LibraryResource, LibraryTargets,
|
||||
LibraryTargetsParams, Pbr, RenderTarget, ResourceSummary, SceneBounds,
|
||||
LibraryTargetsParams, Pbr, RenderTarget, ResourceSummary, SceneBounds, StableRenderTarget,
|
||||
};
|
||||
use crate::{Result, Session, params::bad};
|
||||
|
||||
@@ -98,7 +98,7 @@ fn projection(kind: &str, value: &Value) -> Value {
|
||||
"native_scenes" => json!({"status":"native_composition","executor":"gpu-path","variants":["review_then_apply"],
|
||||
"preserves":["camera","materials","geometry","engineering_properties"],"requires_subject_and_explicit_replacement_consent":true}),
|
||||
"surfaces" => {
|
||||
json!({"status":"implemented_pending_batch_validation","variants":if cfg!(feature="bundled-render-library") {vec!["uniform_opaque","gpu_planar_maps"]} else {vec!["uniform_opaque"]},
|
||||
json!({"status":"implemented_pending_batch_validation","variants":if cfg!(feature="bundled-render-library") {vec!["uniform_opaque","gpu_planar_maps","cpu_saved_planar_maps"]} else {vec!["uniform_opaque"]},
|
||||
"omitted":["height_displacement","true_anisotropic_brdf","manufacturing_geometry"],"maps_portability":"version_and_digest_pinned_builtin_setup_bindings"})
|
||||
}
|
||||
"selene_appearances" => json!({"status":"implemented_pending_batch_validation","variants":["uniform_opaque"],
|
||||
@@ -227,6 +227,12 @@ pub(super) fn targets(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
}
|
||||
let query = input.query.to_lowercase();
|
||||
let scene = crate::product_scene::extract(s)?;
|
||||
let mut stable = BTreeMap::new();
|
||||
for (target, name) in super::surface_bindings::targets(s, command)? {
|
||||
if stable.insert(name, target).is_some() {
|
||||
return Err(bad(command, "ambiguous stable occurrence name"));
|
||||
}
|
||||
}
|
||||
let count = scene.meshes.iter().filter(|body| body.name.to_lowercase().contains(&query)).count();
|
||||
let items: Vec<_> = scene
|
||||
.meshes
|
||||
@@ -238,6 +244,7 @@ pub(super) fn targets(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
.map(|(ordinal, body)| RenderTarget {
|
||||
ordinal,
|
||||
name: body.name.clone(),
|
||||
stable_target: stable.get(&body.name).map(StableRenderTarget::from),
|
||||
color: body.color,
|
||||
opacity: body.opacity,
|
||||
pbr: body.surface.map(|v| Pbr { metallic: v.metallic(), roughness: v.roughness(), dielectric_ior: v.dielectric_ior() }),
|
||||
@@ -435,7 +442,7 @@ struct EnvironmentApply {
|
||||
|
||||
fn has_environment(setup: &solidcraft_doc::render_settings::RenderSetup) -> bool {
|
||||
match &setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options }) => options.hdr.is_some() || options.environment.is_some(),
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => options.hdr.is_some() || options.environment.is_some() || options.sky.is_some(),
|
||||
Some(RenderJob::GpuPath { options, .. }) => options.hdr.is_some() || options.environment.is_some(),
|
||||
_ => false,
|
||||
}
|
||||
@@ -452,10 +459,13 @@ pub(super) fn apply_environment(s: &mut Session, params: &Value) -> Result<Value
|
||||
let rotation = hdr_rotation(input.rotation_rad, command)?;
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
match &setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
if !input.replace_environment && (options.hdr.is_some() || options.environment.is_some()) {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
if !input.replace_environment && (options.hdr.is_some() || options.environment.is_some() || options.sky.is_some()) {
|
||||
return Err(bad(command, "existing environment requires replace_environment: true"));
|
||||
}
|
||||
if metadata["solar_disk"] == true && options.sun.is_some() {
|
||||
return Err(bad(command, "the selected HDR already owns the sun; remove the analytic sun explicitly"));
|
||||
}
|
||||
CpuHdrEnvironment::check_controls(rotation, input.intensity, input.samples).map_err(|e| bad(command, e.to_string()))?;
|
||||
}
|
||||
Some(RenderJob::GpuPath { options, .. }) => {
|
||||
@@ -474,8 +484,9 @@ pub(super) fn apply_environment(s: &mut Session, params: &Value) -> Result<Value
|
||||
let digest = resource.id();
|
||||
settings.resources.insert(resource).map_err(|e| bad(command, e.to_string()))?;
|
||||
match &mut setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
options.environment = None;
|
||||
options.sky = None;
|
||||
options.hdr = Some(CpuHdrEnvironment { resource: digest, rotation_rad: rotation, intensity: input.intensity, samples: input.samples });
|
||||
}
|
||||
Some(RenderJob::GpuPath { options, .. }) => {
|
||||
@@ -548,7 +559,9 @@ pub(super) fn prepare_surface(s: &mut Session, params: &Value) -> Result<Value>
|
||||
"base_color_srgb":format!("builtin://{}/base_color",input.id),"normal_opengl":format!("builtin://{}/normal_open_gl",input.id),
|
||||
"roughness_linear":format!("builtin://{}/roughness",input.id),"metallic_linear":format!("builtin://{}/metallic",input.id)},
|
||||
"source_maps":manifest["maps"],"omitted":["height_displacement","true_anisotropic_brdf","manufacturing_geometry"],
|
||||
"dielectric_ior":"captured_body_value","persistence":"transient_request_binding; authored source bytes are built in"}))
|
||||
"dielectric_ior":"captured_body_value","persistence":"transient_request_binding; authored source bytes are built in",
|
||||
"compatible_saved_methods":if cfg!(feature="bundled-render-library") {vec!["cpu_raytrace","gpu_path"]} else {Vec::<&str>::new()},
|
||||
"cpu_maps":"saved_builtin_opaque_only; direct_or_indirect_lighting; level_zero_bilinear; incompatible_optics_refuse"}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -612,7 +625,13 @@ pub(super) fn rig_lighting(input: &RigInput, command: &str) -> Result<(StudioLig
|
||||
_ => return Err(bad(command, "authored light has no exact runtime adapter")),
|
||||
}
|
||||
}
|
||||
let lighting = StudioLighting { environment: [0.0; 3], sun, area_lights: AreaLights::new(&areas).map_err(|e| bad(command, e.to_string()))? };
|
||||
let lighting = StudioLighting {
|
||||
punctual_lights: Default::default(),
|
||||
directional_lights: Default::default(),
|
||||
environment: [0.0; 3],
|
||||
sun,
|
||||
area_lights: AreaLights::new(&areas).map_err(|e| bad(command, e.to_string()))?,
|
||||
};
|
||||
lighting.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
Ok((
|
||||
lighting,
|
||||
@@ -676,12 +695,106 @@ pub(super) fn rig_clearance(scene: &solidcraft_render::ProductScene, lighting: &
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn prepare_studio(_s: &mut Session, params: &Value) -> Result<Value> {
|
||||
/// Exact typed bridge for the currently authored achromatic area/Sun rigs.
|
||||
/// This changes lighting authority only, retaining dormant CPU sampling fields.
|
||||
pub(super) fn cpu_studio_lighting(options: &mut CpuJob, lighting: &StudioLighting, command: &str) -> Result<bool> {
|
||||
lighting.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if lighting.environment != [0.0; 3] || !lighting.directional_lights.is_empty() || !lighting.punctual_lights.is_empty() {
|
||||
return Err(bad(command, "authored CPU studio projection requires its explicit area/Sun rig and separate environment authority"));
|
||||
}
|
||||
let mut areas = Vec::new();
|
||||
areas.try_reserve_exact(lighting.area_lights.iter().count()).map_err(|_| bad(command, "could not reserve CPU studio lights"))?;
|
||||
for (index, light) in lighting.area_lights.iter().enumerate() {
|
||||
let [r, g, b] = light.radiance;
|
||||
if r.to_bits() != g.to_bits() || r.to_bits() != b.to_bits() {
|
||||
return Err(bad(
|
||||
command,
|
||||
format!("authored rectangle {index} needs a lossless CPU colour/intensity projection; this rig is not supported"),
|
||||
));
|
||||
}
|
||||
if !(0.0..=64.0).contains(&r) {
|
||||
return Err(bad(
|
||||
command,
|
||||
format!(
|
||||
"authored rectangle {index} radiance {r} exceeds CPU area intensity 0..64; this rig is unavailable without changing its authored light"
|
||||
),
|
||||
));
|
||||
}
|
||||
areas.push(RectangleLightOptions {
|
||||
center_mm: light.center,
|
||||
edge_u_mm: light.u.map(|value| value * 2.0),
|
||||
edge_v_mm: light.v.map(|value| value * 2.0),
|
||||
color: [1.0; 3],
|
||||
intensity: r,
|
||||
});
|
||||
}
|
||||
let needs_indirect = lighting.sun.is_some() && options.path.is_none();
|
||||
options.preset = None;
|
||||
options.lights = Some(Vec::new());
|
||||
options.punctual_lights = Some(Vec::new());
|
||||
options.area_lights = Some(areas);
|
||||
options.sun = lighting.sun;
|
||||
options.environment = None;
|
||||
options.sky = None;
|
||||
options.hdr = None;
|
||||
Ok(needs_indirect)
|
||||
}
|
||||
fn adopt_studio_indirect(setup: &mut RenderSetup) {
|
||||
if let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job {
|
||||
options.path.get_or_insert_default();
|
||||
options.progressive.get_or_insert_default();
|
||||
options.primary_samples = 1;
|
||||
options.shadows = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct StudioPrepare {
|
||||
id: String,
|
||||
variant: String,
|
||||
center_mm: [f64; 3],
|
||||
subject_extent_mm: f64,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default = "empty_patch")]
|
||||
set: Value,
|
||||
#[serde(default)]
|
||||
enable_indirect: bool,
|
||||
}
|
||||
pub(super) fn prepare_studio(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.library.studio.prepare";
|
||||
let input: RigInput = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let (lighting, projection) = rig_lighting(&input, command)?;
|
||||
let stage = rig_stage(&input, command)?;
|
||||
Ok(json!({"lighting":lighting,"stage":stage,"projection":projection,"requires_geometry_clearance_review":true,"document_changed":false}))
|
||||
let input: StudioPrepare = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
let rig = RigInput { id: input.id, variant: input.variant, center_mm: input.center_mm, subject_extent_mm: input.subject_extent_mm };
|
||||
let (lighting, projection) = rig_lighting(&rig, command)?;
|
||||
let stage = rig_stage(&rig, command)?;
|
||||
let mut report =
|
||||
json!({"lighting":lighting,"stage":stage,"projection":projection,"requires_geometry_clearance_review":true,"document_changed":false});
|
||||
if let Some(name) = input.name {
|
||||
let mut setup = super::patched_settings(s, &name, &input.set, command)?;
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job else {
|
||||
return Err(bad(command, "typed rig context requires a selected CPU raytrace setup"));
|
||||
};
|
||||
let needs_indirect = cpu_studio_lighting(options, &lighting, command)?;
|
||||
// Resolve the complete prospective adoption without publishing it. Apply
|
||||
// still requires the separate explicit action below, at current revision.
|
||||
if needs_indirect {
|
||||
adopt_studio_indirect(&mut setup);
|
||||
}
|
||||
setup.stage = stage;
|
||||
setup.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
report["setup"] = json!(setup);
|
||||
report["executor"] = json!("cpu-raytrace");
|
||||
report["revision"] = json!(s.revision);
|
||||
report["requires_indirect"] = json!(needs_indirect);
|
||||
report["indirect_adoption"] = json!(
|
||||
needs_indirect
|
||||
.then_some(json!({"path_depth":8,"progressive_if_absent":{"samples_per_pixel":16,"seed":0},"primary_samples":1,"shadows":true}))
|
||||
);
|
||||
} else if input.enable_indirect || input.set != json!({}) {
|
||||
return Err(bad(command, "CPU rig context needs name and typed set"));
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -694,6 +807,10 @@ struct StudioApply {
|
||||
replace_lighting_and_environment: bool,
|
||||
#[serde(default)]
|
||||
replace_stage: bool,
|
||||
#[serde(default)]
|
||||
enable_indirect: bool,
|
||||
#[serde(default)]
|
||||
expected_revision: Option<u64>,
|
||||
}
|
||||
pub(super) fn apply_studio(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.library.studio.apply";
|
||||
@@ -701,22 +818,35 @@ pub(super) fn apply_studio(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
if !input.replace_lighting_and_environment {
|
||||
return Err(bad(command, "confirm replacing lighting and environment explicitly to preserve a single energy owner"));
|
||||
}
|
||||
if input.expected_revision.is_some_and(|revision| revision != s.revision) {
|
||||
return Err(bad(command, "scene or saved settings changed after rig review"));
|
||||
}
|
||||
if !input.replace_stage && s.doc.render.setups.get(&input.name).is_some_and(|setup| !setup.stage.is_empty()) {
|
||||
return Err(bad(command, "existing stage requires replace_stage: true"));
|
||||
}
|
||||
let (lighting, projection) = rig_lighting(&input.rig, command)?;
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
let Some(RenderJob::GpuPath { options, .. }) = &mut setup.job else {
|
||||
return Err(bad(command, "authored area/sun rigs require an explicit GPU job"));
|
||||
};
|
||||
let stage = rig_stage(&input.rig, command)?;
|
||||
if !setup.stage.is_empty() && !input.replace_stage {
|
||||
return Err(bad(command, "existing stage requires replace_stage: true"));
|
||||
}
|
||||
setup.stage = stage;
|
||||
options.lighting = Some(lighting);
|
||||
options.environment = None;
|
||||
options.hdr = None;
|
||||
let needs_indirect = match &mut setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => cpu_studio_lighting(options, &lighting, command)?,
|
||||
Some(RenderJob::GpuPath { options, .. }) => {
|
||||
options.lighting = Some(lighting);
|
||||
options.environment = None;
|
||||
options.hdr = None;
|
||||
false
|
||||
}
|
||||
_ => return Err(bad(command, "authored rigs require an explicit CPU or GPU raytrace setup")),
|
||||
};
|
||||
if needs_indirect {
|
||||
if !input.enable_indirect {
|
||||
return Err(bad(command, "this CPU Sun rig requires explicit Enable indirect lighting and apply consent"));
|
||||
}
|
||||
adopt_studio_indirect(&mut setup);
|
||||
}
|
||||
setup.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
let mut scene = crate::product_scene::extract(s)?;
|
||||
super::staging::apply(&mut scene, &setup, command)?;
|
||||
|
||||
@@ -74,7 +74,7 @@ fn builtin_hdr_capture_is_atomic_undoable_deduplicated_and_path_independent() {
|
||||
assert_eq!(reply["source_sha256"], reply["resource"]);
|
||||
assert_eq!(reply["native_rotation_rad"], std::f64::consts::PI);
|
||||
assert_eq!(reply["self_contained"], true);
|
||||
let Some(RenderJob::CpuRaytrace { options }) = &s.doc.render.setups["Library CPU"].job else {
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &s.doc.render.setups["Library CPU"].job else {
|
||||
panic!("CPU intent");
|
||||
};
|
||||
let id = options.hdr.unwrap().resource;
|
||||
@@ -290,8 +290,12 @@ fn saved_surface_rejects_changed_feature_and_hidden_target_and_can_be_removed_of
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, previous);
|
||||
let before = s.doc.to_json();
|
||||
assert!(s.execute("render.settings.set", &json!({"name":"P","set":{"job":{"method":"cpu_raytrace","options":{}}}})).is_err());
|
||||
assert!(s.execute("render.settings.set", &json!({"name":"P","set":{"job":{"method":"raster"}}})).is_err());
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
let bindings = s.doc.render.setups["P"].surfaces.clone();
|
||||
s.execute("render.settings.set", &json!({"name":"P","set":{"job":{"method":"cpu_raytrace","options":{}}}})).unwrap();
|
||||
assert_eq!(s.doc.render.setups["P"].surfaces, bindings, "supported CPU method changes preserve saved bindings, even offline");
|
||||
assert!(s.render_job_host().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -399,3 +403,265 @@ fn omitted_optional_pack_keeps_discovery_but_refuses_resource_mutation() {
|
||||
assert_eq!(session.doc, before);
|
||||
assert_eq!((session.revision, session.undo.len()), history);
|
||||
}
|
||||
|
||||
#[cfg(feature = "bundled-render-library")]
|
||||
#[test]
|
||||
fn cpu_saved_surface_binding_is_offline_undoable_portable_and_preserves_geometry_and_unsupported_intent() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":20,"height":5,"body_name":"Coupon"})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let camera = solidcraft_render::Camera::default();
|
||||
let set = json!({"camera":camera,"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"progressive":{"samples_per_pixel":3,"seed":9},"output":{"linear_exr":true}}}});
|
||||
let input = json!({"name":"CPU maps","set":set,"surface":{"id":"SURF-001","body_index":0,"body_name":"Coupon"}});
|
||||
assert!(s.render_job_host().is_none());
|
||||
s.execute("render.library.surface.bind", &input).unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.doc.features, before.features);
|
||||
assert_eq!(s.doc.materials, before.materials);
|
||||
assert_eq!(s.doc.appearances, before.appearances);
|
||||
let setup = s.doc.render.setups["CPU maps"].clone();
|
||||
assert_eq!(setup.surfaces.len(), 1);
|
||||
assert_eq!(setup.camera, camera);
|
||||
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened, *s.doc);
|
||||
let snapshot = s.doc.clone();
|
||||
let history = (s.revision, s.undo.len(), s.redo.len());
|
||||
for options in [
|
||||
json!({"reflections":{"bodies":[{"body":"Coupon","mode":"mirror"}],"samples":1,"max_bounces":2}}),
|
||||
json!({"budget":"studio","progressive":{"samples_per_pixel":3,"seed":9},"path":{"max_depth":2},"shadows":false}),
|
||||
] {
|
||||
let mut hostile = input.clone();
|
||||
hostile["name"] = json!("Unsupported");
|
||||
hostile["set"]["job"]["options"] = options;
|
||||
assert!(s.execute("render.library.surface.bind", &hostile).is_err());
|
||||
assert!(Arc::ptr_eq(&s.doc, &snapshot));
|
||||
assert_eq!((s.revision, s.undo.len(), s.redo.len()), history);
|
||||
}
|
||||
let mut stale = setup.clone();
|
||||
let mut binding = stale.surfaces.iter().next().unwrap().clone();
|
||||
binding.source.normal_sha256 = HdrId::of(b"changed");
|
||||
stale.surfaces.set(binding, true).unwrap();
|
||||
assert!(super::super::surface_bindings::resolve(&s, &crate::product_scene::extract(&s).unwrap(), &stale.surfaces, "test").is_err());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.render.setups["CPU maps"], setup);
|
||||
s.execute("render.library.surface.unbind", &json!({"name":"CPU maps","target":setup.surfaces.iter().next().unwrap().target})).unwrap();
|
||||
assert!(s.doc.render.setups["CPU maps"].surfaces.is_empty());
|
||||
assert!(s.render_job_host().is_none());
|
||||
}
|
||||
|
||||
#[cfg(feature = "bundled-render-library")]
|
||||
#[test]
|
||||
fn cpu_path_maps_bind_beside_saved_glass_and_targeted_aging_without_weakening_target_or_legacy_guards() {
|
||||
let mut s = Session::default();
|
||||
for (name, corner) in [("Glass", [0, 0, 0]), ("Panel", [20, 0, 0])] {
|
||||
s.execute("solid.box", &json!({"body_name":name,"length":10,"width":10,"height":5,"corner":corner,"operation":"new"})).unwrap();
|
||||
}
|
||||
let targets = s.execute("render.library.targets", &json!({})).unwrap();
|
||||
let glass = targets["items"].as_array().unwrap().iter().find(|target| target["name"] == "Glass").unwrap().clone();
|
||||
let panel = targets["items"].as_array().unwrap().iter().find(|target| target["name"] == "Panel").unwrap().clone();
|
||||
assert_eq!(panel["stable_target"]["definition"], "Panel");
|
||||
assert_ne!(glass["stable_target"], panel["stable_target"]);
|
||||
s.execute(
|
||||
"render.settings.set",
|
||||
&json!({"name":"Mixed","set":{"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{
|
||||
"progressive":{"samples_per_pixel":4,"seed":9},"path":{"max_depth":4},
|
||||
"aging":[{"kind":"dust","target":panel["stable_target"]}]
|
||||
}}}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"render.optics.bind",
|
||||
&json!({"name":"Mixed","body_index":glass["ordinal"],"body_name":"Glass","expected_revision":s.revision,
|
||||
"optics":{"ior":1.5,"boundary":"closed_mesh"}}),
|
||||
)
|
||||
.unwrap();
|
||||
let before = s.doc.clone();
|
||||
let history = (s.revision, s.undo.len(), s.redo.len());
|
||||
assert!(s.render_job_host().is_none(), "authoring does not require an image producer");
|
||||
let input = |target: &Value| json!({"name":"Mixed","surface":{"id":"SURF-001","body_index":target["ordinal"],"body_name":target["name"]}});
|
||||
let error = s.execute("render.library.surface.bind", &input(&glass)).unwrap_err().to_string();
|
||||
assert!(error.contains("cannot share a target"), "{error}");
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!((s.revision, s.undo.len(), s.redo.len()), history);
|
||||
let mut legacy = input(&panel);
|
||||
legacy["set"] = json!({"job":{"method":"cpu_raytrace","options":{"progressive":{"samples_per_pixel":4,"seed":9}}}});
|
||||
assert!(s.execute("render.library.surface.bind", &legacy).unwrap_err().to_string().contains("require Indirect lighting"));
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!((s.revision, s.undo.len(), s.redo.len()), history);
|
||||
s.execute("render.library.surface.bind", &input(&panel)).unwrap();
|
||||
assert_eq!(s.undo.len(), history.1 + 1);
|
||||
let saved = s.doc.clone();
|
||||
let setup = &saved.render.setups["Mixed"];
|
||||
assert_eq!(setup.surfaces.len(), 1);
|
||||
assert_eq!(setup.optics, before.render.setups["Mixed"].optics);
|
||||
let solidcraft_doc::render_settings::RenderJob::CpuRaytrace { options, .. } = setup.job.as_ref().unwrap() else { panic!("CPU intent changed") };
|
||||
assert!(options.path.is_some());
|
||||
assert_eq!(options.aging.iter().next().unwrap().target.as_ref().unwrap(), &setup.surfaces.iter().next().unwrap().target);
|
||||
assert_eq!(saved.features, before.features);
|
||||
assert_eq!(saved.materials, before.materials);
|
||||
assert_eq!(saved.appearances, before.appearances);
|
||||
assert_eq!(solidcraft_doc::Document::from_json(&saved.to_json()).unwrap(), *saved);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, saved);
|
||||
assert!(s.render_job_host().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_authored_rig_domain_is_exact_and_preserves_dormant_sampling_without_implicit_lights() {
|
||||
use solidcraft_doc::render_settings::CpuJob;
|
||||
let mut s = Session::default();
|
||||
let mut accepted = Vec::new();
|
||||
let mut over_domain = Vec::new();
|
||||
for index in 1..=24 {
|
||||
let id = format!("RIG-{index:03}");
|
||||
let rig = json!({"id":id,"variant":"complete","center_mm":[0,0,0],"subject_extent_mm":100});
|
||||
if s.execute("render.library.studio.prepare", &rig).is_err() {
|
||||
continue;
|
||||
}
|
||||
let mut prepare = rig.clone();
|
||||
prepare["name"] = json!("Rig");
|
||||
prepare["set"] = json!({"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"area_samples":64,"seed":731}}});
|
||||
let before = s.doc.clone();
|
||||
let history = s.undo.len();
|
||||
let result = s.execute("render.library.studio.prepare", &prepare);
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!(history, s.undo.len());
|
||||
match result {
|
||||
Ok(plan) => {
|
||||
accepted.push(id.clone());
|
||||
let options: CpuJob = serde_json::from_value(plan["setup"]["job"]["options"].clone()).unwrap();
|
||||
assert_eq!(options.lights, Some(vec![]));
|
||||
assert_eq!(options.punctual_lights, Some(vec![]));
|
||||
assert_eq!((options.area_samples, options.seed), (Some(64), Some(731)));
|
||||
assert!(options.environment.is_none() && options.sky.is_none() && options.hdr.is_none());
|
||||
assert_eq!(plan["requires_indirect"], id == "RIG-020");
|
||||
}
|
||||
Err(error) => {
|
||||
assert!(error.to_string().contains("exceeds CPU area intensity 0..64"), "{id}: {error}");
|
||||
over_domain.push(id);
|
||||
let err = s
|
||||
.execute(
|
||||
"render.library.studio.apply",
|
||||
&json!({"rig":rig,"name":"Rig","set":prepare["set"],"replace_lighting_and_environment":true}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("exceeds CPU area intensity 0..64"));
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!(history, s.undo.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(accepted, vec!["RIG-007", "RIG-020", "RIG-022"]);
|
||||
assert_eq!(over_domain.len(), 16);
|
||||
let mut options = CpuJob { area_samples: Some(64), seed: Some(731), ..Default::default() };
|
||||
let before = options.clone();
|
||||
let light = AreaLight { center: [0., 0., 10.], u: [1., 0., 0.], v: [0., -1., 0.], radiance: [1., 0.5, 0.25] };
|
||||
let lighting = StudioLighting { area_lights: AreaLights::new(&[light]).unwrap(), ..Default::default() };
|
||||
assert!(cpu_studio_lighting(&mut options, &lighting, "test").unwrap_err().to_string().contains("lossless CPU colour/intensity projection"));
|
||||
assert_eq!(options, before, "no lossy colour decomposition");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_sun_rig_adoption_is_explicit_complete_atomic_and_one_undo() {
|
||||
let mut s = Session::default();
|
||||
s.execute("render.settings.set",&json!({"name":"Rig","set":{"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"primary_samples":4,"shadows":false,"area_samples":64,"seed":731}}}})).unwrap();
|
||||
let rig = json!({"id":"RIG-020","variant":"complete","center_mm":[0,0,0],"subject_extent_mm":100});
|
||||
let mut prepare = rig.clone();
|
||||
prepare["name"] = json!("Rig");
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let plan = s.execute("render.library.studio.prepare", &prepare).unwrap();
|
||||
assert_eq!(plan["requires_indirect"], true);
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
let mut apply = json!({"name":"Rig","rig":rig,"replace_lighting_and_environment":true,"expected_revision":s.revision});
|
||||
assert!(s.execute("render.library.studio.apply", &apply).unwrap_err().to_string().contains("Enable indirect lighting"));
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
apply["enable_indirect"] = json!(true);
|
||||
s.execute("render.library.studio.apply", &apply).unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &s.doc.render.setups["Rig"].job else { panic!() };
|
||||
assert_eq!(options.path.unwrap().max_depth, 8);
|
||||
assert_eq!(options.progressive.unwrap().samples_per_pixel, 16);
|
||||
assert_eq!((options.primary_samples, options.shadows, options.area_samples, options.seed), (1, true, Some(64), Some(731)));
|
||||
assert!(options.sun.is_some());
|
||||
assert_eq!(options.area_lights, Some(vec![]));
|
||||
let saved = s.doc.to_json();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
let before = s.doc.clone();
|
||||
assert!(s.execute("render.library.studio.apply", &apply).is_err());
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
let mut large = Session::default();
|
||||
let fixed = json!({"width":2048,"height":2048,"job":{"method":"cpu_raytrace","options":{}}});
|
||||
// This is an admitted fixed Compact image. Only explicit adoption of the
|
||||
// complete progressive16 Sun request crosses Compact's1,048,576-pixel bound.
|
||||
// Upstream's shared setup refusal names the admitting Studio profile rather
|
||||
// than repeating the current Compact name, so assert the actual size proof.
|
||||
use solidcraft_render::job::{CpuBudget, CpuProgressiveOptions};
|
||||
let adopted = Some(CpuProgressiveOptions { samples_per_pixel: 16, seed: 0 });
|
||||
assert!(CpuBudget::Compact.validate_image(2048, 2048, None).is_ok());
|
||||
assert_eq!(CpuBudget::Compact.limits().max_progressive_pixels, 1_048_576);
|
||||
assert!(CpuBudget::Compact.validate_image(2048, 2048, adopted).is_err());
|
||||
assert!(CpuBudget::Studio.validate_image(2048, 2048, adopted).is_ok());
|
||||
let authored = super::super::patched_settings(&large, "Rig", &fixed, "render.library.studio.prepare").unwrap();
|
||||
assert_eq!((authored.width, authored.height), (2048, 2048));
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &authored.job else { panic!("typed fixed CPU setup") };
|
||||
assert_eq!(options.budget, CpuBudget::Compact);
|
||||
assert!(options.progressive.is_none() && options.path.is_none());
|
||||
let mut oversized = prepare;
|
||||
oversized["set"] = fixed.clone();
|
||||
let before = large.doc.clone();
|
||||
let state = (large.revision, large.evaluations, large.is_dirty(), large.undo.len(), large.redo.len(), large.camera);
|
||||
let check_size_error = |error: String| {
|
||||
assert!(error.contains("render output 2048 × 2048 exceeds the image limits"), "{error}");
|
||||
assert!(error.contains("1048576 pixels in all"), "{error}");
|
||||
assert!(error.contains("largest image at this aspect is 1024 × 1024"), "{error}");
|
||||
assert!(error.contains("choose the Studio CPU resource profile, or a smaller size"), "{error}");
|
||||
eprintln!("complete CPU Sun adoption refused without mutation: {error}");
|
||||
};
|
||||
check_size_error(large.execute("render.library.studio.prepare", &oversized).unwrap_err().to_string());
|
||||
assert!(Arc::ptr_eq(&before, &large.doc));
|
||||
assert_eq!((large.revision, large.evaluations, large.is_dirty(), large.undo.len(), large.redo.len(), large.camera), state);
|
||||
let mut oversized = apply;
|
||||
oversized.as_object_mut().unwrap().remove("expected_revision");
|
||||
oversized["set"] = fixed;
|
||||
check_size_error(large.execute("render.library.studio.apply", &oversized).unwrap_err().to_string());
|
||||
assert!(Arc::ptr_eq(&before, &large.doc));
|
||||
assert_eq!((large.revision, large.evaluations, large.is_dirty(), large.undo.len(), large.redo.len(), large.camera), state);
|
||||
assert!(large.undo.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_sun_rig_keeps_existing_path_adaptive_seed_and_profile_and_rejects_unknown_context() {
|
||||
let mut s = Session::default();
|
||||
let options = json!({"budget":"studio","progressive":{"samples_per_pixel":64,"seed":947},"adaptive":{"pilot_samples":32,"min_samples":32},"path":{"max_depth":5},"area_samples":4,"seed":731});
|
||||
s.execute("render.settings.set", &json!({"name":"Rig","set":{"width":96,"height":64,"job":{"method":"cpu_raytrace","options":options}}}))
|
||||
.unwrap();
|
||||
let rig = json!({"id":"RIG-020","variant":"complete","center_mm":[0,0,0],"subject_extent_mm":100});
|
||||
let mut prepare = rig.clone();
|
||||
prepare["name"] = json!("Rig");
|
||||
let plan = s.execute("render.library.studio.prepare", &prepare).unwrap();
|
||||
assert_eq!(plan["requires_indirect"], false);
|
||||
let old = s.doc.render.setups["Rig"].clone();
|
||||
s.execute("render.library.studio.apply", &json!({"name":"Rig","rig":rig,"replace_lighting_and_environment":true})).unwrap();
|
||||
let Some(RenderJob::CpuRaytrace { options: before, .. }) = old.job else { panic!() };
|
||||
let Some(RenderJob::CpuRaytrace { options: after, .. }) = &s.doc.render.setups["Rig"].job else { panic!() };
|
||||
assert_eq!(
|
||||
(after.budget, after.progressive, after.adaptive, after.path, after.area_samples, after.seed),
|
||||
(before.budget, before.progressive, before.adaptive, before.path, before.area_samples, before.seed)
|
||||
);
|
||||
let doc = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
prepare["extra"] = json!(true);
|
||||
assert!(s.execute("render.library.studio.prepare", &prepare).is_err());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,15 @@ fn normalized_twenty_source_preflight_has_one_metadata_authority_and_no_reservat
|
||||
assert_eq!(options.primary_samples, 16);
|
||||
assert_eq!(options.area_lights.as_ref().unwrap().len(), 4);
|
||||
assert_eq!(options.punctual_lights.as_ref().unwrap().len(), 8);
|
||||
assert_eq!(reply["retained_request_bytes"], json!(options.retained_bytes().unwrap()));
|
||||
assert_eq!(
|
||||
reply["retained_request_bytes"],
|
||||
json!(
|
||||
options.retained_bytes().unwrap()
|
||||
+ serde_json::from_value::<solidcraft_render::job::ImageRequest>(reply["request"].clone())
|
||||
.unwrap()
|
||||
.retained_resource_metadata_bytes()
|
||||
)
|
||||
);
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
assert_eq!(input, unchanged);
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc));
|
||||
@@ -76,3 +84,63 @@ fn invalid_or_unavailable_union_preserves_stale_scene_before_capture_or_replacem
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_finite_sun_common_edit_keeps_raw_radiance_identity_and_undo() {
|
||||
let mut s = Session::default();
|
||||
let sun = json!({"direction":[0.2,-0.3,0.9],"angular_radius_rad":0.02,"radiance":[301.25,202.5,103.75]});
|
||||
s.execute("render.settings.set",&json!({"name":"CPU Sun","set":{"width":16,"height":12,"job":{"method":"cpu_raytrace","options":{
|
||||
"path":{"max_depth":4},"progressive":{"samples_per_pixel":3,"seed":81},"sun":sun,"punctual_lights":[],"area_lights":[],"lights":[{"direction":[0,0,1],"color":[1,0.5,0.25],"intensity":2}]}}}})).unwrap();
|
||||
let before = s.doc.render.setups["CPU Sun"].clone();
|
||||
let rows = crate::render_lights::inspect(&before).unwrap();
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].id, "directional:0");
|
||||
assert_eq!(rows[1].id, "directional:1");
|
||||
let mut exact = before.clone();
|
||||
crate::render_lights::write(&mut exact, &rows).unwrap();
|
||||
assert_eq!(exact, before);
|
||||
let undo = s.undo.len();
|
||||
s.execute("render.lights.set", &json!({"name":"CPU Sun","id":"directional:1","set":{"intensity_ev":2.0}})).unwrap();
|
||||
let changed = s.doc.render.setups["CPU Sun"].clone();
|
||||
assert_eq!(serde_json::to_value(&changed).unwrap()["job"]["options"]["sun"], sun);
|
||||
let edited = crate::render_lights::inspect(&changed).unwrap();
|
||||
assert_eq!((edited[1].intensity, edited[1].colour), (4.0, [1.0, 0.5, 0.25]));
|
||||
assert_eq!(edited[1].geometry, rows[1].geometry);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.render.setups["CPU Sun"], before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.render.setups["CPU Sun"], changed);
|
||||
let document = s.doc.clone();
|
||||
let history = (s.undo.len(), s.redo.len(), s.revision);
|
||||
let mut without_path = serde_json::to_value(&changed).unwrap();
|
||||
without_path["job"]["options"]["path"] = Value::Null;
|
||||
assert!(s.execute("render.settings.set", &json!({"name":"CPU Sun","set":without_path})).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&document, &s.doc));
|
||||
assert_eq!(history, (s.undo.len(), s.redo.len(), s.revision));
|
||||
// Radius zero retains the pre-existing hard-source representation without Path.
|
||||
s.execute("render.settings.set",&json!({"name":"CPU hard","set":{"job":{"method":"cpu_raytrace","options":{"lights":[{"direction":[0,0,1],"color":[1,1,1],"intensity":1}]}}}})).unwrap();
|
||||
let hard = crate::render_lights::inspect(&s.doc.render.setups["CPU hard"]).unwrap();
|
||||
assert!(matches!(hard[0].geometry, crate::schema::render_lights::Geometry::Directional { angular_radius_rad: None, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_sun_add_remove_returns_actual_canonical_addresses_and_preserves_other_sources() {
|
||||
let mut s = Session::default();
|
||||
s.execute("render.settings.set",&json!({"name":"CPU rig","set":{"width":16,"height":12,"job":{"method":"cpu_raytrace","options":{
|
||||
"path":{"max_depth":4},"progressive":{"samples_per_pixel":3,"seed":81},"punctual_lights":[],"area_lights":[],"lights":[{"direction":[0,0,1],"color":[1,0.5,0.25],"intensity":2}]}}}})).unwrap();
|
||||
let before = s.doc.render.setups["CPU rig"].clone();
|
||||
let reply = s.execute("render.lights.add", &json!({"name":"CPU rig","set":{"kind":"directional","softness":0.1}})).unwrap();
|
||||
assert_eq!(reply["id"], "directional:0");
|
||||
let rows = crate::render_lights::inspect(&s.doc.render.setups["CPU rig"]).unwrap();
|
||||
assert_eq!(rows[1].id, "directional:1");
|
||||
assert_eq!(reply["id_remap"][0]["after_id"], "directional:1");
|
||||
let mut filtered = s.doc.render.setups["CPU rig"].clone();
|
||||
let visibility: crate::schema::render_lights::Visibility = serde_json::from_value(json!({"muted":["directional:0"]})).unwrap();
|
||||
crate::render_lights::filter(&mut filtered, &visibility).unwrap();
|
||||
let Some(solidcraft_doc::render_settings::RenderJob::CpuRaytrace { options, .. }) = filtered.job else { panic!("CPU expected") };
|
||||
assert_eq!(options.sun.unwrap().radiance, [0.0; 3]);
|
||||
assert_eq!(options.lights.as_ref().unwrap()[0].intensity, 2.0);
|
||||
s.execute("render.lights.remove", &json!({"name":"CPU rig","id":"directional:0"})).unwrap();
|
||||
assert_eq!(s.doc.render.setups["CPU rig"], before);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde_json::Value;
|
||||
use solidcraft_doc::render_settings::{RenderSettings, RenderSetup};
|
||||
|
||||
use crate::params::bad;
|
||||
use crate::schema::render_lights::{EditParams, EditResult, Geometry, Light};
|
||||
use crate::schema::render_lights::{EditParams, EditResult, Geometry, Light, LightIdRemap};
|
||||
use crate::schema::render_lights::{ListParams, ListResult};
|
||||
use crate::{Result, Session};
|
||||
|
||||
@@ -35,6 +35,33 @@ pub(super) fn list(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
serde_json::to_value(ListResult { lights: crate::render_lights::inspect(&setup).map_err(|e| bad(cmd, e))? }).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
fn canonical_id(setup: &RenderSetup, lights: &[Light], index: usize) -> Option<String> {
|
||||
lights.get(index).map(|light| {
|
||||
let kind = light.geometry.kind();
|
||||
let finite_directional = matches!(
|
||||
setup.job,
|
||||
Some(solidcraft_doc::render_settings::RenderJob::GpuPath { .. } | solidcraft_doc::render_settings::RenderJob::CpuRaytrace { .. })
|
||||
) && kind == "directional";
|
||||
let slot = if finite_directional {
|
||||
if matches!(light.geometry, Geometry::Directional { angular_radius_rad: Some(_), .. }) {
|
||||
0
|
||||
} else {
|
||||
usize::from(lights.iter().any(|l| matches!(l.geometry, Geometry::Directional { angular_radius_rad: Some(_), .. })))
|
||||
+ lights.iter().take(index).filter(|l| matches!(l.geometry, Geometry::Directional { angular_radius_rad: None, .. })).count()
|
||||
}
|
||||
} else {
|
||||
lights
|
||||
.iter()
|
||||
.take(index)
|
||||
.filter(|prior| {
|
||||
if matches!(kind, "point" | "spot") { matches!(prior.geometry.kind(), "point" | "spot") } else { prior.geometry.kind() == kind }
|
||||
})
|
||||
.count()
|
||||
};
|
||||
format!("{kind}:{slot}")
|
||||
})
|
||||
}
|
||||
|
||||
fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
|
||||
let command = format!("render.lights.{operation}");
|
||||
if !params.is_object() {
|
||||
@@ -51,6 +78,7 @@ fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
|
||||
None => s.doc.render.setups.get(&input.name).cloned().unwrap_or_default(),
|
||||
};
|
||||
let mut lights = crate::render_lights::inspect(&setup).map_err(|e| bad(&command, e))?;
|
||||
let original_lights = lights.clone();
|
||||
let target = [setup.camera.target.x, setup.camera.target.y, setup.camera.target.z];
|
||||
let mut selected = None;
|
||||
match operation {
|
||||
@@ -90,13 +118,15 @@ fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
|
||||
if input.set.is_some() {
|
||||
return Err(bad(&command, "duplicate does not accept set"));
|
||||
}
|
||||
let light = lights.get(index).cloned().ok_or_else(|| bad(&command, "missing light"))?;
|
||||
let mut light = lights.get(index).cloned().ok_or_else(|| bad(&command, "missing light"))?;
|
||||
// A new source, even when every numerical value equals its parent.
|
||||
light.id.clear();
|
||||
lights.push(light);
|
||||
selected = lights.len().checked_sub(1);
|
||||
} else {
|
||||
let patch = input.set.ok_or_else(|| bad(&command, "set is required"))?;
|
||||
let light = lights.get_mut(index).ok_or_else(|| bad(&command, "missing light"))?;
|
||||
if matches!(setup.job, Some(solidcraft_doc::render_settings::RenderJob::CpuRaytrace { .. }))
|
||||
if matches!(&setup.job, Some(solidcraft_doc::render_settings::RenderJob::CpuRaytrace { options, .. }) if options.path.is_none())
|
||||
&& light.geometry.kind() == "directional"
|
||||
&& patch.softness.is_some()
|
||||
{
|
||||
@@ -108,19 +138,17 @@ fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
|
||||
}
|
||||
_ => return Err(bad(&command, "unknown light operation")),
|
||||
}
|
||||
let id = selected.and_then(|index| {
|
||||
lights.get(index).map(|light| {
|
||||
let kind = light.geometry.kind();
|
||||
let slot = lights
|
||||
let id = selected.and_then(|index| canonical_id(&setup, &lights, index));
|
||||
let id_remap = lights
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, light)| {
|
||||
original_lights
|
||||
.iter()
|
||||
.take(index)
|
||||
.filter(|prior| {
|
||||
if matches!(kind, "point" | "spot") { matches!(prior.geometry.kind(), "point" | "spot") } else { prior.geometry.kind() == kind }
|
||||
})
|
||||
.count();
|
||||
format!("{kind}:{slot}")
|
||||
.find(|before| !light.id.is_empty() && before.id == light.id)
|
||||
.and_then(|before| canonical_id(&setup, &lights, index).map(|after_id| LightIdRemap { before: before.clone(), after_id }))
|
||||
})
|
||||
});
|
||||
.collect();
|
||||
crate::render_lights::write(&mut setup, &lights).map_err(|e| bad(&command, e))?;
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
settings.setups.insert(input.name.clone(), setup.clone());
|
||||
@@ -128,7 +156,7 @@ fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
|
||||
if *s.doc.render != settings {
|
||||
s.doc_mut().render = std::sync::Arc::new(settings);
|
||||
}
|
||||
serde_json::to_value(EditResult { id, name: input.name, setup: serde_json::to_value(setup).map_err(|e| bad(&command, e.to_string()))? })
|
||||
serde_json::to_value(EditResult { id, id_remap, name: input.name, setup: serde_json::to_value(setup).map_err(|e| bad(&command, e.to_string()))? })
|
||||
.map_err(|e| bad(&command, e.to_string()))
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,13 @@ use solidcraft_doc::{
|
||||
use solidcraft_render::ProductScene;
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
pub(super) fn resolve(s: &Session, scene: &ProductScene, bindings: &OpticalBindings, command: &str) -> Result<Vec<super::transmission::Override>> {
|
||||
fn resolve_each<T>(
|
||||
s: &Session,
|
||||
scene: &ProductScene,
|
||||
bindings: &OpticalBindings,
|
||||
command: &str,
|
||||
convert: impl Fn(usize, &str, OpticalMaterial) -> Result<T>,
|
||||
) -> Result<Vec<T>> {
|
||||
bindings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
let identities = super::surface_bindings::targets(s, command)?;
|
||||
let visible: BTreeMap<_, _> = scene.meshes.iter().enumerate().map(|(i, b)| (b.name.as_str(), i)).collect();
|
||||
@@ -20,13 +26,42 @@ pub(super) fn resolve(s: &Session, scene: &ProductScene, bindings: &OpticalBindi
|
||||
for binding in bindings.iter() {
|
||||
let name = identities.get(&binding.target).ok_or_else(|| bad(command, "saved optical target changed; explicit rebinding required"))?;
|
||||
let index = visible.get(name.as_str()).ok_or_else(|| bad(command, "saved optical target is hidden or absent"))?;
|
||||
let mut value = serde_json::to_value(binding.optics).map_err(|e| bad(command, e.to_string()))?;
|
||||
value["body_index"] = json!(index);
|
||||
value["body_name"] = json!(name);
|
||||
result.push(serde_json::from_value(value).map_err(|e| bad(command, e.to_string()))?);
|
||||
result.push(convert(*index, name, binding.optics)?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(super) fn resolve(s: &Session, scene: &ProductScene, bindings: &OpticalBindings, command: &str) -> Result<Vec<super::transmission::Override>> {
|
||||
resolve_each(s, scene, bindings, command, |index, name, optics| {
|
||||
let mut value = serde_json::to_value(optics).map_err(|e| bad(command, e.to_string()))?;
|
||||
value["body_index"] = json!(index);
|
||||
value["body_name"] = json!(name);
|
||||
serde_json::from_value(value).map_err(|e| bad(command, e.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn resolve_cpu(
|
||||
s: &Session,
|
||||
scene: &ProductScene,
|
||||
bindings: &OpticalBindings,
|
||||
command: &str,
|
||||
) -> Result<Vec<solidcraft_render::job::DielectricOptions>> {
|
||||
resolve_cpu_path(s, scene, bindings, false, command)
|
||||
}
|
||||
pub(super) fn resolve_cpu_path(
|
||||
s: &Session,
|
||||
scene: &ProductScene,
|
||||
bindings: &OpticalBindings,
|
||||
path: bool,
|
||||
command: &str,
|
||||
) -> Result<Vec<solidcraft_render::job::DielectricOptions>> {
|
||||
bindings.validate_for_cpu_path(path).map_err(|e| bad(command, e.to_string()))?;
|
||||
resolve_each(s, scene, bindings, command, |index, name, optics| {
|
||||
optics.to_cpu_for_path(index, name, path).map_err(|e| bad(command, e.to_string()))
|
||||
})
|
||||
}
|
||||
|
||||
fn empty() -> Value {
|
||||
json!({})
|
||||
}
|
||||
@@ -54,8 +89,8 @@ pub(super) fn bind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
if s.doc.render.setups.get(&input.name).is_some_and(|old| old.optics != setup.optics) {
|
||||
return Err(bad(command, "setup patch cannot erase or replace saved optics; use explicit bind/unbind"));
|
||||
}
|
||||
if !matches!(setup.job, Some(RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "portable optics require an explicit GPU setup"));
|
||||
if !matches!(setup.job, Some(RenderJob::CpuRaytrace { .. } | RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "portable optics require an explicit CPU or GPU raytrace setup"));
|
||||
}
|
||||
let mut scene = crate::product_scene::extract(s)?;
|
||||
let name = &scene
|
||||
@@ -72,8 +107,15 @@ pub(super) fn bind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
}
|
||||
setup.optics.set(SavedOptics { target, optics: input.optics }, input.replace).map_err(|e| bad(command, e.to_string()))?;
|
||||
setup.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
let resolved = resolve(s, &scene, &setup.optics, command)?;
|
||||
super::transmission::apply(&mut scene, &resolved, command)?;
|
||||
if matches!(setup.job, Some(RenderJob::CpuRaytrace { .. })) {
|
||||
// Offline saving resolves identity and numerical compatibility. The selected
|
||||
// CPU image producer still certifies actual geometry before any transport.
|
||||
let path = matches!(&setup.job,Some(RenderJob::CpuRaytrace{options,..}) if options.path.is_some());
|
||||
resolve_cpu_path(s, &scene, &setup.optics, path, command)?;
|
||||
} else {
|
||||
let resolved = resolve(s, &scene, &setup.optics, command)?;
|
||||
super::transmission::apply(&mut scene, &resolved, command)?;
|
||||
}
|
||||
let mut settings = (*s.doc.render).clone();
|
||||
settings.setups.insert(input.name.clone(), setup.clone());
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
@@ -146,4 +188,49 @@ mod tests {
|
||||
s.execute("render.optics.unbind", &json!({"name":"Studio","target":target,"expected_revision":s.revision})).unwrap();
|
||||
assert!(s.doc.render.setups["Studio"].optics.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_saved_optics_are_offline_undoable_and_keep_stable_identity_and_mass() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":3,"body_name":"Optics"})).unwrap();
|
||||
s.execute("material.assign", &json!({"bodies":["Optics"],"material":"Steel"})).unwrap();
|
||||
s.execute("render.settings.set",&json!({"name":"CPU glass","set":{"width":8,"height":8,"job":{"method":"cpu_raytrace","options":{"progressive":{"samples_per_pixel":3,"seed":81}}}}})).unwrap();
|
||||
assert!(s.render_job_host().is_none());
|
||||
let physical = s.execute("component.properties", &json!({"component":"root"})).unwrap();
|
||||
assert!(physical["total"]["mass_g"].as_f64().unwrap() > 0.0);
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let evaluations = s.evaluations;
|
||||
s.execute(
|
||||
"render.optics.bind",
|
||||
&json!({"name":"CPU glass","body_index":0,"body_name":"Optics","expected_revision":s.revision,
|
||||
"optics":{"ior":1.51,"absorption_per_mm":[0.1,0.02,0.0],"boundary":"closed_mesh"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.evaluations, evaluations);
|
||||
assert_eq!(s.doc.features, before.features);
|
||||
assert_eq!(s.doc.materials, before.materials);
|
||||
assert_eq!(s.execute("component.properties", &json!({"component":"root"})).unwrap(), physical);
|
||||
let saved = s.doc.clone();
|
||||
let scene = crate::product_scene::extract(&s).unwrap();
|
||||
let cpu = resolve_cpu(&s, &scene, &s.doc.render.setups["CPU glass"].optics, "check").unwrap();
|
||||
assert_eq!(cpu[0].body_name, "Optics");
|
||||
assert_eq!(cpu[0].ior, 1.51);
|
||||
assert_eq!(cpu[0].boundary, solidcraft_render::job::DielectricBoundary::ClosedBox);
|
||||
assert_eq!(
|
||||
s.doc.render.setups["CPU glass"].optics.iter().next().unwrap().optics.boundary,
|
||||
solidcraft_doc::render_optics::OpticalBoundary::ClosedMesh
|
||||
);
|
||||
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.render, s.doc.render);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc, *before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc, *saved);
|
||||
s.execute("browser.visibility", &json!({"items":["Optics"],"visible":false})).unwrap();
|
||||
let hidden = crate::product_scene::extract(&s).unwrap();
|
||||
assert!(resolve_cpu(&s, &hidden, &s.doc.render.setups["CPU glass"].optics, "check").unwrap_err().to_string().contains("hidden or absent"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,9 +67,12 @@ pub(super) fn capture_settings(s: &mut Session, params: &Value) -> Result<Value>
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
// Every numerical and method-dependent rejection happens before touching the source.
|
||||
match &setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
if options.hdr.is_some() || options.environment.is_some() || params.get("assume_linear_srgb").is_some() {
|
||||
return Err(bad(command, "CPU HDR capture requires hdr/environment unset and declared primaries; assume_linear_srgb is GPU-only"));
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
if options.hdr.is_some() || options.environment.is_some() || options.sky.is_some() || params.get("assume_linear_srgb").is_some() {
|
||||
return Err(bad(
|
||||
command,
|
||||
"CPU HDR capture requires hdr/environment/sky unset and declared primaries; assume_linear_srgb is GPU-only",
|
||||
));
|
||||
}
|
||||
CpuHdrEnvironment::check_controls(input.rotation_rad, input.intensity, input.samples.unwrap_or(16))
|
||||
.map_err(|e| bad(command, e.to_string()))?;
|
||||
@@ -89,7 +92,7 @@ pub(super) fn capture_settings(s: &mut Session, params: &Value) -> Result<Value>
|
||||
let info = metadata(&resource);
|
||||
let id = settings.resources.insert(resource).map_err(|e| bad(command, e.to_string()))?;
|
||||
match &mut setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
options.hdr = Some(CpuHdrEnvironment {
|
||||
resource: id,
|
||||
rotation_rad: input.rotation_rad,
|
||||
|
||||
@@ -6,6 +6,64 @@ fn save(s: &mut Session, patch: Value) {
|
||||
s.execute("render.settings.set", &json!({"name":"Studio", "set":patch})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_shot_patch_accepts_all_known_fields_and_refuses_unknowns_without_mutation() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":2,"width":3,"height":4})).unwrap();
|
||||
save(&mut s, json!({"width":8,"height":8}));
|
||||
let before = s.doc.clone();
|
||||
let model = s.model.state();
|
||||
let camera = s.camera;
|
||||
let evaluations = s.evaluations;
|
||||
let undo = s.undo.len();
|
||||
let mut complete = json!(RenderSetup::default());
|
||||
complete["width"] = json!(16);
|
||||
complete["height"] = json!(12);
|
||||
complete["camera"] = json!(solidcraft_render::Camera { distance: 80.0, fov: 0.6, ..Default::default() });
|
||||
complete["job"] = json!({"method":"cpu_raytrace","options":{"path":{"max_depth":4},
|
||||
"progressive":{"samples_per_pixel":4,"seed":17},"lens":{"aperture_radius_mm":0.02,"focus_distance_mm":80.0},
|
||||
"output":{"linear_exr":true}}});
|
||||
for field in ["surfaces", "optics", "coats", "stage"] {
|
||||
complete[field] = json!([]);
|
||||
}
|
||||
assert_eq!(complete.as_object().unwrap().len(), 15);
|
||||
let expected: RenderSetup = serde_json::from_value(complete.clone()).unwrap();
|
||||
expected.validate().unwrap();
|
||||
let input = json!({"name":"Studio","set":complete});
|
||||
let original = input.clone();
|
||||
s.execute("render.settings.set", &input).unwrap();
|
||||
assert_eq!(s.doc.render.setups["Studio"], expected);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.evaluations, evaluations);
|
||||
assert_eq!(s.camera, camera);
|
||||
assert!(Arc::ptr_eq(&model, &s.model.state()));
|
||||
assert_eq!(s.doc.features, before.features);
|
||||
assert_eq!(s.doc.materials, before.materials);
|
||||
assert_eq!(input, original);
|
||||
let saved = s.doc.clone();
|
||||
s.execute("render.settings.set", &input).unwrap();
|
||||
assert!(Arc::ptr_eq(&saved, &s.doc));
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
for set in [json!({"unknown":true}), {
|
||||
let mut over_schema = complete;
|
||||
over_schema["unknown"] = json!(true);
|
||||
over_schema
|
||||
}] {
|
||||
let history = (s.revision, s.undo.len(), s.redo.len(), s.evaluations);
|
||||
assert!(s.execute("render.settings.set", &json!({"name":"Studio","set":set})).is_err());
|
||||
assert!(Arc::ptr_eq(&saved, &s.doc));
|
||||
assert!(Arc::ptr_eq(&model, &s.model.state()));
|
||||
assert_eq!(history, (s.revision, s.undo.len(), s.redo.len(), s.evaluations));
|
||||
assert_eq!(s.camera, camera);
|
||||
}
|
||||
assert_eq!(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(), *s.doc);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, saved);
|
||||
assert_eq!(s.camera, camera);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_editor_rotation_wraps_and_each_edit_is_one_undo() {
|
||||
let mut s = Session::default();
|
||||
@@ -280,11 +338,14 @@ fn render_lights_edit_refusals_are_atomic_for_hostile_numbers_ids_stale_reviews_
|
||||
assert_eq!(before, (s.undo.len(), s.revision));
|
||||
}
|
||||
light_fixture(&mut s, "gpu_path");
|
||||
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"directional"}})).unwrap();
|
||||
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"directional","softness":0.5}})).unwrap();
|
||||
let document = s.doc.clone();
|
||||
for command in ["render.lights.add", "render.lights.duplicate"] {
|
||||
let params =
|
||||
if command.ends_with("add") { json!({"name":"Studio","set":{"kind":"point"}}) } else { json!({"name":"Studio","id":"directional:0"}) };
|
||||
let params = if command.ends_with("add") {
|
||||
json!({"name":"Studio","set":{"kind":"directional","softness":0.5}})
|
||||
} else {
|
||||
json!({"name":"Studio","id":"directional:0"})
|
||||
};
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
assert!(Arc::ptr_eq(&document, &s.doc));
|
||||
}
|
||||
@@ -709,7 +770,7 @@ fn portable_job_save_is_host_independent_atomic_and_undoable_without_geometry_ch
|
||||
save(&mut s, json!({"job":{"method":"cpu_raytrace"}}));
|
||||
assert_eq!(
|
||||
s.doc.render.setups["Studio"].job,
|
||||
Some(solidcraft_doc::render_settings::RenderJob::CpuRaytrace { options: solidcraft_doc::render_settings::CpuJob::default() })
|
||||
Some(solidcraft_doc::render_settings::RenderJob::CpuRaytrace { transparent_background: false, options: Box::default() })
|
||||
);
|
||||
assert_eq!(s.doc.render.setups["Studio"].exposure_ev, 2.5);
|
||||
assert!(Arc::ptr_eq(&model, &s.model.state()));
|
||||
@@ -967,12 +1028,219 @@ fn render_lights_removing_last_area_clears_area_only_sampling_settings() {
|
||||
light_fixture(&mut s, "cpu_raytrace");
|
||||
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"area"}})).unwrap();
|
||||
let mut setup = s.doc.render.setups["Studio"].clone();
|
||||
if let Some(RenderJob::CpuRaytrace { options }) = &mut setup.job {
|
||||
if let Some(RenderJob::CpuRaytrace { options, .. }) = &mut setup.job {
|
||||
options.area_samples = Some(64);
|
||||
options.seed = Some(123);
|
||||
}
|
||||
s.execute("render.lights.remove", &json!({"name":"Studio","id":"area:0","settings":setup})).unwrap();
|
||||
let Some(RenderJob::CpuRaytrace { options }) = &s.doc.render.setups["Studio"].job else { panic!("CPU") };
|
||||
let Some(RenderJob::CpuRaytrace { options, .. }) = &s.doc.render.setups["Studio"].job else { panic!("CPU") };
|
||||
assert!(options.area_samples.is_none() && options.seed.is_none());
|
||||
assert!(options.area_lights.as_ref().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_hard_directional_commands_materialize_defaults_and_keep_sun_distinct() {
|
||||
use crate::schema::render_lights::{Geometry, Visibility};
|
||||
let mut s = Session::default();
|
||||
save(&mut s, json!({"job":{"method":"gpu_path","options":{}}}));
|
||||
let implicit = crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap();
|
||||
assert_eq!(implicit.len(), 3);
|
||||
let before = s.doc.clone();
|
||||
let duplicate = s.execute("render.lights.duplicate", &json!({"name":"Studio","id":"directional:1"})).unwrap();
|
||||
assert_eq!(duplicate["id"], "directional:3");
|
||||
let actual = crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap();
|
||||
assert_eq!(actual.len(), 4);
|
||||
for (a, b) in actual.iter().take(3).zip(&implicit) {
|
||||
assert_eq!((a.intensity, a.colour), (b.intensity, b.colour));
|
||||
let (Geometry::Directional { direction: a, angular_radius_rad: None }, Geometry::Directional { direction: b, angular_radius_rad: None }) =
|
||||
(a.geometry, b.geometry)
|
||||
else {
|
||||
panic!("hard directions")
|
||||
};
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let sun = s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"directional","softness":0.25}})).unwrap();
|
||||
assert_eq!(sun["id"], "directional:0", "finite Sun is canonical first source");
|
||||
let original = s.doc.render.setups["Studio"].clone();
|
||||
let mut render_copy = original.clone();
|
||||
let visibility = Visibility { solo: Some("directional:3".into()), ..Default::default() };
|
||||
assert_eq!(crate::render_lights::filter(&mut render_copy, &visibility).unwrap(), None);
|
||||
let filtered = crate::render_lights::inspect(&render_copy).unwrap();
|
||||
for (i, light) in filtered.iter().enumerate() {
|
||||
assert_eq!(light.intensity > 0.0, i == 3);
|
||||
}
|
||||
assert_eq!(s.doc.render.setups["Studio"], original);
|
||||
for _ in 5..8 {
|
||||
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"directional"}})).unwrap();
|
||||
}
|
||||
let before = s.doc.clone();
|
||||
for (cmd, input) in [
|
||||
("render.lights.add", json!({"name":"Studio","set":{"kind":"directional"}})),
|
||||
("render.lights.duplicate", json!({"name":"Studio","id":"directional:1"})),
|
||||
("render.lights.duplicate", json!({"name":"Studio","id":"directional:0"})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &input).is_err());
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
}
|
||||
// Punctual sources have their own unchanged8-slot envelope. Filling the
|
||||
// directional envelope does not make the new point-light family unavailable.
|
||||
let point = s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"point"}})).unwrap();
|
||||
assert_eq!(point["id"], "point:0");
|
||||
let with_point = crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap();
|
||||
let original_rows = crate::render_lights::inspect(&before.render.setups["Studio"]).unwrap();
|
||||
assert_eq!(with_point.len(), 9);
|
||||
assert_eq!(&with_point[..8], original_rows.as_slice());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before, "one Undo restores the complete original8-directional setup");
|
||||
s.execute("render.lights.remove", &json!({"name":"Studio","id":"directional:7"})).unwrap();
|
||||
assert_eq!(crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap().len(), 7);
|
||||
assert_eq!(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(), *s.doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_light_id_remap_uses_original_identity_even_when_values_are_identical() {
|
||||
use crate::schema::render_lights::LightIdRemap;
|
||||
let mut s = Session::default();
|
||||
let hard = json!({"direction":[0,0,1],"color":[0.25,0.5,1],"intensity":2});
|
||||
save(&mut s, json!({"job":{"method":"gpu_path","options":{"lighting":{"directional_lights":[hard.clone(),hard]}}}}));
|
||||
let duplicated = s.execute("render.lights.duplicate", &json!({"name":"Studio","id":"directional:0"})).unwrap();
|
||||
let map: Vec<LightIdRemap> = serde_json::from_value(duplicated["id_remap"].clone()).unwrap();
|
||||
assert_eq!(map.len(), 2);
|
||||
assert_eq!(duplicated["id"], "directional:2");
|
||||
assert_eq!(
|
||||
map.iter().map(|m| (m.before.id.as_str(), m.after_id.as_str())).collect::<Vec<_>>(),
|
||||
[("directional:0", "directional:0"), ("directional:1", "directional:1")]
|
||||
);
|
||||
let added = s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"directional","softness":0.25}})).unwrap();
|
||||
let map: Vec<LightIdRemap> = serde_json::from_value(added["id_remap"].clone()).unwrap();
|
||||
assert_eq!(map.len(), 3);
|
||||
assert_eq!(added["id"], "directional:0");
|
||||
for (index, entry) in map.iter().enumerate() {
|
||||
assert_eq!(entry.before.id, format!("directional:{index}"));
|
||||
assert_eq!(entry.after_id, format!("directional:{}", index + 1));
|
||||
}
|
||||
let removed = s.execute("render.lights.remove", &json!({"name":"Studio","id":"directional:0"})).unwrap();
|
||||
let map: Vec<LightIdRemap> = serde_json::from_value(removed["id_remap"].clone()).unwrap();
|
||||
assert_eq!(map.len(), 3);
|
||||
for (index, entry) in map.iter().enumerate() {
|
||||
assert_eq!(entry.before.id, format!("directional:{}", index + 1));
|
||||
assert_eq!(entry.after_id, format!("directional:{index}"));
|
||||
}
|
||||
assert!(removed["id"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_light_edits_preserve_untouched_original_sun_and_area_radiance_bits() {
|
||||
let mut s = Session::default();
|
||||
let rgb = [0.7_f64, 1.0, 9.1];
|
||||
save(
|
||||
&mut s,
|
||||
json!({"job":{"method":"gpu_path","options":{"lighting":{
|
||||
"sun":{"direction":[1,2,3],"angular_radius_rad":0.02,"radiance":rgb},
|
||||
"area_lights":[{"center":[0,0,10],"u":[1,0,0],"v":[0,-1,0],"radiance":rgb}],
|
||||
"directional_lights":[{"direction":[0,0,1],"color":[1,1,1],"intensity":1}]
|
||||
}}}}),
|
||||
);
|
||||
let lighting = |s: &Session| {
|
||||
let Some(RenderJob::GpuPath { options, .. }) = &s.doc.render.setups["Studio"].job else { panic!("gpu") };
|
||||
options.lighting.unwrap()
|
||||
};
|
||||
let original = lighting(&s);
|
||||
let bits = |a: [f64; 3]| a.map(f64::to_bits);
|
||||
for (command, params) in [
|
||||
("render.lights.add", json!({"name":"Studio","set":{"kind":"directional"}})),
|
||||
("render.lights.set", json!({"name":"Studio","id":"directional:1","set":{"direction":[2,3,4]}})),
|
||||
("render.lights.remove", json!({"name":"Studio","id":"directional:2"})),
|
||||
] {
|
||||
s.execute(command, ¶ms).unwrap();
|
||||
let actual = lighting(&s);
|
||||
assert_eq!(bits(actual.sun.unwrap().radiance), bits(original.sun.unwrap().radiance));
|
||||
assert_eq!(bits(actual.area_lights.iter().next().unwrap().radiance), bits(rgb));
|
||||
}
|
||||
s.execute("render.lights.remove", &json!({"name":"Studio","id":"directional:0"})).unwrap();
|
||||
assert!(lighting(&s).sun.is_none());
|
||||
assert_eq!(bits(lighting(&s).area_lights.iter().next().unwrap().radiance), bits(rgb));
|
||||
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"directional","softness":0.5}})).unwrap();
|
||||
assert_eq!(bits(lighting(&s).area_lights.iter().next().unwrap().radiance), bits(rgb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_point_spot_commands_keep_cones_ranges_undo_visibility_identity_and_portable_reload() {
|
||||
use crate::schema::render_lights::{Geometry, Visibility};
|
||||
let mut s = Session::default();
|
||||
save(&mut s, json!({"job":{"method":"gpu_path","options":{}}}));
|
||||
let implicit = crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap();
|
||||
let add=s.execute("render.lights.add",&json!({"name":"Studio","set":{"kind":"spot","position_mm":[4,5,6],"emitted_axis":[-4,-5,-6],"inner_half_angle_radians":0.2,"outer_half_angle_radians":0.7,"reference_distance_mm":7.25,"minimum_distance_mm":0.002}})).unwrap();
|
||||
assert_eq!(add["id"], "spot:0");
|
||||
let authored = s.doc.clone();
|
||||
let all = crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap();
|
||||
for (a, b) in all.iter().take(3).zip(&implicit) {
|
||||
assert_eq!((a.geometry, a.intensity, a.colour), (b.geometry, b.intensity, b.colour));
|
||||
assert!(!a.implicit);
|
||||
}
|
||||
let Geometry::Spot { position_mm, emitted_axis, inner_half_angle_radians, outer_half_angle_radians, reference_distance_mm, minimum_distance_mm } =
|
||||
all[3].geometry
|
||||
else {
|
||||
panic!("spot")
|
||||
};
|
||||
assert_eq!((position_mm, emitted_axis), ([4.0, 5.0, 6.0], [-4.0, -5.0, -6.0]));
|
||||
assert_eq!((inner_half_angle_radians, outer_half_angle_radians, reference_distance_mm, minimum_distance_mm), (0.2, 0.7, 7.25, 0.002));
|
||||
let undo = s.undo.len();
|
||||
let point = s.execute("render.lights.set", &json!({"name":"Studio","id":"spot:0","set":{"kind":"point"}})).unwrap();
|
||||
assert_eq!(point["id"], "point:0");
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let rows = crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap();
|
||||
assert_eq!(rows[3].geometry, Geometry::Point { position_mm, reference_distance_mm, minimum_distance_mm });
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, authored);
|
||||
let dup = s.execute("render.lights.duplicate", &json!({"name":"Studio","id":"spot:0"})).unwrap();
|
||||
assert_eq!(dup["id"], "spot:1");
|
||||
assert!(dup["id_remap"].as_array().unwrap().iter().any(|m| m["before"]["id"] == "spot:0" && m["after_id"] == "spot:0"));
|
||||
let before = s.doc.clone();
|
||||
let mut filtered = s.doc.render.setups["Studio"].clone();
|
||||
crate::render_lights::filter(&mut filtered, &Visibility { solo: Some("spot:1".into()), ..Default::default() }).unwrap();
|
||||
let rows = crate::render_lights::inspect(&filtered).unwrap();
|
||||
assert!(rows.iter().all(|l| (l.intensity > 0.0) == (l.id == "spot:1")));
|
||||
assert_eq!(s.doc, before);
|
||||
for _ in 2..8 {
|
||||
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"point"}})).unwrap();
|
||||
}
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
for command in ["render.lights.add", "render.lights.duplicate"] {
|
||||
let params = if command.ends_with("add") { json!({"name":"Studio","set":{"kind":"spot"}}) } else { json!({"name":"Studio","id":"spot:0"}) };
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened, *s.doc);
|
||||
assert_eq!(
|
||||
crate::render_lights::inspect(&reopened.render.setups["Studio"]).unwrap(),
|
||||
crate::render_lights::inspect(&s.doc.render.setups["Studio"]).unwrap()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn gpu_spot_cone_hostile_edits_refuse_atomically_without_widening_or_ignoring_intent() {
|
||||
let mut s = Session::default();
|
||||
light_fixture(&mut s, "gpu_path");
|
||||
s.execute("render.lights.add", &json!({"name":"Studio","set":{"kind":"spot"}})).unwrap();
|
||||
for set in [
|
||||
json!({"inner_half_angle_radians":0.5,"outer_half_angle_radians":0.5}),
|
||||
json!({"inner_half_angle_radians":0.2,"outer_half_angle_radians":0.2000000001}),
|
||||
json!({"emitted_axis":[0,0,0]}),
|
||||
json!({"outer_half_angle_radians":2}),
|
||||
json!({"emitted_axis":[0,0,-1],"aim_at":[1,2,3]}),
|
||||
] {
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.revision, s.undo.len(), s.redo.len(), s.evaluations);
|
||||
assert!(s.execute("render.lights.set", &json!({"name":"Studio","id":"spot:0","set":set})).is_err());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.evaluations));
|
||||
}
|
||||
s.execute("render.lights.set", &json!({"name":"Studio","id":"spot:0","set":{"kind":"point"}})).unwrap();
|
||||
assert!(s.execute("render.lights.set", &json!({"name":"Studio","id":"point:0","set":{"inner_half_angle_radians":0.1}})).is_err());
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{Result, Session, params::bad};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{
|
||||
render_resources::GpuHdrEnvironment,
|
||||
render_resources::{CpuHdrEnvironment, GpuHdrEnvironment},
|
||||
render_settings::{RenderJob, RenderSettings, RenderSetup},
|
||||
};
|
||||
use solidcraft_render::{
|
||||
@@ -76,8 +76,8 @@ fn candidate(s: &Session, input: &Input, command: &str) -> Result<Candidate> {
|
||||
}
|
||||
let source = recipe(&input.id, command)?;
|
||||
let mut setup = super::patched_settings(s, &input.name, &input.set, command)?;
|
||||
if !matches!(&setup.job, Some(RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "native studio compositions require an explicitly selected GPU path setup; no executor is substituted"));
|
||||
if !matches!(&setup.job, Some(RenderJob::CpuRaytrace { .. } | RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "native studio compositions require an explicitly selected CPU or GPU raytrace setup; no executor is substituted"));
|
||||
}
|
||||
let old = s.doc.render.setups.get(&input.name);
|
||||
let stage_replacement = !setup.stage.is_empty() || old.is_some_and(|saved| !saved.stage.is_empty());
|
||||
@@ -95,7 +95,13 @@ fn candidate(s: &Session, input: &Input, command: &str) -> Result<Candidate> {
|
||||
let hdr = source["hdr"].as_str().ok_or_else(|| bad(command, "native composition has no HDR identity"))?;
|
||||
let resource = super::library::hdr_resource(hdr, command)?;
|
||||
let resource_id = resource.id();
|
||||
let mut lighting = StudioLighting { environment: [0.0; 3], sun: None, area_lights: AreaLights::default() };
|
||||
let mut lighting = StudioLighting {
|
||||
punctual_lights: Default::default(),
|
||||
directional_lights: Default::default(),
|
||||
environment: [0.0; 3],
|
||||
sun: None,
|
||||
area_lights: AreaLights::default(),
|
||||
};
|
||||
let mut panels = Vec::new();
|
||||
let mut projection = Value::Null;
|
||||
if let Some(rig) = source["rig"].as_str() {
|
||||
@@ -133,16 +139,31 @@ fn candidate(s: &Session, input: &Input, command: &str) -> Result<Candidate> {
|
||||
});
|
||||
setup.stage = Stage::new(panels).map_err(|e| bad(command, e.to_string()))?;
|
||||
setup.exposure_ev = source["exposure_ev"].as_f64().ok_or_else(|| bad(command, "native composition exposure missing"))?;
|
||||
if let Some(RenderJob::GpuPath { options, .. }) = &mut setup.job {
|
||||
options.environment = None;
|
||||
options.hdr = Some(GpuHdrEnvironment {
|
||||
resource: resource_id,
|
||||
rotation_rad: super::library::hdr_rotation(0.35, command)?,
|
||||
intensity: source["intensity"].as_f64().ok_or_else(|| bad(command, "native composition intensity missing"))?,
|
||||
assume_linear_srgb: false,
|
||||
});
|
||||
options.lighting = Some(lighting);
|
||||
}
|
||||
let executor = match &mut setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
let hdr_samples = options.hdr.map_or(16, |hdr| hdr.samples);
|
||||
super::library::cpu_studio_lighting(options, &lighting, command)?;
|
||||
options.hdr = Some(CpuHdrEnvironment {
|
||||
resource: resource_id,
|
||||
rotation_rad: super::library::hdr_rotation(0.35, command)?,
|
||||
intensity: source["intensity"].as_f64().ok_or_else(|| bad(command, "native composition intensity missing"))?,
|
||||
samples: hdr_samples,
|
||||
});
|
||||
"cpu-raytrace"
|
||||
}
|
||||
Some(RenderJob::GpuPath { options, .. }) => {
|
||||
options.environment = None;
|
||||
options.hdr = Some(GpuHdrEnvironment {
|
||||
resource: resource_id,
|
||||
rotation_rad: super::library::hdr_rotation(0.35, command)?,
|
||||
intensity: source["intensity"].as_f64().ok_or_else(|| bad(command, "native composition intensity missing"))?,
|
||||
assume_linear_srgb: false,
|
||||
});
|
||||
options.lighting = Some(lighting);
|
||||
"gpu-path"
|
||||
}
|
||||
_ => return Err(bad(command, "choose CPU or GPU ray tracing")),
|
||||
};
|
||||
setup.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
super::staging::apply(&mut product, &setup, command)?;
|
||||
super::library::rig_clearance(&product, &lighting, command)?;
|
||||
@@ -152,7 +173,7 @@ fn candidate(s: &Session, input: &Input, command: &str) -> Result<Candidate> {
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
let report = json!({"id":input.id,"name":input.name,"revision":s.revision,"source":source,"setup":setup,
|
||||
"resource_sha256":resource_id,"rig_projection":projection,"status":"resolved_native_projection",
|
||||
"document_changed":false,"executor":"gpu-path","requires_lighting_consent":true,"requires_stage_consent":stage_replacement,
|
||||
"document_changed":false,"executor":executor,"requires_lighting_consent":true,"requires_stage_consent":stage_replacement,
|
||||
"replaces":["lighting","environment","passive_stage","exposure"],
|
||||
"preserves":["camera","output_dimensions","sampling","optical_bindings","geometry","engineering_materials","pose","visibility"],
|
||||
"clearance":"conservative subject/stage/emitter AABB separation; not exact contact certification",
|
||||
@@ -189,16 +210,27 @@ struct Quality {
|
||||
id: String,
|
||||
variant: String,
|
||||
acknowledge_omissions: bool,
|
||||
#[serde(default)]
|
||||
cpu: Option<CpuQuality>,
|
||||
}
|
||||
#[derive(Clone, Copy, Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct CpuQuality {
|
||||
minimum_budget: solidcraft_render::job::CpuBudget,
|
||||
adaptive: Option<solidcraft_render::job::adaptive::AdaptiveSampling>,
|
||||
}
|
||||
pub(super) fn quality(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let command = "render.quality.prepare";
|
||||
let input: Quality = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.variant != "fixed_samples_png" || !input.acknowledge_omissions {
|
||||
if !matches!(input.variant.as_str(), "fixed_samples_png" | "cpu_progressive_png") || !input.acknowledge_omissions {
|
||||
return Err(bad(
|
||||
command,
|
||||
"explicit fixed_samples_png projection and omission acknowledgement are required; full Q-* requirements remain unbound",
|
||||
"an explicit fixed_samples_png or cpu_progressive_png projection and omission acknowledgement are required; full Q-* requirements remain unbound",
|
||||
));
|
||||
}
|
||||
if input.variant == "fixed_samples_png" && input.cpu.is_some() {
|
||||
return Err(bad(command, "CPU budget context requires the cpu_progressive_png projection"));
|
||||
}
|
||||
let source = super::library::item("quality", &input.id, command)?;
|
||||
let number = |path: &[&str]| -> Result<u32> {
|
||||
let mut field = source;
|
||||
@@ -208,6 +240,47 @@ pub(super) fn quality(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
field.as_u64().and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad(command, "quality field is not a bounded unsigned integer"))
|
||||
};
|
||||
let (width, height) = (number(&["resolution", "width"])?, number(&["resolution", "height"])?);
|
||||
if input.variant == "cpu_progressive_png" {
|
||||
if !matches!(input.id.as_str(), "Q-001" | "Q-002" | "Q-003") || source["motion"]["enabled"] != false {
|
||||
return Err(bad(
|
||||
command,
|
||||
"only the three canonical still-image CPU cost budgets are bound; optical/convergence concepts are not CPU presets",
|
||||
));
|
||||
}
|
||||
let samples = number(&["sampling", "minimum_samples"])?.max(16);
|
||||
let seed = number(&["seed"])?;
|
||||
let context = input.cpu.unwrap_or_default();
|
||||
let production = Some(solidcraft_render::job::CpuProgressiveOptions { samples_per_pixel: samples, seed });
|
||||
let mut eligible = false;
|
||||
let mut selected = None;
|
||||
let mut refusal = None;
|
||||
for budget in solidcraft_render::job::CpuBudget::ALL {
|
||||
eligible |= budget == context.minimum_budget;
|
||||
if !eligible {
|
||||
continue;
|
||||
}
|
||||
let admitted = solidcraft_render::job::cpu_camera_sampling(production, context.adaptive, budget)
|
||||
.and_then(|sampling| budget.validate_image(width, height, sampling));
|
||||
match admitted {
|
||||
Ok(()) => {
|
||||
selected = Some(budget);
|
||||
break;
|
||||
}
|
||||
Err(error) => refusal = Some(error),
|
||||
}
|
||||
}
|
||||
let budget = selected
|
||||
.ok_or_else(|| bad(command, refusal.map_or_else(|| "no existing CPU profile admits this budget".to_string(), |e| e.to_string())))?;
|
||||
return Ok(
|
||||
json!({"id":input.id,"name":source["name"],"variant":input.variant,"status":"cpu_cost_budget_projection_not_full_quality_profile",
|
||||
"revision":s.revision,"document_changed":false,"source_unchanged":true,
|
||||
"settings":{"width":width,"height":height},"cpu_options":{"progressive":{"samples_per_pixel":samples,"seed":seed},"budget":budget},
|
||||
"cpu_context":{"minimum_budget":context.minimum_budget,"adaptive":context.adaptive},
|
||||
"requires_progressive":true,"changed_fields":["width","height","progressive.samples_per_pixel","progressive.seed","budget"],
|
||||
"preserves":["executor","camera","lens","lighting","environment","stage","optical_bindings","surface_maps","aging","exposure","tone_map","finishing","raw_master","denoise","path","adaptive"],
|
||||
"omitted":["catalog_maximum_samples","catalog_transport_depth","catalog_denoise_choice","adaptive_stopping_and_error_target","convergence_certification","time_guarantee"],"source":source}),
|
||||
);
|
||||
}
|
||||
let (samples, depth) = (number(&["sampling", "maximum_samples"])?, number(&["transport", "maximum_depth"])?);
|
||||
let seed = number(&["seed"])?;
|
||||
if solidcraft_render::job::GPU_PATH_LIMITS.fit(u64::from(width), u64::from(height), None) != Some((u64::from(width), u64::from(height)))
|
||||
|
||||
@@ -210,3 +210,261 @@ fn finishing_and_linear_master_saved_intent_survive_reopen_without_host_and_reje
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_quality_projects_exact_catalog_costs_with_existing_admissible_profiles_without_mutation() {
|
||||
use solidcraft_render::job::{CpuBudget, CpuProgressiveOptions};
|
||||
let mut s = subject();
|
||||
let before = s.doc.clone();
|
||||
let history = (s.revision, s.undo.len(), s.redo.len());
|
||||
for (id, width, height, samples, profile) in
|
||||
[("Q-001", 960, 540, 16, CpuBudget::Compact), ("Q-002", 1600, 900, 32, CpuBudget::Studio), ("Q-003", 2560, 1440, 64, CpuBudget::Studio)]
|
||||
{
|
||||
let plan = s.execute("render.quality.prepare", &json!({"id":id,"variant":"cpu_progressive_png","acknowledge_omissions":true})).unwrap();
|
||||
assert_eq!(plan["settings"], json!({"width":width,"height":height}));
|
||||
assert_eq!(plan["cpu_options"], json!({"budget":profile,"progressive":{"samples_per_pixel":samples,"seed":21026}}));
|
||||
assert_eq!(plan["changed_fields"], json!(["width", "height", "progressive.samples_per_pixel", "progressive.seed", "budget"]));
|
||||
assert!(plan.get("gpu_options").is_none());
|
||||
assert!(plan["cpu_options"].get("path").is_none());
|
||||
assert!(plan["cpu_options"].get("denoise").is_none());
|
||||
assert_eq!(plan["status"], "cpu_cost_budget_projection_not_full_quality_profile");
|
||||
assert_eq!(plan["document_changed"], false);
|
||||
let pixels = u64::from(width) * u64::from(height);
|
||||
assert!(pixels * u64::from(samples) <= profile.limits().max_camera_samples);
|
||||
profile.validate_image(width, height, Some(CpuProgressiveOptions { samples_per_pixel: samples, seed: 21026 })).unwrap();
|
||||
for larger in [CpuBudget::Studio, CpuBudget::Workstation] {
|
||||
let kept = s
|
||||
.execute(
|
||||
"render.quality.prepare",
|
||||
&json!({"id":id,"variant":"cpu_progressive_png","acknowledge_omissions":true,"cpu":{"minimum_budget":larger}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(kept["cpu_options"]["budget"], json!(larger), "an explicitly chosen larger profile must not be lowered");
|
||||
}
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!(history, (s.revision, s.undo.len(), s.redo.len()));
|
||||
}
|
||||
let pilot = s
|
||||
.execute(
|
||||
"render.quality.prepare",
|
||||
&json!({"id":"Q-001","variant":"cpu_progressive_png","acknowledge_omissions":true,
|
||||
"cpu":{"adaptive":{"pilot_samples":256,"min_samples":16}}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pilot["cpu_options"]["budget"], "studio", "Draft must budget272total passes, not hide pilots outside Compact64");
|
||||
let first_pixels = 960_u64 * 540;
|
||||
assert!(first_pixels * 272 < CpuBudget::Studio.limits().max_camera_samples);
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!(history, (s.revision, s.undo.len(), s.redo.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_quality_rejects_unbound_ids_hostile_context_and_incompatible_adaptive_without_silent_substitution() {
|
||||
let mut s = subject();
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.revision, s.undo.len(), s.redo.len());
|
||||
for value in [
|
||||
json!({"id":"Q-004","variant":"cpu_progressive_png","acknowledge_omissions":true}),
|
||||
json!({"id":"Q-999","variant":"cpu_progressive_png","acknowledge_omissions":true}),
|
||||
json!({"id":"Q-001","variant":"cpu_progressive_png","acknowledge_omissions":false}),
|
||||
json!({"id":"Q-001","variant":"cpu_progressive_png","acknowledge_omissions":true,"cpu":{"minimum_budget":"unlimited"}}),
|
||||
json!({"id":"Q-001","variant":"cpu_progressive_png","acknowledge_omissions":true,"cpu":{"enable_gi":true}}),
|
||||
json!({"id":"Q-001","variant":"fixed_samples_png","acknowledge_omissions":true,"cpu":{}}),
|
||||
json!({"id":"Q-001","variant":"cpu_progressive_png","acknowledge_omissions":true,"cpu":{"adaptive":{"pilot_samples":16,"min_samples":32}}}),
|
||||
json!({"id":"Q-003","variant":"cpu_progressive_png","acknowledge_omissions":true,"cpu":{"adaptive":{"pilot_samples":16,"min_samples":128}}}),
|
||||
] {
|
||||
assert!(s.execute("render.quality.prepare", &value).is_err(), "{value}");
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "bundled-render-library")]
|
||||
#[test]
|
||||
fn cpu_native_studios_preserve_full_job_and_exact_hdr_rig_authority_for_all_twenty_five() {
|
||||
use solidcraft_doc::render_settings::CpuJob;
|
||||
use solidcraft_render::job::{CpuBudget, CpuPathOptions, CpuProgressiveOptions, ThinLens, adaptive::AdaptiveSampling};
|
||||
for entry in entries().as_array().unwrap() {
|
||||
let id = entry["id"].as_str().unwrap();
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":20,"width":15,"height":10,"body_name":"Subject"})).unwrap();
|
||||
let camera = solidcraft_render::Camera { fov: 0.5, ..Default::default() };
|
||||
let options = CpuJob {
|
||||
budget: CpuBudget::Studio,
|
||||
progressive: Some(CpuProgressiveOptions { samples_per_pixel: 64, seed: 947 }),
|
||||
adaptive: Some(AdaptiveSampling::default()),
|
||||
path: Some(CpuPathOptions { max_depth: 5 }),
|
||||
denoise: true,
|
||||
lens: Some(ThinLens { aperture_radius_mm: 0.2, focus_distance_mm: 100.0 }),
|
||||
output: Some(Box::new(solidcraft_render::job::OutputOptions { linear_exr: true, ..Default::default() })),
|
||||
area_samples: Some(64),
|
||||
seed: Some(731),
|
||||
volume: Some(solidcraft_render::job::volume::HomogeneousVolume { extinction_per_mm: 0.0, ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
s.execute("render.settings.set", &json!({"name":"Shot","set":{"width":96,"height":64,"camera":camera,"job":{"method":"cpu_raytrace","transparent_background":true,"options":options}}})).unwrap();
|
||||
s.execute("render.library.environment.apply", &json!({"id":"HDR-001","name":"Shot","samples":64})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let revision = s.revision;
|
||||
let old = s.doc.render.setups["Shot"].clone();
|
||||
let plan = s.execute("render.preset.resolve", &json!({"id":id,"name":"Shot","expected_revision":revision})).unwrap();
|
||||
assert!(Arc::ptr_eq(&s.doc, &before));
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
assert_eq!(plan["executor"], "cpu-raytrace");
|
||||
let applied = s
|
||||
.execute("render.preset.apply", &json!({"id":id,"name":"Shot","expected_revision":revision,"replace_lighting_and_environment":true}))
|
||||
.unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(applied["setup"], plan["setup"]);
|
||||
let saved = &s.doc.render.setups["Shot"];
|
||||
assert_eq!((saved.camera, saved.width, saved.height), (old.camera, old.width, old.height));
|
||||
assert_eq!((&saved.optics, &saved.coats, &saved.surfaces), (&old.optics, &old.coats, &old.surfaces));
|
||||
let Some(RenderJob::CpuRaytrace { options, transparent_background }) = &saved.job else { panic!("CPU method lost") };
|
||||
assert!(*transparent_background);
|
||||
let Some(RenderJob::CpuRaytrace { options: old_options, .. }) = &old.job else {
|
||||
panic!("the captured original setup must retain its CPU method")
|
||||
};
|
||||
let mut expected = (**old_options).clone();
|
||||
expected.preset = None;
|
||||
expected.lights = Some(vec![]);
|
||||
expected.punctual_lights = Some(vec![]);
|
||||
expected.environment = None;
|
||||
expected.sky = None;
|
||||
expected.sun = None;
|
||||
expected.hdr = Some(CpuHdrEnvironment {
|
||||
resource: serde_json::from_value(plan["resource_sha256"].clone()).unwrap(),
|
||||
rotation_rad: 0.35 + std::f64::consts::PI,
|
||||
intensity: entry["intensity"].as_f64().unwrap(),
|
||||
samples: 64,
|
||||
});
|
||||
expected.area_lights = Some(vec![]);
|
||||
if entry["rig"] == "RIG-002" {
|
||||
let bounds = crate::product_scene::extract(&s).unwrap().bounds;
|
||||
let size = bounds.max - bounds.min;
|
||||
let c = bounds.center();
|
||||
let (lighting, _) = super::super::library::rig_lighting(
|
||||
&super::super::library::RigInput {
|
||||
id: "RIG-002".into(),
|
||||
variant: "complete".into(),
|
||||
center_mm: [c.x, c.y, c.z],
|
||||
subject_extent_mm: size.x.max(size.y).max(size.z),
|
||||
},
|
||||
"test",
|
||||
)
|
||||
.unwrap();
|
||||
expected.area_lights = Some(
|
||||
lighting
|
||||
.area_lights
|
||||
.iter()
|
||||
.map(|light| solidcraft_render::job::RectangleLightOptions {
|
||||
center_mm: light.center,
|
||||
edge_u_mm: light.u.map(|x| x * 2.0),
|
||||
edge_v_mm: light.v.map(|x| x * 2.0),
|
||||
color: [1.0; 3],
|
||||
intensity: light.radiance[0] / 16.0,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
assert_eq!(expected.area_lights.as_ref().unwrap().iter().map(|a| a.intensity).fold(0.0, f64::max), 29.691586369625);
|
||||
}
|
||||
assert_eq!(**options, expected, "all unrelated CPU intent survives {id}");
|
||||
let hdr = options.hdr.unwrap();
|
||||
assert_eq!(
|
||||
s.doc.render.resources.required(&hdr.resource).unwrap().bytes(),
|
||||
super::super::library_data::asset(entry["hdr"].as_str().unwrap()).unwrap().0
|
||||
);
|
||||
assert_eq!(s.doc.features, before.features);
|
||||
assert_eq!(s.doc.materials, before.materials);
|
||||
let saved_json = s.doc.to_json();
|
||||
let reopened = solidcraft_doc::Document::from_json(&saved_json).unwrap();
|
||||
assert_eq!(reopened.render.setups["Shot"], *saved);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.to_json(), saved_json);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "bundled-render-library", feature = "raytrace-cpu", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn cpu_native_studio_cold_capture_matches_typed_source_every_prefix_and_raw_master() {
|
||||
use solidcraft_render::job::{CancelToken, CpuHost, ImageRequest, Integrator, JobId, Phase};
|
||||
use solidcraft_render::lighting::environment_input::EnvironmentInput;
|
||||
use solidcraft_render::raytrace::image::{ImagePoll, ImageTask};
|
||||
for id in ["NATIVE-STUDIO-001", "NATIVE-STUDIO-004", "RIG-020"] {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":20,"width":15,"height":10,"body_name":"Subject"})).unwrap();
|
||||
let camera = solidcraft_render::Camera { target: solidcore_record::Vec3::new(10.0, 7.5, 5.0), distance: 35.0, ..Default::default() };
|
||||
s.execute("render.settings.set",&json!({"name":"Shot","set":{"width":8,"height":8,"camera":camera,"job":{"method":"cpu_raytrace","options":{"progressive":{"samples_per_pixel":3,"seed":47},"area_samples":1,"seed":731,"output":{"linear_exr":true}}}}})).unwrap();
|
||||
s.execute("render.library.environment.apply", &json!({"id":"HDR-001","name":"Shot","samples":1})).unwrap();
|
||||
if id == "RIG-020" {
|
||||
s.execute("render.library.studio.apply", &json!({"name":"Shot","rig":{"id":id,"variant":"complete","center_mm":[10,7.5,5],"subject_extent_mm":20},"expected_revision":s.revision,"replace_lighting_and_environment":true,"enable_indirect":true})).unwrap();
|
||||
} else {
|
||||
s.execute("render.preset.apply", &json!({"id":id,"name":"Shot","expected_revision":s.revision,"replace_lighting_and_environment":true}))
|
||||
.unwrap();
|
||||
}
|
||||
let bytes = solidcraft_io::write_design_container(&s.doc).unwrap();
|
||||
s = Session::new(solidcraft_io::read_design(&bytes).unwrap());
|
||||
s.execute("render.scene", &json!({})).unwrap();
|
||||
let setup = s.doc.render.setups["Shot"].clone();
|
||||
let Some(RenderJob::CpuRaytrace { options, transparent_background }) = &setup.job else { panic!() };
|
||||
let hdr = options.hdr;
|
||||
if id == "RIG-020" {
|
||||
assert!(hdr.is_none());
|
||||
assert!(options.sun.is_some());
|
||||
} else {
|
||||
assert_eq!(hdr.unwrap().samples, 1);
|
||||
}
|
||||
let runtime = options.to_runtime(setup.exposure_ev).unwrap();
|
||||
let mut scene = crate::product_scene::extract(&s).unwrap();
|
||||
super::super::staging::apply(&mut scene, &setup, "test").unwrap();
|
||||
let render = |quantum| {
|
||||
let request = ImageRequest {
|
||||
integrator: Integrator::Raytrace,
|
||||
raytrace: Some(runtime.clone()),
|
||||
camera: setup.camera,
|
||||
width: setup.width,
|
||||
height: setup.height,
|
||||
background: setup.background,
|
||||
transparent_background: *transparent_background,
|
||||
studio_weights: None,
|
||||
environment: None,
|
||||
textures: None,
|
||||
};
|
||||
let input = hdr.map(|hdr| {
|
||||
EnvironmentInput::new(s.doc.render.resources.required(&hdr.resource).unwrap().source(), hdr.rotation_rad, hdr.intensity, hdr.samples)
|
||||
.unwrap()
|
||||
});
|
||||
let mut task = ImageTask::new_with_environment(scene.clone(), request, CancelToken::default(), runtime.clone(), input).unwrap();
|
||||
let mut prefixes = Vec::new();
|
||||
for _ in 0..2_000_000 {
|
||||
match task.step_with_previews(quantum).unwrap() {
|
||||
ImagePoll::Pending(_) => {}
|
||||
ImagePoll::Preview { png, samples_per_pixel } => prefixes.push((samples_per_pixel, png)),
|
||||
ImagePoll::Complete(png) => return (prefixes, png, task.take_linear_exr().unwrap()),
|
||||
}
|
||||
}
|
||||
panic!("bounded native studio source failed to finish")
|
||||
};
|
||||
let expected = render(1);
|
||||
assert_eq!(expected.0.iter().map(|(sample, _)| *sample).collect::<Vec<_>>(), [1, 2]);
|
||||
assert!(expected.0.iter().all(|(_, png)| png.starts_with(b"\x89PNG\r\n\x1a\n")));
|
||||
assert_eq!(expected, render(4096), "every complete prefix, final PNG and raw master is independent of work quantum");
|
||||
let host = CpuHost::for_platform().unwrap();
|
||||
s.set_render_job_host(Some(host)).unwrap();
|
||||
let doc = s.doc.clone();
|
||||
let history = (s.revision, s.undo.len(), s.redo.len());
|
||||
s.execute("render.preflight", &json!({"settings":setup})).unwrap();
|
||||
let reply = s.execute("render.start", &json!({"settings":setup})).unwrap();
|
||||
let job: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
|
||||
let handle = s.render_job_handle(job).unwrap();
|
||||
let status = handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap();
|
||||
assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
|
||||
let frame = handle.frame().unwrap().unwrap();
|
||||
assert_eq!(frame.png(), expected.1.as_slice());
|
||||
assert_eq!(frame.linear_exr().unwrap(), expected.2.as_slice());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!((s.revision, s.undo.len(), s.redo.len()), history);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +135,16 @@ pub(super) fn bind(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
return Err(bad(command, "setup patch cannot erase or replace saved surface bindings; use explicit bind/unbind commands"));
|
||||
}
|
||||
|
||||
if !matches!(setup.job, Some(RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "persistent authored maps require an explicit GPU setup"));
|
||||
if !matches!(setup.job, Some(RenderJob::CpuRaytrace { .. } | RenderJob::GpuPath { .. })) {
|
||||
return Err(bad(command, "persistent authored maps require an explicit CPU or GPU raytrace setup"));
|
||||
}
|
||||
if let Some(RenderJob::CpuRaytrace { options, .. }) = &setup.job {
|
||||
if !setup.optics.is_empty() && options.path.is_none() {
|
||||
return Err(bad(command, "CPU maps with Glass require Indirect lighting and separate targets"));
|
||||
}
|
||||
// Saving validates portable intent; capture later resolves stable aging
|
||||
// targets. The final setup validator still forbids maps on a Glass target.
|
||||
options.validate_surface_maps(setup.exposure_ev).map_err(|e| bad(command, e.to_string()))?;
|
||||
}
|
||||
let prepared = super::library::prepare_surface(s, &input.surface)?;
|
||||
let id = prepared["id"].as_str().ok_or_else(|| bad(command, "missing prepared surface identifier"))?;
|
||||
|
||||
@@ -154,7 +154,7 @@ fn divider(_: &mut Session, params: &Value) -> Result<Value> {
|
||||
Ok(json!({"position":input.position.clamp(0.05,0.95)}))
|
||||
}
|
||||
|
||||
fn batch(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
pub(crate) fn batch_request(s: &Session, params: &Value) -> Result<Value> {
|
||||
let input: BatchParams = serde_json::from_value(params.clone()).map_err(|e| bad("render.batch", e.to_string()))?;
|
||||
s.render_batch_ready().map_err(|reason| bad("render.batch", reason))?;
|
||||
if s.model.is_stale() {
|
||||
@@ -187,7 +187,11 @@ fn batch(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
return Err(bad("render.batch", "replace must be false or omitted"));
|
||||
}
|
||||
request.insert("settings".into(), json!(settings));
|
||||
let request = std::sync::Arc::new(Value::Object(request));
|
||||
Ok(Value::Object(request))
|
||||
}
|
||||
|
||||
fn batch(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let request = std::sync::Arc::new(batch_request(s, params)?);
|
||||
let shots = s
|
||||
.doc
|
||||
.render
|
||||
|
||||
@@ -6,13 +6,14 @@ use solidcraft_render::{Camera, StandardView};
|
||||
use super::CommandSpec;
|
||||
use crate::params::{bad, num, req_vec2, req_vec3, vec3};
|
||||
use crate::schema::view_navigation::{
|
||||
CameraReply, NavigatorParams, NormalToParams, RenderBrowser, RenderFocus, RenderSelect, StandardViewParams, TabStep, TabStepParams,
|
||||
CameraReply, NavigatorParams, NormalToParams, RenderBrowser, RenderFocus, RenderLive, RenderSelect, StandardViewParams, TabStep, TabStepParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{Result, Sel, Session};
|
||||
|
||||
const FOCUS_CONTRACT: TypedContract<RenderFocus> = TypedContract::new();
|
||||
const NAVIGATOR_CONTRACT: TypedContract<NavigatorParams> = TypedContract::new();
|
||||
const RENDER_LIVE_CONTRACT: TypedContract<RenderLive> = TypedContract::new();
|
||||
const BROWSER_CONTRACT: TypedContract<RenderBrowserRequest> = TypedContract::new();
|
||||
const TAB_STEP_CONTRACT: TypedContract<TabStepParams> = TypedContract::new();
|
||||
const STANDARD_VIEW_CONTRACT: TypedContract<StandardViewParams> = TypedContract::new();
|
||||
@@ -85,6 +86,10 @@ CommandSpec::new("view.render_browser", "Render Browser", crate::checked!("view.
|
||||
CommandSpec::new("view.render_inspector", "Render Inspector", render_inspector)
|
||||
.icon("inspect").noundo().doc_only()
|
||||
.params("visible: bool → {visible}; per-document Inspector visibility; never edits the design or opens a dialog"),
|
||||
CommandSpec::new("view.render_live", "Pause or Resume Live Render", crate::checked!("view.render_live", render_live))
|
||||
.icon("render_image").noundo().doc_only()
|
||||
.contract(&RENDER_LIVE_CONTRACT).output(output_schema_for::<RenderLive>)
|
||||
.params("paused: bool → {paused}; pause or resume this document's live Render preview; headless validation only, never saves settings or changes a final image"),
|
||||
CommandSpec::new("view.render_select", "Render selection", crate::checked!("view.render_select", render_select))
|
||||
.contract(&RENDER_SELECT).output(output_schema_for::<RenderSelect>)
|
||||
.icon("select").noundo().doc_only()
|
||||
@@ -346,6 +351,11 @@ fn render_focus(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!(request))
|
||||
}
|
||||
|
||||
fn render_live(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: RenderLive = serde_json::from_value(p.clone()).map_err(|e| bad("view.render_live", e.to_string()))?;
|
||||
Ok(json!(request))
|
||||
}
|
||||
|
||||
fn render_select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let request: RenderSelect = serde_json::from_value(p.clone()).map_err(|e| bad("view.render_select", e.to_string()))?;
|
||||
let result = serde_json::to_value(request).map_err(|e| bad("view.render_select", e.to_string()))?;
|
||||
@@ -1146,7 +1156,7 @@ fn render_workspace_focus_and_browser_validate_without_side_effects() {
|
||||
let camera = session.camera;
|
||||
let document = session.doc.clone();
|
||||
let before = (session.undo.len(), session.redo.len(), session.revision, session.evaluations, session.is_dirty());
|
||||
for (id, field) in [("view.render_focus", "on"), ("view.render_browser", "open")] {
|
||||
for (id, field) in [("view.render_focus", "on"), ("view.render_browser", "open"), ("view.render_live", "paused")] {
|
||||
let command = crate::find_command(id).unwrap();
|
||||
assert!(command.contract.is_some() && command.output.is_some());
|
||||
for on in [true, false] {
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod product_scene;
|
||||
pub mod recovery;
|
||||
pub mod render_jobs;
|
||||
pub mod render_lights;
|
||||
pub mod render_preparation;
|
||||
pub mod sample;
|
||||
pub mod schema;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -32,13 +32,6 @@ fn owner() -> u64 {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Execution {
|
||||
pub executor: solidcraft_render::ExecutorId,
|
||||
pub options: solidcraft_render::job::JobOptions,
|
||||
pub environment: Option<solidcraft_render::lighting::environment_input::EnvironmentInput>,
|
||||
pub shot: Option<String>,
|
||||
}
|
||||
|
||||
struct LiveJob {
|
||||
handle: JobHandle,
|
||||
shot: Option<String>,
|
||||
@@ -68,6 +61,9 @@ pub(crate) struct BatchShot {
|
||||
pub struct RenderQueueState {
|
||||
pub running: usize,
|
||||
pub waiting: usize,
|
||||
/// A real native preparation claim, separate from an invented running job.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub preparing: bool,
|
||||
pub job: Option<JobId>,
|
||||
pub shot: Option<String>,
|
||||
pub error: Option<String>,
|
||||
@@ -85,8 +81,10 @@ pub(crate) struct Jobs {
|
||||
completed: VecDeque<Completed>,
|
||||
selected: Option<solidcraft_render::job::JobId>,
|
||||
waiting: VecDeque<BatchShot>,
|
||||
batch_job: Option<JobId>,
|
||||
batch_error: Option<String>,
|
||||
pub(crate) batch_job: Option<JobId>,
|
||||
pub(crate) batch_error: Option<String>,
|
||||
pub(crate) native_batch: Option<crate::render_preparation::batch::NativeState>,
|
||||
pub(crate) native_epoch: u64,
|
||||
comparison: Option<[Option<JobId>; 2]>,
|
||||
}
|
||||
|
||||
@@ -106,6 +104,8 @@ impl Default for Jobs {
|
||||
waiting: VecDeque::new(),
|
||||
batch_job: None,
|
||||
batch_error: None,
|
||||
native_batch: None,
|
||||
native_epoch: 0,
|
||||
comparison: None,
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,9 @@ impl Default for Jobs {
|
||||
|
||||
impl Drop for Jobs {
|
||||
fn drop(&mut self) {
|
||||
if let Some(native) = &self.native_batch {
|
||||
native.cancel.cancel();
|
||||
}
|
||||
for job in &self.live {
|
||||
let _ = job.handle.cancel();
|
||||
}
|
||||
@@ -120,6 +123,9 @@ impl Drop for Jobs {
|
||||
}
|
||||
|
||||
impl Jobs {
|
||||
pub(crate) fn owner_key(&self) -> u64 {
|
||||
self.owner
|
||||
}
|
||||
pub(crate) fn set_comparison(&mut self, sides: Option<[Option<JobId>; 2]>) {
|
||||
self.comparison = sides;
|
||||
if let Some([left, right]) = sides {
|
||||
@@ -187,24 +193,66 @@ impl Jobs {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn start_with_environment(
|
||||
pub(crate) fn start_prepared(
|
||||
&mut self,
|
||||
revision: u64,
|
||||
scene: solidcraft_render::ProductScene,
|
||||
request: solidcraft_render::job::ImageRequest,
|
||||
prepared: solidcraft_render::job::PreparedSubmission,
|
||||
replace: bool,
|
||||
execution: Execution,
|
||||
shot: Option<String>,
|
||||
) -> Result<JobStatus, JobError> {
|
||||
let Execution { executor, options, environment, shot } = execution;
|
||||
if self.batch_job.is_some() {
|
||||
self.try_start_prepared(revision, &mut Some(prepared), replace, shot)
|
||||
}
|
||||
|
||||
pub(crate) fn try_start_prepared(
|
||||
&mut self,
|
||||
revision: u64,
|
||||
slot: &mut Option<solidcraft_render::job::PreparedSubmission>,
|
||||
replace: bool,
|
||||
shot: Option<String>,
|
||||
) -> Result<JobStatus, JobError> {
|
||||
self.try_start_prepared_inner(revision, slot, replace, shot, None)
|
||||
}
|
||||
pub(crate) fn try_start_prepared_inner(
|
||||
&mut self,
|
||||
revision: u64,
|
||||
slot: &mut Option<solidcraft_render::job::PreparedSubmission>,
|
||||
replace: bool,
|
||||
shot: Option<String>,
|
||||
ticket: Option<crate::render_preparation::batch::Ticket>,
|
||||
) -> Result<JobStatus, JobError> {
|
||||
let prepared = slot.as_ref().ok_or_else(|| JobError::new(ErrorCode::InvalidRequest, "prepared render was already consumed"))?;
|
||||
let batch_next = if let Some(ticket) = ticket {
|
||||
let native = self
|
||||
.native_batch
|
||||
.as_ref()
|
||||
.filter(|n| n.ticket == ticket && n.claimed && !n.cancel.is_cancelled())
|
||||
.ok_or_else(|| JobError::new(ErrorCode::Cancelled, "native shot queue claim is no longer current"))?;
|
||||
if replace || native.next >= native.total {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid native shot queue front"));
|
||||
}
|
||||
Some(native.next + 1)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if ticket.is_none() && (self.batch_job.is_some() || self.native_batch.is_some()) {
|
||||
if !replace {
|
||||
return Err(JobError::new(ErrorCode::QueueFull, "cancel the shot queue before starting another render"));
|
||||
}
|
||||
self.waiting.clear();
|
||||
self.batch_job = None;
|
||||
if let Some(native) = self.native_batch.take() {
|
||||
native.cancel.cancel();
|
||||
}
|
||||
}
|
||||
self.collect()?;
|
||||
let host = self.host_for(&executor).ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "resolved image host is not installed"))?.clone();
|
||||
let host = self
|
||||
.host_for(prepared.executor())
|
||||
.ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "resolved image host is not installed"))?
|
||||
.clone();
|
||||
if !host.same_host(prepared.host()) {
|
||||
return Err(JobError::new(ErrorCode::WrongOwner, "prepared render host changed before enqueue"));
|
||||
}
|
||||
prepared.check_cancel()?;
|
||||
if replace {
|
||||
for job in self.live.drain(..) {
|
||||
let mut status = job.handle.cancel()?;
|
||||
@@ -228,18 +276,18 @@ impl Jobs {
|
||||
}
|
||||
let serial = self.serial.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render job identity exhausted"))?;
|
||||
let generation = self.generation.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render generation exhausted"))?;
|
||||
let handle = host.submit_with_environment_and_options(
|
||||
JobId { session: self.owner, serial, generation },
|
||||
revision,
|
||||
scene,
|
||||
request,
|
||||
options,
|
||||
environment,
|
||||
)?;
|
||||
let handle = host.try_submit_prepared(JobId { session: self.owner, serial, generation }, revision, slot)?;
|
||||
let status = handle.status()?;
|
||||
self.serial = serial;
|
||||
self.generation = generation;
|
||||
self.live.push(LiveJob { handle, shot });
|
||||
if let Some(next) = batch_next {
|
||||
if let Some(native) = &mut self.native_batch {
|
||||
native.next = next;
|
||||
native.claimed = false;
|
||||
}
|
||||
self.batch_job = Some(status.job);
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
@@ -248,7 +296,7 @@ impl Jobs {
|
||||
source_id: JobId,
|
||||
presentation: solidcraft_render::job::presentation::Presentation,
|
||||
) -> Result<JobStatus, JobError> {
|
||||
if self.batch_job.is_some() {
|
||||
if self.batch_job.is_some() || self.native_batch.is_some() {
|
||||
return Err(JobError::new(ErrorCode::QueueFull, "cancel the shot queue before regrading"));
|
||||
}
|
||||
self.collect()?;
|
||||
@@ -294,6 +342,38 @@ impl Jobs {
|
||||
.ok_or_else(|| JobError::new(ErrorCode::UnknownJob, "render job is no longer retained"))
|
||||
}
|
||||
|
||||
/// Metadata-only identity validation for deferred native cancellation.
|
||||
/// Do not collect terminal work, clone a handle/frame or advance a queue on
|
||||
/// this read path. Actual cancellation still uses its ordinary handler.
|
||||
pub(crate) fn check_retained_or_historical_job(&self, id: JobId) -> Result<(), JobError> {
|
||||
self.check_owner(id)?;
|
||||
if self.live.iter().any(|job| job.handle.id() == id)
|
||||
|| self.completed.iter().any(|result| result.handle.id() == id)
|
||||
|| self.history.iter().any(|status| status.job == id)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(JobError::new(ErrorCode::UnknownJob, "render job is no longer retained"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot existing status without collecting or evicting registry owners.
|
||||
/// Native queue polling uses this path; ordinary status/result APIs retain
|
||||
/// their existing explicit collection semantics.
|
||||
pub(crate) fn peek_status(&self, id: JobId) -> Result<JobStatus, JobError> {
|
||||
self.check_owner(id)?;
|
||||
if let Some(handle) =
|
||||
self.live.iter().map(|job| &job.handle).chain(self.completed.iter().map(|result| &result.handle)).find(|handle| handle.id() == id)
|
||||
{
|
||||
return handle.status();
|
||||
}
|
||||
self.history
|
||||
.iter()
|
||||
.find(|status| status.job == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| JobError::new(ErrorCode::UnknownJob, "render job is no longer retained"))
|
||||
}
|
||||
|
||||
pub(crate) fn status(&mut self, id: JobId) -> Result<JobStatus, JobError> {
|
||||
self.check_owner(id)?;
|
||||
self.collect()?;
|
||||
@@ -348,7 +428,12 @@ impl crate::Session {
|
||||
let active = self.render_jobs.batch_job.and_then(|id| self.render_jobs.live.iter().find(|job| job.handle.id() == id));
|
||||
RenderQueueState {
|
||||
running: self.render_jobs.live.iter().filter(|job| job.handle.status().is_ok_and(|status| !status.progress.phase.terminal())).count(),
|
||||
waiting: self.render_jobs.waiting.len(),
|
||||
waiting: self
|
||||
.render_jobs
|
||||
.native_batch
|
||||
.as_ref()
|
||||
.map_or(self.render_jobs.waiting.len(), |n| n.total.saturating_sub(n.next + usize::from(n.claimed))),
|
||||
preparing: self.render_jobs.native_batch.as_ref().is_some_and(|n| n.claimed),
|
||||
job: self.render_jobs.batch_job,
|
||||
shot: active.and_then(|job| job.shot.clone()),
|
||||
error: self.render_jobs.batch_error.clone(),
|
||||
@@ -356,13 +441,29 @@ impl crate::Session {
|
||||
}
|
||||
|
||||
pub fn render_batch_ready(&self) -> std::result::Result<(), String> {
|
||||
self.render_batch_ready_with_claim(false)
|
||||
}
|
||||
|
||||
/// Recheck ordinary Batch enablement without treating this exact first
|
||||
/// preparation's own fixed claim as a competing queue.
|
||||
pub(crate) fn render_batch_ready_for_first(&self, ticket: crate::render_preparation::batch::Ticket) -> std::result::Result<(), String> {
|
||||
if !ticket.matches(self) || !self.render_jobs.native_batch.as_ref().is_some_and(|n| n.ticket == ticket && n.claimed && n.next == 0) {
|
||||
return Err("native first shot claim is no longer current".into());
|
||||
}
|
||||
self.render_batch_ready_with_claim(true)
|
||||
}
|
||||
|
||||
fn render_batch_ready_with_claim(&self, own_first_claim: bool) -> std::result::Result<(), String> {
|
||||
if self.doc.render.setups.is_empty() {
|
||||
return Err("no saved shots to render".into());
|
||||
}
|
||||
if self.doc.render.setups.len() > 32 {
|
||||
return Err("render all shots is limited to 32 shots".into());
|
||||
}
|
||||
if self.render_jobs.batch_job.is_some() || self.render_queue_state().running != 0 {
|
||||
if (!own_first_claim && self.render_jobs.native_batch.is_some())
|
||||
|| self.render_jobs.batch_job.is_some()
|
||||
|| self.render_queue_state().running != 0
|
||||
{
|
||||
return Err("a render or shot queue is already active".into());
|
||||
}
|
||||
Ok(())
|
||||
@@ -405,6 +506,42 @@ impl crate::Session {
|
||||
|
||||
/// Non-blocking queue scheduling. The existing host still owns all render work.
|
||||
pub fn advance_render_batch(&mut self) -> crate::Result<()> {
|
||||
// Native foreground/background status polling never captures a shot.
|
||||
if self.render_jobs.native_batch.is_some() {
|
||||
if self.render_jobs.native_batch.as_ref().is_some_and(|n| !n.ticket.matches(self)) {
|
||||
if let Some(native) = self.render_jobs.native_batch.take() {
|
||||
native.cancel.cancel();
|
||||
}
|
||||
self.render_jobs.batch_job = None;
|
||||
self.render_jobs.batch_error = Some("native shot queue belongs to a previous document owner".into());
|
||||
return Ok(());
|
||||
}
|
||||
if self.render_jobs.native_batch.as_ref().is_some_and(|n| n.cancel.is_cancelled()) {
|
||||
self.render_jobs.native_batch = None;
|
||||
self.render_jobs.batch_job = None;
|
||||
self.render_jobs.batch_error = Some("native shot queue was cancelled".into());
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(job) = self.render_jobs.batch_job {
|
||||
let status = self.render_jobs.peek_status(job).map_err(job_error)?;
|
||||
if status.progress.phase.terminal() {
|
||||
let failed = status.progress.phase != Phase::Completed;
|
||||
let finished = self.render_jobs.native_batch.as_ref().is_some_and(|n| n.next == n.total && !n.claimed);
|
||||
if failed || finished {
|
||||
if let Some(native) = self.render_jobs.native_batch.take()
|
||||
&& failed
|
||||
{
|
||||
native.cancel.cancel();
|
||||
}
|
||||
self.render_jobs.batch_job = None;
|
||||
if failed {
|
||||
self.render_jobs.batch_error = status.error.map(|e| e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let Some(job) = self.render_jobs.batch_job else { return Ok(()) };
|
||||
let status = self.render_jobs.status(job).map_err(job_error)?;
|
||||
if !status.progress.phase.terminal() {
|
||||
@@ -421,11 +558,25 @@ impl crate::Session {
|
||||
|
||||
pub(crate) fn cancel_render_batch(&mut self, job: JobId) {
|
||||
if self.render_jobs.batch_job == Some(job) {
|
||||
if let Some(native) = self.render_jobs.native_batch.take() {
|
||||
native.cancel.cancel();
|
||||
}
|
||||
self.render_jobs.waiting.clear();
|
||||
self.render_jobs.batch_job = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cancel_native_preparation(&mut self) -> bool {
|
||||
if self.render_jobs.batch_job.is_none() && self.render_jobs.native_batch.as_ref().is_some_and(|n| n.claimed) {
|
||||
if let Some(native) = self.render_jobs.native_batch.take() {
|
||||
native.cancel.cancel();
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Capture the UI draft's label alongside an unnamed active request, never in the design.
|
||||
pub fn label_render_job(&mut self, id: JobId, shot: &str) -> crate::Result<()> {
|
||||
solidcraft_doc::render_settings::RenderSettings::validate_name(shot).map_err(|error| crate::EngineError::Other(error.to_string()))?;
|
||||
@@ -594,4 +745,291 @@ mod displayed_result_tests {
|
||||
let reply = s.execute("render.start", &json!({"settings":{"width":8,"height":8}})).unwrap();
|
||||
assert!(s.execute("render.result.pin", &json!({"job":reply["job"]})).is_err(), "a pending result is never exportable");
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn first_native_plan(s: &mut crate::Session) -> (crate::render_preparation::batch::Plan, JobId) {
|
||||
use crate::render_preparation::batch;
|
||||
use solidcraft_render::job::CancelToken;
|
||||
let mut params = Some(json!({}));
|
||||
let claim = batch::claim(s, &mut params, &mut None, CancelToken::default()).unwrap();
|
||||
let (ready, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
let (returning, returned) = std::sync::mpsc::sync_channel(1);
|
||||
let thread = std::thread::spawn(move || {
|
||||
batch::with_scope(claim, |claim| {
|
||||
ready.send(batch::prepare(claim)).unwrap();
|
||||
drop(returned.recv().unwrap());
|
||||
});
|
||||
});
|
||||
let mut prepared = receiver.recv().unwrap();
|
||||
assert_eq!(batch::finish_retained(s, &mut prepared).unwrap(), json!({"queued":2}));
|
||||
let plan = batch::take_plan(&mut prepared).unwrap().unwrap();
|
||||
returning.send(prepared).unwrap();
|
||||
thread.join().unwrap();
|
||||
(plan, s.render_queue_state().job.unwrap())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn native_next_discard_uses_cached_status_but_hostile_missing_status_fails_only_its_exact_ticket() {
|
||||
use crate::render_preparation::batch;
|
||||
use solidcraft_render::job::CancelToken;
|
||||
for hostile_missing_status in [false, true] {
|
||||
let mut s = crate::Session::default();
|
||||
let host = CpuHost::cooperative();
|
||||
s.set_render_job_host(Some(host.clone())).unwrap();
|
||||
let old = complete(&mut s, [11, 23, 37]);
|
||||
let old_handle = s.render_job_handle(old).unwrap();
|
||||
let old_frame = old_handle.frame().unwrap().unwrap();
|
||||
let old_png = old_frame.png().to_vec();
|
||||
for name in ["A", "B"] {
|
||||
s.execute("render.settings.set", &json!({"name":name,"set":{"width":2,"height":1,"job":{"method":"raster"}}})).unwrap();
|
||||
}
|
||||
let (plan, first) = first_native_plan(&mut s);
|
||||
for _ in 0..10_000 {
|
||||
if s.render_job_handle(first).unwrap().status().unwrap().progress.phase.terminal() {
|
||||
break;
|
||||
}
|
||||
host.pump(4096).unwrap();
|
||||
}
|
||||
assert_eq!(s.render_job_handle(first).unwrap().status().unwrap().progress.phase, Phase::Completed);
|
||||
let retained = s.render_retained_results().unwrap();
|
||||
let index = retained.iter().position(|r| r.frame.job == first).unwrap();
|
||||
s.execute("render.result.discard", &json!({"index":index})).unwrap();
|
||||
assert!(s.render_job_handle(first).is_err());
|
||||
assert_eq!(
|
||||
s.render_jobs.status(first).unwrap().progress.phase,
|
||||
Phase::Completed,
|
||||
"ordinary Discard preserves the recent status cache and is not an UnknownJob reproducer"
|
||||
);
|
||||
if hostile_missing_status {
|
||||
// Defensive hostile registry-state injection, not an ordinary
|
||||
// UI Discard claim: public regrade/replace cannot rotate this
|
||||
// status while preserving the same native ticket.
|
||||
s.render_jobs.history.retain(|status| status.job != first);
|
||||
}
|
||||
let bytes = plan.retained_bytes();
|
||||
let held = host.admitted_bytes();
|
||||
let mut slot = Some(plan);
|
||||
let result = batch::claim_next(&mut s, &mut slot, CancelToken::default());
|
||||
if hostile_missing_status {
|
||||
let error = match result {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("missing current status must refuse"),
|
||||
};
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("render job is no longer retained"));
|
||||
assert_eq!(s.render_queue_state().error.as_deref(), Some(message.as_str()));
|
||||
assert_eq!((s.render_queue_state().waiting, s.render_queue_state().preparing), (0, false));
|
||||
assert!(s.render_queue_state().job.is_none());
|
||||
assert!(slot.is_some());
|
||||
assert_eq!(host.admitted_bytes(), held, "metadata failure cannot release the retained Plan backing");
|
||||
assert_eq!(old_frame.png(), old_png);
|
||||
assert_eq!(s.render_jobs.status(old).unwrap().progress.phase, Phase::Completed);
|
||||
let token = CancelToken::default();
|
||||
let replacement = batch::claim(&mut s, &mut Some(json!({})), &mut None, token.clone()).unwrap();
|
||||
let current = json!(s.render_queue_state());
|
||||
assert!(batch::claim_next(&mut s, &mut slot, CancelToken::default()).is_err());
|
||||
assert_eq!(json!(s.render_queue_state()), current, "stale Plan refusal cannot fail a replacement epoch");
|
||||
assert!(!token.is_cancelled());
|
||||
let before = host.admitted_bytes();
|
||||
let retired = slot.take().unwrap();
|
||||
std::thread::spawn(move || batch::retire_plan(retired)).join().unwrap();
|
||||
assert_eq!(host.admitted_bytes(), before - bytes, "only actual worker retirement releases Plan credit");
|
||||
batch::cancel(&mut s).unwrap();
|
||||
std::thread::spawn(move || batch::with_scope(replacement, drop)).join().unwrap();
|
||||
} else {
|
||||
let claim = result.unwrap().unwrap();
|
||||
assert!(slot.is_none());
|
||||
assert_eq!(s.render_queue_state().waiting, 0);
|
||||
assert!(s.render_queue_state().preparing);
|
||||
assert_eq!(host.admitted_bytes(), held);
|
||||
batch::cancel(&mut s).unwrap();
|
||||
std::thread::spawn(move || batch::with_scope(claim, drop)).join().unwrap();
|
||||
assert_eq!(host.admitted_bytes(), held - bytes);
|
||||
}
|
||||
assert_eq!(old_frame.png(), old_png);
|
||||
assert_eq!(old_handle.status().unwrap().progress.phase, Phase::Completed);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn immutable_native_cancel_identity_checks_live_completed_and_discarded_history_without_collecting_or_advancing() {
|
||||
use crate::render_preparation::batch;
|
||||
let mut s = crate::Session::default();
|
||||
let host = CpuHost::cooperative();
|
||||
s.set_render_job_host(Some(host.clone())).unwrap();
|
||||
for name in ["A", "B"] {
|
||||
s.execute("render.settings.set", &json!({"name":name,"set":{"width":2,"height":1,"job":{"method":"raster"}}})).unwrap();
|
||||
}
|
||||
let (plan, job) = first_native_plan(&mut s);
|
||||
let document = s.doc.clone();
|
||||
let model = s.model.state();
|
||||
let handle = s.render_job_handle(job).unwrap();
|
||||
let check = |s: &crate::Session| {
|
||||
let identities = || {
|
||||
(
|
||||
s.render_jobs.live.iter().map(|job| job.handle.id()).collect::<Vec<_>>(),
|
||||
s.render_jobs.completed.iter().map(|result| result.handle.id()).collect::<Vec<_>>(),
|
||||
s.render_jobs.history.iter().map(|status| status.job).collect::<Vec<_>>(),
|
||||
s.render_jobs.serial,
|
||||
s.render_jobs.generation,
|
||||
json!(s.render_queue_state()),
|
||||
)
|
||||
};
|
||||
let before = identities();
|
||||
let bytes = host.admitted_bytes();
|
||||
let owners = (Arc::strong_count(&document), Arc::strong_count(&model));
|
||||
let history = (s.revision, s.undo.len(), s.redo.len());
|
||||
assert_eq!(batch::check_cancel(s, &json!({"job":job})).unwrap(), Some(job));
|
||||
assert_eq!(batch::check_cancel(s, &json!({})).unwrap(), None);
|
||||
assert_eq!(identities(), before, "pure cancel validation cannot collect or advance a native shot front");
|
||||
assert_eq!(host.admitted_bytes(), bytes, "validation neither reserves nor drops renderer/plan backing");
|
||||
assert_eq!((Arc::strong_count(&document), Arc::strong_count(&model)), owners, "no source clone or retirement");
|
||||
assert!(Arc::ptr_eq(&s.doc, &document));
|
||||
assert!(Arc::ptr_eq(&s.model.state(), &model));
|
||||
assert_eq!((s.revision, s.undo.len(), s.redo.len()), history);
|
||||
};
|
||||
assert_eq!(handle.status().unwrap().progress.phase, Phase::Queued);
|
||||
check(&s);
|
||||
for _ in 0..10_000 {
|
||||
if !host.pump(4096).unwrap() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(handle.status().unwrap().progress.phase, Phase::Completed);
|
||||
assert_eq!((s.render_jobs.live.len(), s.render_jobs.completed.len(), s.render_jobs.history.len()), (1, 0, 0));
|
||||
check(&s);
|
||||
assert_eq!(
|
||||
(s.render_jobs.live.len(), s.render_jobs.completed.len(), s.render_jobs.history.len()),
|
||||
(1, 0, 0),
|
||||
"a completed-but-uncollected handle remains in live metadata"
|
||||
);
|
||||
s.render_jobs.collect().unwrap();
|
||||
assert_eq!((s.render_jobs.live.len(), s.render_jobs.completed.len(), s.render_jobs.history.len()), (0, 1, 1));
|
||||
check(&s);
|
||||
let frame = handle.frame().unwrap().unwrap();
|
||||
let png = frame.png().to_vec();
|
||||
s.execute("render.result.discard", &json!({"index":0})).unwrap();
|
||||
assert_eq!((s.render_jobs.live.len(), s.render_jobs.completed.len(), s.render_jobs.history.len()), (0, 0, 1));
|
||||
assert!(s.render_job_handle(job).is_err());
|
||||
check(&s);
|
||||
assert_eq!(frame.png(), png, "checking an actual discarded result uses history, not a replacement image");
|
||||
let unknown = JobId { serial: u64::MAX, generation: u64::MAX, ..job };
|
||||
let foreign = JobId { session: job.session + 1, ..job };
|
||||
for (id, code) in [(unknown, ErrorCode::UnknownJob), (foreign, ErrorCode::WrongOwner)] {
|
||||
let before = json!(s.render_queue_state());
|
||||
let bytes = host.admitted_bytes();
|
||||
assert_eq!(s.render_jobs.check_retained_or_historical_job(id).unwrap_err().code, code);
|
||||
assert!(batch::check_cancel(&s, &json!({"job":id})).is_err());
|
||||
assert_eq!(json!(s.render_queue_state()), before);
|
||||
assert_eq!(host.admitted_bytes(), bytes);
|
||||
}
|
||||
assert_eq!((s.render_queue_state().waiting, s.render_queue_state().preparing), (1, false));
|
||||
let held = host.admitted_bytes();
|
||||
let plan_bytes = plan.retained_bytes();
|
||||
assert!(batch::cancel(&mut s).unwrap(), "terminal history is sufficient to cancel the remaining native queue");
|
||||
assert_eq!((s.render_queue_state().waiting, s.render_queue_state().preparing, s.render_queue_state().job), (0, false, None));
|
||||
assert_eq!((s.render_jobs.live.len(), s.render_jobs.completed.len(), s.render_jobs.history.len()), (0, 0, 1));
|
||||
assert_eq!(host.admitted_bytes(), held, "metadata cancellation retains the Plan lease until its worker retires it");
|
||||
assert_eq!(frame.png(), png);
|
||||
assert!(Arc::ptr_eq(&s.doc, &document) && Arc::ptr_eq(&s.model.state(), &model));
|
||||
std::thread::spawn(move || batch::retire_plan(plan)).join().unwrap();
|
||||
assert_eq!(host.admitted_bytes(), held - plan_bytes);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn native_queue_status_and_next_claim_keep_eight_results_and_terminal_live_backing_until_explicit_collection() {
|
||||
use crate::render_preparation::batch;
|
||||
use solidcraft_render::job::CancelToken;
|
||||
for cancelled in [false, true] {
|
||||
let mut s = crate::Session::default();
|
||||
let host = CpuHost::cooperative();
|
||||
s.set_render_job_host(Some(host.clone())).unwrap();
|
||||
let mut old_ids = Vec::new();
|
||||
let mut oldest_frame = None;
|
||||
for index in 0..8 {
|
||||
let id = complete(&mut s, [index, 31, 63]);
|
||||
s.render_retained_results().unwrap();
|
||||
old_ids.push(id);
|
||||
if index == 0 {
|
||||
oldest_frame = Some(Arc::downgrade(&s.render_job_handle(id).unwrap().frame().unwrap().unwrap()));
|
||||
}
|
||||
}
|
||||
let oldest_frame = oldest_frame.unwrap();
|
||||
assert_eq!(s.render_jobs.completed.len(), 8);
|
||||
for name in ["A", "B"] {
|
||||
s.execute("render.settings.set", &json!({"name":name,"set":{"width":2,"height":1,"job":{"method":"raster"}}})).unwrap();
|
||||
}
|
||||
let (plan, job) = first_native_plan(&mut s);
|
||||
let handle = s.render_job_handle(job).unwrap();
|
||||
if cancelled {
|
||||
assert_eq!(handle.cancel().unwrap().progress.phase, Phase::Cancelled);
|
||||
} else {
|
||||
for _ in 0..10_000 {
|
||||
if !host.pump(4096).unwrap() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(handle.status().unwrap().progress.phase, Phase::Completed);
|
||||
}
|
||||
let actual = handle.status().unwrap();
|
||||
let peek = s.render_jobs.peek_status(job).unwrap();
|
||||
assert_eq!((peek.job, peek.progress.phase), (job, actual.progress.phase));
|
||||
assert_eq!(peek.error.as_ref().map(|e| (e.code, e.message.as_str())), actual.error.as_ref().map(|e| (e.code, e.message.as_str())));
|
||||
let foreign = JobId { session: job.session + 1, ..job };
|
||||
let unknown = JobId { serial: u64::MAX, generation: u64::MAX, ..job };
|
||||
assert_eq!(s.render_jobs.peek_status(foreign).unwrap_err().code, ErrorCode::WrongOwner);
|
||||
assert_eq!(s.render_jobs.peek_status(unknown).unwrap_err().code, ErrorCode::UnknownJob);
|
||||
let held = host.admitted_bytes();
|
||||
let membership = |s: &crate::Session| {
|
||||
(
|
||||
s.render_jobs.live.iter().map(|j| j.handle.id()).collect::<Vec<_>>(),
|
||||
s.render_jobs.completed.iter().map(|j| j.handle.id()).collect::<Vec<_>>(),
|
||||
s.render_jobs.history.iter().map(|j| j.job).collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
let before = membership(&s);
|
||||
assert_eq!(before.0, vec![job]);
|
||||
assert_eq!(before.1, old_ids);
|
||||
assert!(oldest_frame.upgrade().is_some());
|
||||
s.advance_render_batch().unwrap();
|
||||
assert_eq!(membership(&s), before, "native status polling cannot collect a terminal live job or evict result0");
|
||||
assert_eq!(host.admitted_bytes(), held);
|
||||
assert!(oldest_frame.upgrade().is_some());
|
||||
let mut slot = Some(plan);
|
||||
let claim = batch::claim_next(&mut s, &mut slot, CancelToken::default());
|
||||
assert_eq!(membership(&s), before, "metadata-only next-front claim cannot drive ordinary result retention");
|
||||
assert_eq!(host.admitted_bytes(), held);
|
||||
assert!(oldest_frame.upgrade().is_some());
|
||||
if cancelled {
|
||||
assert!(claim.is_err());
|
||||
assert!(slot.is_some());
|
||||
assert_eq!((s.render_queue_state().waiting, s.render_queue_state().preparing), (0, false));
|
||||
assert!(s.render_queue_state().job.is_none());
|
||||
assert_eq!(s.render_queue_state().error.as_deref(), Some("native shot queue was cancelled"));
|
||||
let bytes = slot.as_ref().unwrap().retained_bytes();
|
||||
let retired = slot.take().unwrap();
|
||||
std::thread::spawn(move || batch::retire_plan(retired)).join().unwrap();
|
||||
assert_eq!(host.admitted_bytes(), held - bytes);
|
||||
let rows = s.render_retained_results().unwrap();
|
||||
assert_eq!(rows.iter().map(|r| r.frame.job).collect::<Vec<_>>(), old_ids);
|
||||
assert!(s.render_jobs.live.is_empty());
|
||||
assert!(oldest_frame.upgrade().is_some());
|
||||
} else {
|
||||
let claim = claim.unwrap().unwrap();
|
||||
assert!(slot.is_none());
|
||||
assert_eq!((s.render_queue_state().waiting, s.render_queue_state().preparing), (0, true));
|
||||
assert_eq!(s.render_queue_state().job, Some(job), "keep the last real job while preparing the next camera");
|
||||
// Ordinary explicit result collection still applies its existing8-row policy.
|
||||
let rows = s.render_retained_results().unwrap();
|
||||
let mut expected = old_ids[1..].to_vec();
|
||||
expected.push(job);
|
||||
assert_eq!(rows.iter().map(|r| r.frame.job).collect::<Vec<_>>(), expected);
|
||||
assert!(s.render_jobs.live.is_empty());
|
||||
assert!(oldest_frame.upgrade().is_none(), "only explicit ordinary collection evicts the oldest completed frame");
|
||||
assert!(host.admitted_bytes() < held);
|
||||
batch::cancel(&mut s).unwrap();
|
||||
std::thread::spawn(move || batch::with_scope(claim, drop)).join().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ use solidcore_record::Vec3;
|
||||
use solidcraft_doc::render_settings::{RenderJob, RenderSetup};
|
||||
use solidcraft_geom::prelude::*;
|
||||
use solidcraft_render::job::LightingPreset;
|
||||
use solidcraft_render::job::{AreaLight, AreaLights, DirectionalLightOptions, PunctualLightOptions, RectangleLightOptions, SpotConeOptions, Sun};
|
||||
use solidcraft_render::job::{
|
||||
AreaLight, AreaLights, DirectionalLightOptions, DirectionalLights, PunctualLightOptions, PunctualLights, RectangleLightOptions, SpotConeOptions,
|
||||
Sun,
|
||||
};
|
||||
|
||||
pub fn parse_id(id: &str) -> Result<(&str, usize), String> {
|
||||
if id.len() > 16 {
|
||||
@@ -82,6 +85,8 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
|
||||
(edit.height_mm, 0.000002, 2e6),
|
||||
(edit.reference_distance_mm, 0.001, 1e6),
|
||||
(edit.minimum_distance_mm, 0.001, 1e6),
|
||||
(edit.inner_half_angle_radians, 0.0, std::f64::consts::FRAC_PI_2),
|
||||
(edit.outer_half_angle_radians, 0.0, std::f64::consts::FRAC_PI_2),
|
||||
] {
|
||||
if value.is_some_and(|v| !v.is_finite() || !(low..=high).contains(&v)) {
|
||||
return Err("light edit exceeds finite numerical bounds".into());
|
||||
@@ -90,6 +95,7 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
|
||||
for (value, low, high) in [
|
||||
(edit.position_mm, -1e12, 1e12),
|
||||
(edit.direction, -1e6, 1e6),
|
||||
(edit.emitted_axis, -1e9, 1e9),
|
||||
(edit.edge_u_mm, -2e6, 2e6),
|
||||
(edit.edge_v_mm, -2e6, 2e6),
|
||||
(edit.radiance, 0.0, 1e8),
|
||||
@@ -116,15 +122,25 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
|
||||
Geometry::Area { edge_u_mm, .. } => vector(edge_u_mm).len(),
|
||||
_ => 10.0,
|
||||
};
|
||||
let old_punctual = match light.geometry {
|
||||
Geometry::Point { position_mm, reference_distance_mm, minimum_distance_mm }
|
||||
| Geometry::Spot { position_mm, reference_distance_mm, minimum_distance_mm, .. } => {
|
||||
Some((position_mm, reference_distance_mm, minimum_distance_mm))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let (punctual_position, punctual_reference, punctual_minimum) = old_punctual.unwrap_or((array(point), distance, 0.001));
|
||||
let type_changed = edit.kind.as_ref().is_some_and(|kind| kind != light.geometry.kind());
|
||||
if let Some(kind) = edit.kind.as_ref().filter(|_| type_changed) {
|
||||
light.geometry = match kind.as_str() {
|
||||
"directional" => Geometry::Directional { direction: array(direction), angular_radius_rad: None },
|
||||
"point" => Geometry::Point { position_mm: array(point), reference_distance_mm: distance, minimum_distance_mm: 0.001 },
|
||||
"point" => {
|
||||
Geometry::Point { position_mm: punctual_position, reference_distance_mm: punctual_reference, minimum_distance_mm: punctual_minimum }
|
||||
}
|
||||
"spot" => Geometry::Spot {
|
||||
position_mm: array(point),
|
||||
reference_distance_mm: distance,
|
||||
minimum_distance_mm: 0.001,
|
||||
position_mm: punctual_position,
|
||||
reference_distance_mm: punctual_reference,
|
||||
minimum_distance_mm: punctual_minimum,
|
||||
emitted_axis: array(-direction),
|
||||
inner_half_angle_radians: 0.2,
|
||||
outer_half_angle_radians: 0.5,
|
||||
@@ -144,7 +160,7 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
|
||||
}
|
||||
if let Some(rgb) = edit.radiance {
|
||||
if !matches!(light.geometry, Geometry::Area { .. } | Geometry::Directional { angular_radius_rad: Some(_), .. }) {
|
||||
return Err("radiance requires an area light or GPU sun".into());
|
||||
return Err("radiance requires an area light or finite Sun".into());
|
||||
}
|
||||
(light.intensity, light.colour) = radiance(rgb);
|
||||
}
|
||||
@@ -161,6 +177,14 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
|
||||
if !matches!(light.geometry, Geometry::Directional { .. }) && edit.direction.is_some() {
|
||||
return Err("direction requires a directional light".into());
|
||||
}
|
||||
if !matches!(light.geometry, Geometry::Spot { .. })
|
||||
&& (edit.emitted_axis.is_some() || edit.inner_half_angle_radians.is_some() || edit.outer_half_angle_radians.is_some())
|
||||
{
|
||||
return Err("cone angles and emitted axis require a spot light".into());
|
||||
}
|
||||
if edit.emitted_axis.is_some() && edit.aim_at.is_some() {
|
||||
return Err("choose a spot emitted axis or an aim point, not both".into());
|
||||
}
|
||||
let moved = type_changed || edit.azimuth_deg.is_some() || edit.elevation_deg.is_some() || edit.distance_mm.is_some() || edit.aim_at.is_some();
|
||||
match &mut light.geometry {
|
||||
Geometry::Directional { direction: axis, angular_radius_rad } => {
|
||||
@@ -182,7 +206,14 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
|
||||
if edit.softness.is_some() || edit.size_mm.is_some() {
|
||||
return Err("point/spot lights have no finite emitter size or soft shadows".into());
|
||||
}
|
||||
if moved {
|
||||
if moved
|
||||
&& !(type_changed
|
||||
&& old_punctual.is_some()
|
||||
&& edit.azimuth_deg.is_none()
|
||||
&& edit.elevation_deg.is_none()
|
||||
&& edit.distance_mm.is_none()
|
||||
&& edit.aim_at.is_none())
|
||||
{
|
||||
*position_mm = array(point);
|
||||
}
|
||||
if let Some(position) = edit.position_mm {
|
||||
@@ -236,9 +267,62 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
|
||||
{
|
||||
*emitted_axis = array(solidcraft_render::surface::unit_direction(vector(aim) - vector(*position_mm)).unwrap_or(-Vec3::Z));
|
||||
}
|
||||
if let Geometry::Spot { emitted_axis, inner_half_angle_radians, outer_half_angle_radians, .. } = &mut light.geometry {
|
||||
if let Some(axis) = edit.emitted_axis {
|
||||
*emitted_axis = axis;
|
||||
}
|
||||
if let Some(inner) = edit.inner_half_angle_radians {
|
||||
*inner_half_angle_radians = inner;
|
||||
}
|
||||
if let Some(outer) = edit.outer_half_angle_radians {
|
||||
*outer_half_angle_radians = outer;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn same_vector_bits(a: [f64; 3], b: [f64; 3]) -> bool {
|
||||
a.into_iter().zip(b).all(|(a, b)| a.to_bits() == b.to_bits())
|
||||
}
|
||||
|
||||
// IDs refer to the original family slots until write publishes the new order.
|
||||
// Preserve an untouched authored source's raw radiance rather than performing an
|
||||
// unrelated divide/multiply round trip through the common intensity/colour UI.
|
||||
fn original_sun(lighting: solidcraft_render::job::StudioLighting, light: &Light) -> Option<Sun> {
|
||||
original_sun_value(lighting.sun?, light)
|
||||
}
|
||||
fn original_sun_value(original: Sun, light: &Light) -> Option<Sun> {
|
||||
if light.id != "directional:0" {
|
||||
return None;
|
||||
}
|
||||
let Geometry::Directional { direction, angular_radius_rad: Some(radius) } = light.geometry else {
|
||||
return None;
|
||||
};
|
||||
let (intensity, colour) = radiance(original.radiance);
|
||||
(same_vector_bits(direction, original.direction)
|
||||
&& radius.to_bits() == original.angular_radius_rad.to_bits()
|
||||
&& light.intensity.to_bits() == intensity.to_bits()
|
||||
&& same_vector_bits(light.colour, colour))
|
||||
.then_some(original)
|
||||
}
|
||||
fn original_area(lighting: solidcraft_render::job::StudioLighting, light: &Light) -> Option<AreaLight> {
|
||||
let (kind, index) = parse_id(&light.id).ok()?;
|
||||
if kind != "area" {
|
||||
return None;
|
||||
}
|
||||
let original = *lighting.area_lights.iter().nth(index)?;
|
||||
let Geometry::Area { position_mm, edge_u_mm, edge_v_mm } = light.geometry else {
|
||||
return None;
|
||||
};
|
||||
let (intensity, colour) = radiance(original.radiance);
|
||||
(same_vector_bits(position_mm, original.center)
|
||||
&& same_vector_bits(edge_u_mm, original.u.map(|x| x * 2.0))
|
||||
&& same_vector_bits(edge_v_mm, original.v.map(|x| x * 2.0))
|
||||
&& light.intensity.to_bits() == intensity.to_bits()
|
||||
&& same_vector_bits(light.colour, colour))
|
||||
.then_some(original)
|
||||
}
|
||||
|
||||
/// Materializes a canonical rig in a cloned setup. Caller validates and publishes
|
||||
/// atomically. Unsupported backend families are refused, never silently dropped.
|
||||
pub fn write(setup: &mut RenderSetup, lights: &[Light]) -> Result<(), String> {
|
||||
@@ -252,23 +336,28 @@ pub fn write(setup: &mut RenderSetup, lights: &[Light]) -> Result<(), String> {
|
||||
return Err("light family limit exceeded (8 directional, 8 punctual, 4 area)".into());
|
||||
}
|
||||
match setup.job.as_mut() {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
if directional.iter().any(|light| matches!(light.geometry, Geometry::Directional { angular_radius_rad: Some(_), .. })) {
|
||||
return Err("CPU directional lights have hard shadows; softness is not supported".into());
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
let mut sun = None;
|
||||
let mut hard = Vec::new();
|
||||
for light in directional {
|
||||
if let Geometry::Directional { direction, angular_radius_rad } = light.geometry {
|
||||
if let Some(angular_radius_rad) = angular_radius_rad {
|
||||
if sun.is_some() {
|
||||
return Err("lighting supports one finite Sun; use hard directionals for additional distant sources".into());
|
||||
}
|
||||
sun = Some(options.sun.and_then(|sun| original_sun_value(sun, light)).unwrap_or(Sun {
|
||||
direction,
|
||||
angular_radius_rad,
|
||||
radiance: light.colour.map(|value| value * light.intensity),
|
||||
}));
|
||||
} else {
|
||||
hard.push(DirectionalLightOptions { direction, color: light.colour, intensity: light.intensity });
|
||||
}
|
||||
}
|
||||
}
|
||||
options.sun = sun;
|
||||
options.preset = None;
|
||||
options.lights = Some(
|
||||
directional
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let direction = match l.geometry {
|
||||
Geometry::Directional { direction, .. } => direction,
|
||||
_ => [0.0, 0.0, 1.0],
|
||||
};
|
||||
DirectionalLightOptions { direction, color: l.colour, intensity: l.intensity }
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
options.lights = Some(hard);
|
||||
options.punctual_lights = Some(
|
||||
punctual
|
||||
.into_iter()
|
||||
@@ -319,25 +408,69 @@ pub fn write(setup: &mut RenderSetup, lights: &[Light]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
Some(RenderJob::GpuPath { options, .. }) => {
|
||||
if !punctual.is_empty() || directional.len() > 1 {
|
||||
return Err("GPU lighting supports one Sun and four Areas; choose an explicit studio before editing legacy default lights".into());
|
||||
}
|
||||
let mut lighting = options.lighting.unwrap_or_default();
|
||||
lighting.sun = directional.first().and_then(|l| match l.geometry {
|
||||
Geometry::Directional { direction, angular_radius_rad } => {
|
||||
Some(Sun { direction, angular_radius_rad: angular_radius_rad.unwrap_or(0.02), radiance: l.colour.map(|c| c * l.intensity) })
|
||||
let mut sun = None;
|
||||
let mut hard = Vec::new();
|
||||
for light in directional {
|
||||
if let Geometry::Directional { direction, angular_radius_rad } = light.geometry {
|
||||
if let Some(angular_radius_rad) = angular_radius_rad {
|
||||
if sun.is_some() {
|
||||
return Err("GPU lighting supports one finite Sun; use hard directional lights for additional distant sources".into());
|
||||
}
|
||||
sun = Some(original_sun(lighting, light).unwrap_or(Sun {
|
||||
direction,
|
||||
angular_radius_rad,
|
||||
radiance: light.colour.map(|c| c * light.intensity),
|
||||
}));
|
||||
} else {
|
||||
hard.push(DirectionalLightOptions { direction, color: light.colour, intensity: light.intensity });
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
}
|
||||
let punctual: Vec<_> = punctual
|
||||
.into_iter()
|
||||
.filter_map(|light| {
|
||||
let (position_mm, reference_distance_mm, minimum_distance_mm, cone) = match light.geometry {
|
||||
Geometry::Point { position_mm, reference_distance_mm, minimum_distance_mm } => {
|
||||
(position_mm, reference_distance_mm, minimum_distance_mm, None)
|
||||
}
|
||||
Geometry::Spot {
|
||||
position_mm,
|
||||
reference_distance_mm,
|
||||
minimum_distance_mm,
|
||||
emitted_axis,
|
||||
inner_half_angle_radians,
|
||||
outer_half_angle_radians,
|
||||
} => (
|
||||
position_mm,
|
||||
reference_distance_mm,
|
||||
minimum_distance_mm,
|
||||
Some(SpotConeOptions { emitted_axis, inner_half_angle_radians, outer_half_angle_radians }),
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
Some(PunctualLightOptions {
|
||||
position_mm,
|
||||
reference_distance_mm,
|
||||
minimum_distance_mm,
|
||||
cone,
|
||||
color: light.colour,
|
||||
intensity: light.intensity,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
lighting.punctual_lights = PunctualLights::new(&punctual).map_err(|e| e.to_string())?;
|
||||
lighting.sun = sun;
|
||||
lighting.directional_lights = DirectionalLights::new(&hard).map_err(|e| e.to_string())?;
|
||||
let areas: Vec<_> = area
|
||||
.into_iter()
|
||||
.filter_map(|l| match l.geometry {
|
||||
Geometry::Area { position_mm, edge_u_mm, edge_v_mm } => Some(AreaLight {
|
||||
Geometry::Area { position_mm, edge_u_mm, edge_v_mm } => Some(original_area(lighting, l).unwrap_or(AreaLight {
|
||||
center: position_mm,
|
||||
u: edge_u_mm.map(|v| v * 0.5),
|
||||
v: edge_v_mm.map(|v| v * 0.5),
|
||||
radiance: l.colour.map(|c| c * l.intensity),
|
||||
}),
|
||||
})),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -381,7 +514,7 @@ pub fn inspect(setup: &RenderSetup) -> Result<Vec<Light>, String> {
|
||||
setup.validate().map_err(|e| e.to_string())?;
|
||||
let mut lights = Vec::new();
|
||||
match &setup.job {
|
||||
Some(RenderJob::CpuRaytrace { options }) => {
|
||||
Some(RenderJob::CpuRaytrace { options, .. }) => {
|
||||
let implicit = options.lights.is_none();
|
||||
let defaults;
|
||||
let directional = match &options.lights {
|
||||
@@ -391,10 +524,21 @@ pub fn inspect(setup: &RenderSetup) -> Result<Vec<Light>, String> {
|
||||
&defaults
|
||||
}
|
||||
};
|
||||
if let Some(sun) = options.sun {
|
||||
let (intensity, colour) = radiance(sun.radiance);
|
||||
push(
|
||||
&mut lights,
|
||||
0,
|
||||
false,
|
||||
intensity,
|
||||
colour,
|
||||
Geometry::Directional { direction: sun.direction, angular_radius_rad: Some(sun.angular_radius_rad) },
|
||||
);
|
||||
}
|
||||
for (index, light) in directional.iter().enumerate() {
|
||||
push(
|
||||
&mut lights,
|
||||
index,
|
||||
index + usize::from(options.sun.is_some()),
|
||||
implicit,
|
||||
light.intensity,
|
||||
light.color,
|
||||
@@ -443,6 +587,34 @@ pub fn inspect(setup: &RenderSetup) -> Result<Vec<Light>, String> {
|
||||
Geometry::Directional { direction: sun.direction, angular_radius_rad: Some(sun.angular_radius_rad) },
|
||||
);
|
||||
}
|
||||
for (index, light) in lighting.directional_lights.iter().enumerate() {
|
||||
push(
|
||||
&mut lights,
|
||||
index + usize::from(lighting.sun.is_some()),
|
||||
false,
|
||||
light.intensity,
|
||||
light.color,
|
||||
Geometry::Directional { direction: light.direction, angular_radius_rad: None },
|
||||
);
|
||||
}
|
||||
for (index, light) in lighting.punctual_lights.iter().enumerate() {
|
||||
let geometry = match light.cone {
|
||||
Some(cone) => Geometry::Spot {
|
||||
position_mm: light.position_mm,
|
||||
reference_distance_mm: light.reference_distance_mm,
|
||||
minimum_distance_mm: light.minimum_distance_mm,
|
||||
emitted_axis: cone.emitted_axis,
|
||||
inner_half_angle_radians: cone.inner_half_angle_radians,
|
||||
outer_half_angle_radians: cone.outer_half_angle_radians,
|
||||
},
|
||||
None => Geometry::Point {
|
||||
position_mm: light.position_mm,
|
||||
reference_distance_mm: light.reference_distance_mm,
|
||||
minimum_distance_mm: light.minimum_distance_mm,
|
||||
},
|
||||
};
|
||||
push(&mut lights, index, false, light.intensity, light.color, geometry);
|
||||
}
|
||||
for (index, light) in lighting.area_lights.iter().enumerate() {
|
||||
let (intensity, colour) = radiance(light.radiance);
|
||||
push(
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Native UI preparation of the same strict Check/Start commands beside the
|
||||
//! window. No job starts in the scratch owner; commit uses the real registry.
|
||||
pub mod batch;
|
||||
pub(crate) mod batch_payload;
|
||||
|
||||
use crate::{Result, Session, cmd::PreparedCapture};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_render::job::CancelToken;
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Check,
|
||||
Start,
|
||||
}
|
||||
impl Kind {
|
||||
fn command(self) -> &'static str {
|
||||
match self {
|
||||
Self::Check => "render.preflight",
|
||||
Self::Start => "render.start",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move-only, fully proved capture. The already-created scratch owner provides
|
||||
/// exact source provenance without a second CAD snapshot or a cache-pointer guess.
|
||||
/// Renderer resources/leases drop before that temporary CAD owner.
|
||||
pub struct Prepared {
|
||||
capture: PreparedCapture,
|
||||
source: Session,
|
||||
kind: Kind,
|
||||
shot: Option<String>,
|
||||
params: Value,
|
||||
completed: bool,
|
||||
}
|
||||
impl Prepared {
|
||||
pub fn kind(&self) -> Kind {
|
||||
self.kind
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes Session::scratch, not the live window Session. The caller carries its
|
||||
/// VFS scope and cancellation token onto its native preparation worker.
|
||||
pub fn prepare(snapshot: Session, kind: Kind, params: Value, cancel: CancelToken) -> Result<Prepared> {
|
||||
prepare_with_metadata(snapshot, kind, params, cancel, None)
|
||||
}
|
||||
fn prepare_with_metadata(
|
||||
snapshot: Session,
|
||||
kind: Kind,
|
||||
params: Value,
|
||||
cancel: CancelToken,
|
||||
retained: Option<&solidcraft_render::job::RetainedMetadata>,
|
||||
) -> Result<Prepared> {
|
||||
cancel.check().map_err(crate::render_jobs::job_error)?;
|
||||
let params = if params.is_null() { Value::Object(Default::default()) } else { params };
|
||||
validate_command(&snapshot, kind, ¶ms)?;
|
||||
let shot = params.get("setup").and_then(Value::as_str).map(str::to_owned);
|
||||
let capture = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
crate::cmd::prepare_capture_with_metadata(&snapshot, ¶ms, kind.command(), &cancel, retained)
|
||||
})) {
|
||||
Ok(result) => result?,
|
||||
Err(cause) => {
|
||||
let message =
|
||||
cause.downcast_ref::<&str>().map(|value| value.to_string()).or_else(|| cause.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
return Err(crate::EngineError::Internal(kind.command().into(), message));
|
||||
}
|
||||
};
|
||||
submission(&capture)?.check_cancel().map_err(crate::render_jobs::job_error)?;
|
||||
Ok(Prepared { capture, source: snapshot, kind, shot, params, completed: false })
|
||||
}
|
||||
|
||||
// Keep the existing engine command enablement and embedding-host refusal.
|
||||
// UI/control schema Off/Warn/Strict remains its ordinary dispatch policy.
|
||||
fn validate_command(session: &Session, kind: Kind, params: &Value) -> Result<()> {
|
||||
validate_command_id(session, kind.command(), params)
|
||||
}
|
||||
fn validate_command_id(session: &Session, id: &str, params: &Value) -> Result<()> {
|
||||
let spec = crate::find_command(id).ok_or_else(|| crate::EngineError::UnknownCommand(id.into()))?;
|
||||
(spec.enabled)(session).map_err(|why| crate::EngineError::Disabled(spec.id.into(), why))?;
|
||||
session.check_command(spec, params)
|
||||
}
|
||||
|
||||
fn submission(capture: &PreparedCapture) -> Result<&solidcraft_render::job::PreparedSubmission> {
|
||||
capture.submission.as_ref().ok_or_else(|| crate::EngineError::Other("prepared render was already consumed".into()))
|
||||
}
|
||||
|
||||
fn same_moves(a: &BTreeMap<u64, solidcraft_doc::Mat>, b: &BTreeMap<u64, solidcraft_doc::Mat>) -> bool {
|
||||
a.len() == b.len()
|
||||
&& a.iter().zip(b).all(|((ka, a), (kb, b))| ka == kb && a.iter().flatten().zip(b.iter().flatten()).all(|(a, b)| a.to_bits() == b.to_bits()))
|
||||
}
|
||||
fn camera_bits(camera: Option<solidcraft_render::Camera>) -> Option<(solidcraft_render::UpAxis, [u64; 8])> {
|
||||
camera.map(|camera| {
|
||||
(
|
||||
camera.up,
|
||||
[camera.target.x, camera.target.y, camera.target.z, camera.yaw, camera.pitch, camera.roll, camera.distance, camera.fov].map(f64::to_bits),
|
||||
)
|
||||
})
|
||||
}
|
||||
fn current(source: &Session, session: &Session) -> bool {
|
||||
source.uid == session.uid
|
||||
&& source.revision == session.revision
|
||||
&& Arc::ptr_eq(&source.doc, &session.doc)
|
||||
&& Arc::ptr_eq(&source.model.state(), &session.model.state())
|
||||
&& !session.model.is_stale()
|
||||
&& source.active_sketch == session.active_sketch
|
||||
&& source.feature_edit_sketch == session.feature_edit_sketch
|
||||
&& source.active_component == session.active_component
|
||||
&& source.active_occurrence_path == session.active_occurrence_path
|
||||
&& source.visibility == session.visibility
|
||||
&& same_moves(&source.pending_moves, &session.pending_moves)
|
||||
&& same_moves(&source.exploded, &session.exploded)
|
||||
&& camera_bits(source.camera) == camera_bits(session.camera)
|
||||
}
|
||||
|
||||
/// Strict synchronous owner wrapper. Native UI completion uses finish_retained
|
||||
/// and returns the retained owner to its preparation worker for disposal.
|
||||
pub fn finish(session: &mut Session, mut prepared: Prepared) -> Result<Value> {
|
||||
finish_retained(session, &mut prepared)
|
||||
}
|
||||
|
||||
/// Revalidate and commit without destroying the large captured source on the
|
||||
/// caller. Every error retains the capsule and its leases; success retains its
|
||||
/// scratch source. The native caller returns this owner to the same worker and
|
||||
/// waits for retirement acknowledgement before reporting resources released.
|
||||
pub fn finish_retained(session: &mut Session, prepared: &mut Prepared) -> Result<Value> {
|
||||
finish_inner(session, prepared, None)
|
||||
}
|
||||
fn finish_inner(session: &mut Session, prepared: &mut Prepared, ticket: Option<batch::Ticket>) -> Result<Value> {
|
||||
if prepared.completed {
|
||||
return Err(crate::EngineError::Other("prepared render was already completed".into()));
|
||||
}
|
||||
if !current(&prepared.source, session) {
|
||||
return Err(crate::EngineError::Other("render source changed while preparing; check again".into()));
|
||||
}
|
||||
validate_command(session, prepared.kind, &prepared.params)?;
|
||||
let captured = submission(&prepared.capture)?;
|
||||
let installed = session
|
||||
.render_jobs
|
||||
.host_for(captured.executor())
|
||||
.ok_or_else(|| crate::EngineError::Other("prepared render host is no longer installed".into()))?;
|
||||
if !installed.same_host(captured.host()) || !installed.is_available() {
|
||||
return Err(crate::EngineError::Other("prepared render host changed before completion".into()));
|
||||
}
|
||||
captured.check_cancel().map_err(crate::render_jobs::job_error)?;
|
||||
if prepared.kind == Kind::Check {
|
||||
prepared.completed = true;
|
||||
return Ok(std::mem::take(&mut prepared.capture.reply));
|
||||
}
|
||||
let status = session
|
||||
.render_jobs
|
||||
.try_start_prepared_inner(session.revision, &mut prepared.capture.submission, prepared.capture.replace, prepared.shot.clone(), ticket)
|
||||
.map_err(crate::render_jobs::job_error)?;
|
||||
prepared.completed = true;
|
||||
let mut status = json!(status);
|
||||
if let (Some(out), Some(ignored)) = (status.as_object_mut(), prepared.capture.reply.get("ignored_controls")) {
|
||||
out.insert("ignored_controls".into(), ignored.clone());
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
//! Native camera queue preparation. Runtime command metadata is distinct from
|
||||
//! funded renderer storage: the long-lived shared request is an exact Vec arena.
|
||||
use super::{
|
||||
Kind,
|
||||
batch_payload::{self, Payload},
|
||||
};
|
||||
use crate::{EngineError, Result, Session};
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_io::vfs::PathScope;
|
||||
use solidcraft_render::{
|
||||
Camera,
|
||||
job::{CancelToken, Phase, RetainedMetadata},
|
||||
};
|
||||
use std::mem::size_of;
|
||||
|
||||
fn bad(message: &str) -> EngineError {
|
||||
EngineError::Other(message.into())
|
||||
}
|
||||
fn check(cancel: &CancelToken) -> Result<()> {
|
||||
cancel.check().map_err(crate::render_jobs::job_error)
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Ticket {
|
||||
uid: u64,
|
||||
owner: u64,
|
||||
epoch: u64,
|
||||
}
|
||||
impl Ticket {
|
||||
pub(crate) fn matches(self, s: &Session) -> bool {
|
||||
self.uid == s.uid && self.owner == s.render_jobs.owner_key()
|
||||
}
|
||||
}
|
||||
pub(crate) struct NativeState {
|
||||
pub ticket: Ticket,
|
||||
pub next: usize,
|
||||
pub total: usize,
|
||||
pub claimed: bool,
|
||||
pub cancel: CancelToken,
|
||||
}
|
||||
struct Row {
|
||||
name: String,
|
||||
camera: Camera,
|
||||
}
|
||||
/// Opaque persistent shared request. Backing precedes its independent lease.
|
||||
pub struct Plan {
|
||||
ticket: Ticket,
|
||||
payload: Payload,
|
||||
rows: Vec<Row>,
|
||||
scope: Option<PathScope>,
|
||||
next: usize,
|
||||
lease: RetainedMetadata,
|
||||
}
|
||||
impl Plan {
|
||||
pub fn owner_matches(&self, s: &Session) -> bool {
|
||||
self.ticket.matches(s)
|
||||
}
|
||||
pub fn retained_bytes(&self) -> u64 {
|
||||
self.lease.bytes()
|
||||
}
|
||||
}
|
||||
/// One exact source/front claim; a failed channel delivery retains this owner.
|
||||
pub struct Claim {
|
||||
ticket: Ticket,
|
||||
front: usize,
|
||||
first: bool,
|
||||
total: usize,
|
||||
source: Session,
|
||||
params: Option<Value>,
|
||||
scope: Option<PathScope>,
|
||||
plan: Option<Plan>,
|
||||
cancel: CancelToken,
|
||||
}
|
||||
impl Claim {
|
||||
pub fn owner_matches(&self, s: &Session) -> bool {
|
||||
self.ticket.matches(s)
|
||||
}
|
||||
pub fn is_first(&self) -> bool {
|
||||
self.first
|
||||
}
|
||||
}
|
||||
/// Success or failure owns its complete retirement path. There is no heavy Err
|
||||
/// payload implicitly destroyed on the window.
|
||||
pub struct Prepared {
|
||||
ticket: Ticket,
|
||||
front: usize,
|
||||
first: bool,
|
||||
total: usize,
|
||||
plan: Option<Plan>,
|
||||
inner: Option<super::Prepared>,
|
||||
// Exact first caller input is command metadata, not persistent Plan backing.
|
||||
// Keep it through current-owner policy validation and worker retirement.
|
||||
first_params: Option<Value>,
|
||||
error: Option<String>,
|
||||
cancel: CancelToken,
|
||||
committed: bool,
|
||||
finished: bool,
|
||||
}
|
||||
impl Prepared {
|
||||
pub fn owner_matches(&self, s: &Session) -> bool {
|
||||
self.ticket.matches(s)
|
||||
}
|
||||
pub fn is_first(&self) -> bool {
|
||||
self.first
|
||||
}
|
||||
pub fn committed(&self) -> bool {
|
||||
self.committed
|
||||
}
|
||||
}
|
||||
|
||||
/// Caller starts its empty bounded worker first. Metadata refusal takes neither
|
||||
/// owned input nor scope. No source capture, resource IO or job starts here.
|
||||
pub fn claim(s: &mut Session, params: &mut Option<Value>, scope: &mut Option<PathScope>, cancel: CancelToken) -> Result<Claim> {
|
||||
check(&cancel)?;
|
||||
let input = params.as_ref().ok_or_else(|| bad("batch request was already claimed"))?;
|
||||
solidcraft_io::vfs::scoped(scope.clone(), || super::validate_command_id(s, "render.batch", input))?;
|
||||
s.render_batch_ready().map_err(EngineError::Other)?;
|
||||
if s.model.is_stale() {
|
||||
return Err(bad("finish the modelling gesture before rendering shots"));
|
||||
}
|
||||
let epoch = s.render_jobs.native_epoch.checked_add(1).ok_or_else(|| bad("native shot queue identity exhausted"))?;
|
||||
let owner = s.render_jobs.owner_key();
|
||||
if owner == 0 {
|
||||
return Err(bad("render session identity exhausted"));
|
||||
}
|
||||
let ticket = Ticket { uid: s.uid, owner, epoch };
|
||||
let total = s.doc.render.setups.len();
|
||||
let source = s.scratch();
|
||||
let params = params.take().ok_or_else(|| bad("batch request vanished before claim"))?;
|
||||
s.render_jobs.native_epoch = epoch;
|
||||
s.render_jobs.batch_error = None;
|
||||
s.render_jobs.native_batch = Some(NativeState { ticket, next: 0, total, claimed: true, cancel: cancel.clone() });
|
||||
Ok(Claim { ticket, front: 0, first: true, total, source, params: Some(params), scope: scope.take(), plan: None, cancel })
|
||||
}
|
||||
/// None means the actual current job is still running or its front is claimed.
|
||||
/// Refusal preserves the plan; the consumer retires it on its worker.
|
||||
pub fn claim_next(s: &mut Session, slot: &mut Option<Plan>, cancel: CancelToken) -> Result<Option<Claim>> {
|
||||
check(&cancel)?;
|
||||
let plan = slot.as_ref().ok_or_else(|| bad("batch plan was already claimed"))?;
|
||||
if !plan.owner_matches(s) {
|
||||
return Err(bad("batch plan belongs to another real session owner"));
|
||||
}
|
||||
// Verify the exact real owner/epoch/front before metadata errors may fail
|
||||
// this queue. A stale Plan is not authority to alter a replacement ticket.
|
||||
let native =
|
||||
s.render_jobs.native_batch.as_ref().filter(|n| n.ticket == plan.ticket).ok_or_else(|| bad("native shot queue is no longer current"))?;
|
||||
if native.claimed {
|
||||
return Ok(None);
|
||||
}
|
||||
if native.next != plan.next || native.total != plan.rows.len() || native.next >= native.total {
|
||||
return Err(bad("native shot queue front changed"));
|
||||
}
|
||||
let ticket = plan.ticket;
|
||||
let ready = (|| {
|
||||
s.advance_render_batch()?;
|
||||
if !s.render_jobs.native_batch.as_ref().is_some_and(|n| n.ticket == ticket) {
|
||||
return Err(bad("native shot queue is no longer current"));
|
||||
}
|
||||
if let Some(job) = s.render_jobs.batch_job {
|
||||
let status = s.render_jobs.peek_status(job).map_err(crate::render_jobs::job_error)?;
|
||||
if !status.progress.phase.terminal() {
|
||||
return Ok(false);
|
||||
}
|
||||
if status.progress.phase != Phase::Completed {
|
||||
return Err(bad("previous native shot did not complete"));
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
})();
|
||||
match ready {
|
||||
Ok(true) => {}
|
||||
Ok(false) => return Ok(None),
|
||||
Err(error) => {
|
||||
// Fixed metadata only: retain Plan/source backing for its original
|
||||
// worker retirement and never touch a newer queue on this owner.
|
||||
fail(s, ticket, &error.to_string());
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let source = s.scratch();
|
||||
let front = plan.next;
|
||||
let total = plan.rows.len();
|
||||
let ticket = plan.ticket;
|
||||
let plan = slot.take().ok_or_else(|| bad("batch plan vanished before claim"))?;
|
||||
if let Some(native) = &mut s.render_jobs.native_batch {
|
||||
native.claimed = true;
|
||||
native.cancel = cancel.clone();
|
||||
}
|
||||
Ok(Some(Claim { ticket, front, first: false, total, source, params: None, scope: None, plan: Some(plan), cancel }))
|
||||
}
|
||||
|
||||
fn shot_params(mut value: Value, camera: Camera) -> Result<Value> {
|
||||
value
|
||||
.get_mut("settings")
|
||||
.and_then(Value::as_object_mut)
|
||||
.ok_or_else(|| bad("queued render settings are missing"))?
|
||||
.insert("camera".into(), json!(camera));
|
||||
Ok(value)
|
||||
}
|
||||
fn plan_bytes(source: &Session, payload: batch_payload::Counts, scope: Option<&PathScope>) -> Result<u64> {
|
||||
let rows = source.doc.render.setups.len();
|
||||
let mut bytes = (size_of::<Plan>() as u64).checked_add(payload.bytes()?).ok_or_else(|| bad("batch plan byte overflow"))?;
|
||||
bytes = bytes
|
||||
.checked_add(rows.checked_mul(size_of::<Row>()).ok_or_else(|| bad("batch row byte overflow"))? as u64)
|
||||
.ok_or_else(|| bad("batch plan byte overflow"))?;
|
||||
for name in source.doc.render.setups.keys() {
|
||||
bytes = bytes.checked_add(name.len() as u64).ok_or_else(|| bad("batch name byte overflow"))?;
|
||||
}
|
||||
bytes
|
||||
.checked_add(scope.map_or(Ok(0), PathScope::retained_heap_bytes).map_err(|e| bad(&e.to_string()))?)
|
||||
.ok_or_else(|| bad("batch scope byte overflow"))
|
||||
}
|
||||
fn first(claim: Claim, out: &mut Prepared) -> Result<()> {
|
||||
out.first_params = claim.params;
|
||||
let input = out.first_params.as_ref().ok_or_else(|| bad("initial batch input missing"))?;
|
||||
let empty = Value::Object(Default::default());
|
||||
let input = if input.is_null() { &empty } else { input };
|
||||
// Both the original Batch and every nested Start retain their exact guard
|
||||
// inputs for current-owner validation before the first real enqueue.
|
||||
let common = crate::cmd::batch_request(&claim.source, input)?;
|
||||
let (name, camera) = claim.source.doc.render.setups.iter().next().map(|(n, s)| (n.clone(), s.camera)).ok_or_else(|| bad("no saved shots"))?;
|
||||
let params = shot_params(common.clone(), camera)?;
|
||||
let mut inner = super::prepare(claim.source, Kind::Start, params, claim.cancel.clone())?;
|
||||
inner.shot = Some(name);
|
||||
out.inner = Some(inner);
|
||||
let counts = batch_payload::count(&common, &claim.cancel)?;
|
||||
let source = &out.inner.as_ref().ok_or_else(|| bad("missing first prepared owner"))?.source;
|
||||
let bytes = plan_bytes(source, counts, claim.scope.as_ref())?;
|
||||
let lease = super::submission(&out.inner.as_ref().ok_or_else(|| bad("missing first preparation"))?.capture)?
|
||||
.reserve_retained_metadata(bytes)
|
||||
.map_err(crate::render_jobs::job_error)?;
|
||||
let mut rows = Vec::new();
|
||||
rows.try_reserve_exact(claim.total).map_err(|_| bad("could not allocate batch rows"))?;
|
||||
if rows.capacity() != claim.total {
|
||||
return Err(bad("batch row capacity exceeds reservation"));
|
||||
}
|
||||
for (name, setup) in &source.doc.render.setups {
|
||||
check(&claim.cancel)?;
|
||||
let mut copied = String::new();
|
||||
copied.try_reserve_exact(name.len()).map_err(|_| bad("could not retain batch name"))?;
|
||||
if copied.capacity() != name.len() {
|
||||
return Err(bad("batch name capacity exceeds reservation"));
|
||||
}
|
||||
copied.push_str(name);
|
||||
rows.push(Row { name: copied, camera: setup.camera });
|
||||
}
|
||||
let payload = Payload::new(common, counts, &claim.cancel)?;
|
||||
// No normalized Arc<Value> remains alongside this arena. The exact original
|
||||
// Batch input belongs only to this first Prepared command metadata owner,
|
||||
// never to the persistent Plan, and retires on the same worker after finish.
|
||||
let plan = Plan { ticket: claim.ticket, payload, rows, scope: claim.scope, next: 0, lease };
|
||||
if plan.actual_bytes()? != plan.lease.bytes() {
|
||||
return Err(bad("batch arena actual backing disagrees with reservation"));
|
||||
}
|
||||
out.plan = Some(plan);
|
||||
Ok(())
|
||||
}
|
||||
fn next(mut claim: Claim, out: &mut Prepared) -> Result<()> {
|
||||
out.plan = claim.plan.take();
|
||||
let plan = out.plan.as_ref().ok_or_else(|| bad("missing next-shot plan"))?;
|
||||
let row = plan.rows.get(claim.front).ok_or_else(|| bad("batch camera row missing"))?;
|
||||
let params = shot_params(plan.payload.value(&claim.cancel)?, row.camera)?;
|
||||
let mut inner = super::prepare_with_metadata(claim.source, Kind::Start, params, claim.cancel.clone(), Some(&plan.lease))?;
|
||||
inner.shot = Some(row.name.clone());
|
||||
out.inner = Some(inner);
|
||||
Ok(())
|
||||
}
|
||||
impl Plan {
|
||||
fn actual_bytes(&self) -> Result<u64> {
|
||||
let mut bytes = (size_of::<Self>() as u64).checked_add(self.payload.heap_bytes()?).ok_or_else(|| bad("batch actual byte overflow"))?;
|
||||
bytes = bytes
|
||||
.checked_add(self.rows.capacity().checked_mul(size_of::<Row>()).ok_or_else(|| bad("batch actual row overflow"))? as u64)
|
||||
.ok_or_else(|| bad("batch actual row overflow"))?;
|
||||
for row in &self.rows {
|
||||
bytes = bytes.checked_add(row.name.capacity() as u64).ok_or_else(|| bad("batch actual name overflow"))?;
|
||||
}
|
||||
bytes
|
||||
.checked_add(self.scope.as_ref().map_or(Ok(0), PathScope::retained_heap_bytes).map_err(|e| bad(&e.to_string()))?)
|
||||
.ok_or_else(|| bad("batch actual scope overflow"))
|
||||
}
|
||||
}
|
||||
/// Always returns the exact owner, including failures. Caller must retire it on
|
||||
/// this same worker after finish/stale/cancel, and wait for acknowledgement.
|
||||
pub fn prepare(claim: Claim) -> Prepared {
|
||||
let mut out = Prepared {
|
||||
ticket: claim.ticket,
|
||||
front: claim.front,
|
||||
first: claim.first,
|
||||
total: claim.total,
|
||||
plan: None,
|
||||
inner: None,
|
||||
first_params: None,
|
||||
error: None,
|
||||
cancel: claim.cancel.clone(),
|
||||
committed: false,
|
||||
finished: false,
|
||||
};
|
||||
let scope = claim.scope.clone().or_else(|| claim.plan.as_ref().and_then(|p| p.scope.clone()));
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
solidcraft_io::vfs::scoped(scope, || {
|
||||
check(&claim.cancel)?;
|
||||
if claim.first { first(claim, &mut out) } else { next(claim, &mut out) }
|
||||
})
|
||||
}));
|
||||
out.error = match result {
|
||||
Ok(Ok(())) => None,
|
||||
Ok(Err(error)) => Some(error.to_string()),
|
||||
Err(_) => Some("native batch preparation failed internally".into()),
|
||||
};
|
||||
out
|
||||
}
|
||||
fn fail(s: &mut Session, ticket: Ticket, message: &str) {
|
||||
if s.render_jobs.native_batch.as_ref().is_some_and(|n| n.ticket == ticket) {
|
||||
if let Some(native) = s.render_jobs.native_batch.take() {
|
||||
native.cancel.cancel();
|
||||
}
|
||||
s.render_jobs.batch_job = None;
|
||||
s.render_jobs.batch_error = Some(message.into());
|
||||
}
|
||||
}
|
||||
/// The real owner commits; a different current tab cannot adopt its jobs. All
|
||||
/// error and successful source backing remains in `out` for worker retirement.
|
||||
pub fn finish_retained(s: &mut Session, out: &mut Prepared) -> Result<Value> {
|
||||
if !out.owner_matches(s) {
|
||||
return Err(bad("batch completion belongs to another real session owner"));
|
||||
}
|
||||
if out.finished {
|
||||
return Err(bad("batch preparation was already completed"));
|
||||
}
|
||||
let native = s
|
||||
.render_jobs
|
||||
.native_batch
|
||||
.as_ref()
|
||||
.filter(|n| n.ticket == out.ticket && n.claimed && n.next == out.front && n.total == out.total)
|
||||
.ok_or_else(|| bad("native shot queue claim is no longer current"))?;
|
||||
if let Err(error) = check(&native.cancel) {
|
||||
out.finished = true;
|
||||
fail(s, out.ticket, &error.to_string());
|
||||
return Err(error);
|
||||
}
|
||||
let result = (|| {
|
||||
check(&out.cancel)?;
|
||||
if let Some(error) = &out.error {
|
||||
return Err(bad(error));
|
||||
}
|
||||
let scope = out.plan.as_ref().and_then(|p| p.scope.clone());
|
||||
let inner = out.inner.as_mut().ok_or_else(|| bad("batch preparation has no proved job"))?;
|
||||
let reply = solidcraft_io::vfs::scoped(scope, || {
|
||||
if out.first {
|
||||
let input = out.first_params.as_ref().ok_or_else(|| bad("first batch guard input missing"))?;
|
||||
let spec = crate::find_command("render.batch").ok_or_else(|| EngineError::UnknownCommand("render.batch".into()))?;
|
||||
// The ordinary enablement shares this readiness predicate. Only
|
||||
// this exact first claimed front is exempt from its own busy bit.
|
||||
s.render_batch_ready_for_first(out.ticket).map_err(|why| EngineError::Disabled(spec.id.into(), why))?;
|
||||
s.check_command(spec, input)?;
|
||||
}
|
||||
super::finish_inner(s, inner, Some(out.ticket))
|
||||
})?;
|
||||
out.committed = true;
|
||||
let plan = out.plan.as_mut().ok_or_else(|| bad("batch completion has no plan"))?;
|
||||
plan.next = out.front + 1;
|
||||
Ok(if out.first { json!({"queued":out.total}) } else { reply })
|
||||
})();
|
||||
out.finished = true;
|
||||
if let Err(error) = &result
|
||||
&& !out.committed
|
||||
{
|
||||
fail(s, out.ticket, &error.to_string());
|
||||
}
|
||||
result
|
||||
}
|
||||
pub fn take_plan(out: &mut Prepared) -> Result<Option<Plan>> {
|
||||
if !out.committed {
|
||||
return Err(bad("batch plan cannot advance before its actual job"));
|
||||
}
|
||||
if out.plan.as_ref().is_some_and(|p| p.next < p.rows.len()) { Ok(out.plan.take()) } else { Ok(None) }
|
||||
}
|
||||
/// Metadata-only cancellation, including a first claim which has no fabricated
|
||||
/// job ID. Idle/closed plans still require their separate worker retirement.
|
||||
pub fn cancel(s: &mut Session) -> Result<bool> {
|
||||
let Some(native) = s.render_jobs.native_batch.take() else { return Ok(false) };
|
||||
native.cancel.cancel();
|
||||
if let Some(job) = s.render_jobs.batch_job.take() {
|
||||
// A discarded completed front can survive only in the bounded status
|
||||
// history. It needs no live handle or additional cancellation; looking
|
||||
// it up must not collect or evict another retained result here.
|
||||
let status = s.render_jobs.peek_status(job).map_err(crate::render_jobs::job_error)?;
|
||||
if !status.progress.phase.terminal() {
|
||||
let _ = s.render_jobs.handle(job).map_err(crate::render_jobs::job_error)?.cancel().map_err(crate::render_jobs::job_error)?;
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Cleanup for a particular idle plan. Owner equality alone is deliberately
|
||||
/// insufficient: an identical replacement queue has a different epoch.
|
||||
pub fn cancel_plan(s: &mut Session, plan: &Plan) -> Result<bool> {
|
||||
if !plan.owner_matches(s) || !s.render_jobs.native_batch.as_ref().is_some_and(|n| n.ticket == plan.ticket) {
|
||||
return Ok(false);
|
||||
}
|
||||
cancel(s)
|
||||
}
|
||||
|
||||
/// The native worker wraps its entire send/return/drop lifecycle here. Send the
|
||||
/// release acknowledgement only after this returns, so both captured backing
|
||||
/// and its temporary caller permission context have really retired. The plan
|
||||
/// itself still owns/funds exactly one persistent scope between jobs.
|
||||
pub fn with_scope<T>(claim: Claim, f: impl FnOnce(Claim) -> T) -> T {
|
||||
let scope = claim.scope.clone().or_else(|| claim.plan.as_ref().and_then(|p| p.scope.clone()));
|
||||
solidcraft_io::vfs::scoped(scope, || f(claim))
|
||||
}
|
||||
/// Idle, closed or rejected plan-only messages need no second stored scope.
|
||||
/// Hold the lease until original scope storage and all backing have dropped.
|
||||
pub fn retire_plan(plan: Plan) {
|
||||
let Plan { payload, rows, scope, lease, .. } = plan;
|
||||
solidcraft_io::vfs::scoped(scope, || drop((payload, rows)));
|
||||
drop(lease);
|
||||
}
|
||||
/// Pure ordinary cancellation enablement/policy/typed-argument check. A pending
|
||||
/// UI input has no invented job: None may cancel that input; Some must follow
|
||||
/// the ordinary actual-job cancellation route, including ownership validation.
|
||||
pub fn check_cancel(s: &Session, params: &Value) -> Result<Option<solidcraft_render::job::JobId>> {
|
||||
super::validate_command_id(s, "render.cancel", params)?;
|
||||
let job = crate::cmd::render_cancel_params(params)?;
|
||||
if let Some(job) = job {
|
||||
s.render_jobs.check_retained_or_historical_job(job).map_err(crate::render_jobs::job_error)?;
|
||||
}
|
||||
Ok(job)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Owned shared command data. Only public Vec/String capacities are retained;
|
||||
//! no serde_json object allocation survives conversion into the plan.
|
||||
use crate::{EngineError, Result};
|
||||
use serde_json::{Number, Value};
|
||||
use solidcraft_render::job::CancelToken;
|
||||
use std::mem::size_of;
|
||||
|
||||
fn bad(message: &str) -> EngineError {
|
||||
EngineError::Other(message.into())
|
||||
}
|
||||
fn add(a: u64, b: usize) -> Result<u64> {
|
||||
a.checked_add(u64::try_from(b).map_err(|_| bad("batch payload byte overflow"))?).ok_or_else(|| bad("batch payload byte overflow"))
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum Numeric {
|
||||
Signed(i64),
|
||||
Unsigned(u64),
|
||||
Float(u64),
|
||||
}
|
||||
impl Numeric {
|
||||
fn capture(value: Number) -> Result<Self> {
|
||||
if value.is_i64() {
|
||||
return value.as_i64().map(Self::Signed).ok_or_else(|| bad("batch signed number"));
|
||||
}
|
||||
if value.is_u64() {
|
||||
return value.as_u64().map(Self::Unsigned).ok_or_else(|| bad("batch unsigned number"));
|
||||
}
|
||||
if value.is_f64() {
|
||||
return value.as_f64().filter(|n| n.is_finite()).map(|n| Self::Float(n.to_bits())).ok_or_else(|| bad("batch finite number"));
|
||||
}
|
||||
Err(bad("batch command number is not representable without changing its value"))
|
||||
}
|
||||
fn value(self) -> Result<Value> {
|
||||
Ok(Value::Number(match self {
|
||||
Self::Signed(v) => v.into(),
|
||||
Self::Unsigned(v) => v.into(),
|
||||
Self::Float(v) => Number::from_f64(f64::from_bits(v)).ok_or_else(|| bad("batch stored number"))?,
|
||||
}))
|
||||
}
|
||||
}
|
||||
enum Node {
|
||||
Pending(Value),
|
||||
Null,
|
||||
Bool(bool),
|
||||
Number(Numeric),
|
||||
String(String),
|
||||
Array { begin: usize, end: usize },
|
||||
Object { begin: usize, end: usize },
|
||||
}
|
||||
struct Edge {
|
||||
key: Option<String>,
|
||||
child: usize,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Counts {
|
||||
nodes: usize,
|
||||
edges: usize,
|
||||
strings: u64,
|
||||
}
|
||||
impl Counts {
|
||||
pub(crate) fn bytes(self) -> Result<u64> {
|
||||
let bytes = self
|
||||
.nodes
|
||||
.checked_mul(size_of::<Node>())
|
||||
.and_then(|n| self.edges.checked_mul(size_of::<Edge>()).and_then(|e| n.checked_add(e)))
|
||||
.ok_or_else(|| bad("batch arena storage overflow"))?;
|
||||
add(self.strings, bytes)
|
||||
}
|
||||
}
|
||||
|
||||
// This is the existing serde_json command parser nesting envelope, not a
|
||||
// material/light/shot count. Valid typed rendering commands are shallower.
|
||||
const JSON_DEPTH: usize = 128;
|
||||
enum Frame<'a> {
|
||||
Array(std::slice::Iter<'a, Value>),
|
||||
Object(serde_json::map::Iter<'a>),
|
||||
}
|
||||
fn cancelled(cancel: &CancelToken) -> Result<()> {
|
||||
cancel.check().map_err(crate::render_jobs::job_error)
|
||||
}
|
||||
pub(crate) fn count(value: &Value, cancel: &CancelToken) -> Result<Counts> {
|
||||
let mut counts = Counts { nodes: 0, edges: 0, strings: 0 };
|
||||
let mut stack: [Option<Frame<'_>>; JSON_DEPTH] = std::array::from_fn(|_| None);
|
||||
let mut depth = 0;
|
||||
let mut next = Some(value);
|
||||
loop {
|
||||
cancelled(cancel)?;
|
||||
if let Some(value) = next.take() {
|
||||
counts.nodes = counts.nodes.checked_add(1).ok_or_else(|| bad("batch arena node overflow"))?;
|
||||
match value {
|
||||
Value::String(v) => counts.strings = add(counts.strings, v.capacity())?,
|
||||
Value::Array(v) => {
|
||||
counts.edges = counts.edges.checked_add(v.len()).ok_or_else(|| bad("batch arena edge overflow"))?;
|
||||
*stack.get_mut(depth).ok_or_else(|| bad("batch request exceeds the existing JSON nesting envelope"))? =
|
||||
Some(Frame::Array(v.iter()));
|
||||
depth += 1;
|
||||
}
|
||||
Value::Object(v) => {
|
||||
counts.edges = counts.edges.checked_add(v.len()).ok_or_else(|| bad("batch arena edge overflow"))?;
|
||||
*stack.get_mut(depth).ok_or_else(|| bad("batch request exceeds the existing JSON nesting envelope"))? =
|
||||
Some(Frame::Object(v.iter()));
|
||||
depth += 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
while depth != 0 && next.is_none() {
|
||||
match stack.get_mut(depth - 1).and_then(Option::as_mut).ok_or_else(|| bad("batch arena traversal"))? {
|
||||
Frame::Array(iter) => next = iter.next(),
|
||||
Frame::Object(iter) => {
|
||||
if let Some((key, value)) = iter.next() {
|
||||
counts.strings = add(counts.strings, key.capacity())?;
|
||||
next = Some(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if next.is_none() {
|
||||
*stack.get_mut(depth - 1).ok_or_else(|| bad("batch arena traversal pop"))? = None;
|
||||
depth -= 1;
|
||||
}
|
||||
}
|
||||
if next.is_none() {
|
||||
return Ok(counts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Payload {
|
||||
nodes: Vec<Node>,
|
||||
edges: Vec<Edge>,
|
||||
strings: u64,
|
||||
}
|
||||
impl Payload {
|
||||
/// Caller reserves Counts::bytes plus its fixed plan/rows/scope backing
|
||||
/// before this new bulk allocation. The old command Value is caller-owned
|
||||
/// metadata and is dismantled here on the preparation worker.
|
||||
pub(crate) fn new(value: Value, counts: Counts, cancel: &CancelToken) -> Result<Self> {
|
||||
cancelled(cancel)?;
|
||||
let mut nodes = Vec::new();
|
||||
let mut edges = Vec::new();
|
||||
nodes.try_reserve_exact(counts.nodes).map_err(|_| bad("could not allocate batch arena nodes"))?;
|
||||
edges.try_reserve_exact(counts.edges).map_err(|_| bad("could not allocate batch arena edges"))?;
|
||||
if nodes.capacity() != counts.nodes || edges.capacity() != counts.edges {
|
||||
return Err(bad("batch arena capacity exceeds its reservation"));
|
||||
}
|
||||
nodes.push(Node::Pending(value));
|
||||
let mut at = 0;
|
||||
while at < nodes.len() {
|
||||
cancelled(cancel)?;
|
||||
let slot = nodes.get_mut(at).ok_or_else(|| bad("batch arena pending node"))?;
|
||||
let Node::Pending(value) = std::mem::replace(slot, Node::Null) else {
|
||||
return Err(bad("batch arena node already resolved"));
|
||||
};
|
||||
let begin = edges.len();
|
||||
let resolved = match value {
|
||||
Value::Null => Node::Null,
|
||||
Value::Bool(v) => Node::Bool(v),
|
||||
Value::Number(v) => Node::Number(Numeric::capture(v)?),
|
||||
Value::String(v) => Node::String(v),
|
||||
Value::Array(values) => {
|
||||
for value in values {
|
||||
cancelled(cancel)?;
|
||||
if nodes.len() == nodes.capacity() || edges.len() == edges.capacity() {
|
||||
return Err(bad("batch arena array exceeds reservation"));
|
||||
}
|
||||
let child = nodes.len();
|
||||
nodes.push(Node::Pending(value));
|
||||
edges.push(Edge { key: None, child });
|
||||
}
|
||||
Node::Array { begin, end: edges.len() }
|
||||
}
|
||||
Value::Object(values) => {
|
||||
for (key, value) in values {
|
||||
cancelled(cancel)?;
|
||||
if nodes.len() == nodes.capacity() || edges.len() == edges.capacity() {
|
||||
return Err(bad("batch arena object exceeds reservation"));
|
||||
}
|
||||
let child = nodes.len();
|
||||
nodes.push(Node::Pending(value));
|
||||
edges.push(Edge { key: Some(key), child });
|
||||
}
|
||||
Node::Object { begin, end: edges.len() }
|
||||
}
|
||||
};
|
||||
*nodes.get_mut(at).ok_or_else(|| bad("batch arena resolved slot"))? = resolved;
|
||||
at += 1;
|
||||
}
|
||||
if nodes.len() != counts.nodes || edges.len() != counts.edges {
|
||||
return Err(bad("batch arena accounting disagrees with conversion"));
|
||||
}
|
||||
Ok(Self { nodes, edges, strings: counts.strings })
|
||||
}
|
||||
pub(crate) fn heap_bytes(&self) -> Result<u64> {
|
||||
add(
|
||||
self.strings,
|
||||
self.nodes
|
||||
.capacity()
|
||||
.checked_mul(size_of::<Node>())
|
||||
.and_then(|n| self.edges.capacity().checked_mul(size_of::<Edge>()).and_then(|e| n.checked_add(e)))
|
||||
.ok_or_else(|| bad("batch arena actual capacity overflow"))?,
|
||||
)
|
||||
}
|
||||
/// Exactly one per-shot raw command Value is reconstructed on the worker.
|
||||
/// Like individual command metadata it remains caller-owned, is moved into
|
||||
/// Prepared.params, and retires on that worker. It is not decoded renderer
|
||||
/// storage or a second retained common batch request.
|
||||
pub(crate) fn value(&self, cancel: &CancelToken) -> Result<Value> {
|
||||
self.node_value(0, 0, cancel)
|
||||
}
|
||||
fn node_value(&self, at: usize, depth: usize, cancel: &CancelToken) -> Result<Value> {
|
||||
cancelled(cancel)?;
|
||||
if depth > JSON_DEPTH {
|
||||
return Err(bad("batch arena stored nesting"));
|
||||
}
|
||||
let copy = |text: &str| -> Result<String> {
|
||||
let mut value = String::new();
|
||||
value.try_reserve_exact(text.len()).map_err(|_| bad("could not copy batch command text"))?;
|
||||
value.push_str(text);
|
||||
Ok(value)
|
||||
};
|
||||
Ok(match self.nodes.get(at).ok_or_else(|| bad("batch arena stored index"))? {
|
||||
Node::Null => Value::Null,
|
||||
Node::Bool(v) => Value::Bool(*v),
|
||||
Node::Number(v) => v.value()?,
|
||||
Node::String(v) => Value::String(copy(v)?),
|
||||
Node::Array { begin, end } => {
|
||||
let entries = self.edges.get(*begin..*end).ok_or_else(|| bad("batch arena array range"))?;
|
||||
let mut values = Vec::new();
|
||||
values.try_reserve_exact(entries.len()).map_err(|_| bad("could not copy batch command array"))?;
|
||||
for edge in entries {
|
||||
values.push(self.node_value(edge.child, depth + 1, cancel)?);
|
||||
}
|
||||
Value::Array(values)
|
||||
}
|
||||
Node::Object { begin, end } => {
|
||||
let entries = self.edges.get(*begin..*end).ok_or_else(|| bad("batch arena object range"))?;
|
||||
let mut values = serde_json::Map::new();
|
||||
for edge in entries {
|
||||
let key = edge.key.as_deref().ok_or_else(|| bad("batch arena object key"))?;
|
||||
values.insert(copy(key)?, self.node_value(edge.child, depth + 1, cancel)?);
|
||||
}
|
||||
Value::Object(values)
|
||||
}
|
||||
Node::Pending(_) => return Err(bad("batch arena conversion incomplete")),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_element_bytes() -> (usize, usize) {
|
||||
(size_of::<Node>(), size_of::<Edge>())
|
||||
}
|
||||
#[cfg(test)]
|
||||
impl Payload {
|
||||
pub(crate) fn test_capacities(&self) -> (usize, usize, u64) {
|
||||
(self.nodes.capacity(), self.edges.capacity(), self.strings)
|
||||
}
|
||||
}
|
||||
@@ -25,3 +25,57 @@ pub struct EnvironmentResult {
|
||||
pub name: String,
|
||||
pub setup: Value,
|
||||
}
|
||||
|
||||
/// Shared clear-coat numbers; the handler validates the renderer's exact domain.
|
||||
#[derive(Clone, Copy, Deserialize, JsonSchema)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct CoatNumbers {
|
||||
pub weight: f64,
|
||||
pub roughness: f64,
|
||||
pub ior: f64,
|
||||
}
|
||||
impl Default for CoatNumbers {
|
||||
fn default() -> Self {
|
||||
let coat = solidcraft_doc::render_coat::CoatMaterial::default();
|
||||
Self { weight: coat.weight, roughness: coat.roughness, ior: coat.ior }
|
||||
}
|
||||
}
|
||||
impl CoatNumbers {
|
||||
pub fn material(self) -> solidcraft_doc::render_coat::CoatMaterial {
|
||||
solidcraft_doc::render_coat::CoatMaterial { weight: self.weight, roughness: self.roughness, ior: self.ior }
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CoatBindParams {
|
||||
pub name: String,
|
||||
/// Numerical shot snapshot; cannot replace existing coat bindings through this patch.
|
||||
#[serde(default = "empty_patch")]
|
||||
pub set: Value,
|
||||
pub body_index: usize,
|
||||
pub body_name: String,
|
||||
pub coat: CoatNumbers,
|
||||
pub expected_revision: u64,
|
||||
#[serde(default)]
|
||||
pub replace: bool,
|
||||
}
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CoatUnbindParams {
|
||||
pub name: String,
|
||||
/// Exact saved definition/feature/component/occurrence_path, checked by the document type.
|
||||
#[schemars(with = "std::collections::BTreeMap<String, Value>")]
|
||||
pub target: Value,
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
#[derive(Serialize, JsonSchema)]
|
||||
pub struct CoatBindResult {
|
||||
pub name: String,
|
||||
pub setup: Value,
|
||||
pub geometry_and_engineering_unchanged: bool,
|
||||
}
|
||||
#[derive(Serialize, JsonSchema)]
|
||||
pub struct CoatUnbindResult {
|
||||
pub name: String,
|
||||
pub removed: bool,
|
||||
}
|
||||
|
||||
@@ -322,6 +322,8 @@ pub struct RenderTarget {
|
||||
pub ordinal: usize,
|
||||
/// Its name, with its component's path when it is in one.
|
||||
pub name: String,
|
||||
/// Its saved definition, feature and occurrence identity; `null` when no stable target exists.
|
||||
pub stable_target: Option<StableRenderTarget>,
|
||||
/// Its colour: red, green and blue, 0 to 255.
|
||||
pub color: [u8; 3],
|
||||
/// How opaque it is: 1 is solid.
|
||||
@@ -330,6 +332,30 @@ pub struct RenderTarget {
|
||||
pub pbr: Option<Pbr>,
|
||||
}
|
||||
|
||||
/// A stable body identity accepted by saved surface, optical and clear-coat bindings.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct StableRenderTarget {
|
||||
/// The saved body definition's name.
|
||||
pub definition: String,
|
||||
/// The feature that produced the body.
|
||||
pub feature: u64,
|
||||
/// The owning component, or 0 for the root definition.
|
||||
pub component: u64,
|
||||
/// The ordered occurrence ids from the root; empty for a root definition.
|
||||
pub occurrence_path: Vec<u64>,
|
||||
}
|
||||
|
||||
impl From<&solidcraft_doc::render_surface::RenderBodyTarget> for StableRenderTarget {
|
||||
fn from(target: &solidcraft_doc::render_surface::RenderBodyTarget) -> Self {
|
||||
Self {
|
||||
definition: target.definition.clone(),
|
||||
feature: target.feature,
|
||||
component: target.component,
|
||||
occurrence_path: target.occurrence_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A body's PBR look.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct Pbr {
|
||||
|
||||
@@ -2,7 +2,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
LibraryDescribeParams, LibraryDescription, LibraryListParams, LibraryPage, LibraryPreview, LibraryPreviewParams, LibraryTargets,
|
||||
LibraryTargetsParams,
|
||||
LibraryTargetsParams, RenderTarget, StableRenderTarget,
|
||||
};
|
||||
use crate::Session;
|
||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms};
|
||||
@@ -124,7 +124,7 @@ fn render_library_commands_reply_with_their_schemas() {
|
||||
let empty = run(&mut s, "render.library.targets", json!({}));
|
||||
matches(&targets, &empty);
|
||||
assert!(empty["bounds"].is_null(), "{empty}");
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 5, "body_name": "Target"}));
|
||||
let created = run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": 5, "body_name": "Target"}));
|
||||
run(
|
||||
&mut s,
|
||||
"appearance.assign",
|
||||
@@ -134,6 +134,35 @@ fn render_library_commands_reply_with_their_schemas() {
|
||||
matches(&targets, &r);
|
||||
assert_eq!((r["total"].as_u64(), r["items"][0]["name"].as_str()), (Some(1), Some("Target")), "{r}");
|
||||
assert!(r["items"][0]["pbr"].is_object() && r["bounds"].is_object(), "{r}");
|
||||
assert_eq!(
|
||||
r["items"][0]["stable_target"],
|
||||
json!({"definition":"Target","feature":created["feature"],"component":0,"occurrence_path":[]}),
|
||||
"the typed reply must preserve the saved root-body identity"
|
||||
);
|
||||
let mut without_stable = r.clone();
|
||||
without_stable["items"][0] = serde_json::to_value(RenderTarget {
|
||||
ordinal: 0,
|
||||
name: "Unbound".into(),
|
||||
stable_target: None,
|
||||
color: [180, 120, 70],
|
||||
opacity: 1.0,
|
||||
pbr: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(without_stable["items"][0].get("stable_target"), Some(&Value::Null), "an unavailable identity stays an explicit null");
|
||||
matches(&targets, &without_stable);
|
||||
let authority =
|
||||
solidcraft_doc::render_surface::RenderBodyTarget { definition: "Part".into(), feature: 7, component: 3, occurrence_path: vec![11, 3, 6] };
|
||||
authority.validate().unwrap();
|
||||
let mut nested = r.clone();
|
||||
nested["items"][0]["stable_target"] = serde_json::to_value(StableRenderTarget::from(&authority)).unwrap();
|
||||
matches(&targets, &nested);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<solidcraft_doc::render_surface::RenderBodyTarget>(nested["items"][0]["stable_target"].clone()).unwrap(),
|
||||
authority
|
||||
);
|
||||
nested["items"][0]["stable_target"]["occurrence_path"] = json!([11, "not an occurrence id", 6]);
|
||||
assert!(!conforms(targets.as_value(), targets.as_value(), &nested), "the reply schema must describe the stable identity's actual fields");
|
||||
if cfg!(feature = "bundled-render-library") {
|
||||
matches(&preview, &run(&mut s, "render.library.preview", json!({"id": "SURF-001"})));
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ pub struct ListResult {
|
||||
pub lights: Vec<Light>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct Light {
|
||||
/// Family and zero-based source slot: directional:N, point:N, spot:N or area:N.
|
||||
/// Point and spot share their punctual-list index. Stable across numerical edits,
|
||||
@@ -41,13 +41,13 @@ pub struct Light {
|
||||
pub geometry: Geometry,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Geometry {
|
||||
Directional {
|
||||
/// World direction towards the source, as stored; need not be unit length.
|
||||
direction: [f64; 3],
|
||||
/// Present for the GPU finite-disk sun only, in radians.
|
||||
/// Present for a finite-disk Sun, in radians. CPU requires explicit Path transport.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
angular_radius_rad: Option<f64>,
|
||||
},
|
||||
@@ -134,6 +134,11 @@ pub struct LightPatch {
|
||||
/// World-space fields, lowered to the existing renderer parameters.
|
||||
pub position_mm: Option<[f64; 3]>,
|
||||
pub direction: Option<[f64; 3]>,
|
||||
/// Spot-only world emission direction FROM the light. Conflicts with aim_at.
|
||||
pub emitted_axis: Option<[f64; 3]>,
|
||||
/// Spot half cones in radians; require 0 <= inner < outer <= pi/2.
|
||||
pub inner_half_angle_radians: Option<f64>,
|
||||
pub outer_half_angle_radians: Option<f64>,
|
||||
pub edge_u_mm: Option<[f64; 3]>,
|
||||
pub edge_v_mm: Option<[f64; 3]>,
|
||||
pub radiance: Option<[f64; 3]>,
|
||||
@@ -154,9 +159,21 @@ pub struct EditParams {
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
|
||||
/// A surviving source's address before and after this exact structural edit.
|
||||
/// `before` verifies the mapping belongs to the caller's draft, not a different
|
||||
/// settings override. New or duplicated sources never inherit an old identity.
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LightIdRemap {
|
||||
pub before: Light,
|
||||
pub after_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct EditResult {
|
||||
pub id: Option<String>,
|
||||
#[schemars(length(max = 20))]
|
||||
pub id_remap: Vec<LightIdRemap>,
|
||||
pub name: String,
|
||||
pub setup: Value,
|
||||
}
|
||||
|
||||
@@ -34,6 +34,14 @@ pub struct RenderFocus {
|
||||
pub on: bool,
|
||||
}
|
||||
|
||||
/// Pause or resume the current document's live Render preview without saving a setup.
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RenderLive {
|
||||
/// True pauses refinement; false resumes it using the current visual settings.
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RenderBrowser {
|
||||
|
||||
@@ -3,6 +3,51 @@
|
||||
use super::*;
|
||||
use solidcraft_render::job::adaptive::SamplingReport;
|
||||
|
||||
fn internal(message: &str) -> JobError {
|
||||
JobError::new(ErrorCode::Internal, message)
|
||||
}
|
||||
|
||||
fn production_count(options: &JobOptions, bytes: Option<&[u8]>) -> Result<u32> {
|
||||
let adaptive = options.adaptive.ok_or_else(|| internal("missing adaptive preview options"))?;
|
||||
let bytes: [u8; 4] = bytes.and_then(|bytes| bytes.try_into().ok()).ok_or_else(|| internal("missing adaptive preview budget"))?;
|
||||
let value = f32::from_le_bytes(bytes);
|
||||
if !value.is_finite() || value.fract() != 0.0 || value < adaptive.min_samples as f32 || value > options.samples as f32 {
|
||||
return Err(internal("invalid adaptive preview production count"));
|
||||
}
|
||||
Ok(value as u32)
|
||||
}
|
||||
pub(super) fn preview_divisor(options: &JobOptions, completed: u32, budget: Option<&[u8]>) -> Result<f32> {
|
||||
let adaptive = options.adaptive.ok_or_else(|| internal("missing adaptive preview options"))?;
|
||||
let total = options.samples.checked_add(adaptive.pilot_samples).ok_or_else(|| internal("adaptive preview target overflow"))?;
|
||||
if completed == 0 || completed > total {
|
||||
return Err(internal("adaptive preview iteration exceeds its target"));
|
||||
}
|
||||
if completed <= adaptive.pilot_samples {
|
||||
return Ok(1.0);
|
||||
}
|
||||
let production = completed - adaptive.pilot_samples;
|
||||
let target = production_count(options, budget)?;
|
||||
// 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> {
|
||||
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));
|
||||
}
|
||||
let production = task.sample - options.pilot_samples;
|
||||
let mut paths = pixels * u64::from(options.pilot_samples);
|
||||
let bytes = budgets.ok_or_else(|| internal("missing adaptive preview budgets"))?;
|
||||
for (index, pixel) in bytes.as_chunks::<16>().0.iter().enumerate() {
|
||||
if index % 4096 == 0 {
|
||||
task.cancel.check()?;
|
||||
}
|
||||
paths += u64::from(production.min(production_count(&task.options, pixel.get(..4))?)) * u64::from(task.options.transmission_samples);
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
pub(super) fn report(task: &Task) -> Result<SamplingReport> {
|
||||
task.cancel.check()?;
|
||||
task.context.ensure_available()?;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Display copy only: never writes the independently sampled production film.
|
||||
struct Parameters { image:vec4<u32>, work:vec4<u32> };
|
||||
@group(0) @binding(0) var<uniform> u:Parameters;
|
||||
@group(0) @binding(1) var<storage,read> film:array<vec4<f32>>;
|
||||
@group(0) @binding(2) var<storage,read_write> display:array<vec4<f32>>;
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) gid:vec3<u32>) {
|
||||
let pixels=u.image.x*u.image.y;
|
||||
let pixel=u.image.w+gid.x;
|
||||
if gid.x>=u.work.x || pixel>=pixels { return; }
|
||||
if u.image.z<=u.work.y {
|
||||
display[pixel]=film[pixels+pixel];
|
||||
} else {
|
||||
let completed=u.image.z-u.work.y;
|
||||
let budget=u32(film[2u*pixels+pixel].x);
|
||||
// Completed pixels were divided in place by the production shader already.
|
||||
if completed<budget { display[pixel]=film[pixel]/f32(completed); }
|
||||
else { display[pixel]=film[pixel]; }
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,21 @@ fn independent_pilots_never_enter_the_final_master_and_admission_counts_auxiliar
|
||||
let req = request(16);
|
||||
let old = admission(&source, &req, fixed).unwrap();
|
||||
let new = admission(&source, &req, adaptive).unwrap();
|
||||
assert_eq!(new.estimated_total_bytes - old.estimated_total_bytes, 16 * 16 * 32);
|
||||
assert_eq!(new.estimated_total_bytes - old.estimated_total_bytes, 16 * 16 * 48);
|
||||
let old_filtered = admission(&source, &req, JobOptions { denoise: true, ..fixed }).unwrap();
|
||||
let new_filtered = admission(&source, &req, JobOptions { denoise: true, ..adaptive }).unwrap();
|
||||
assert_eq!(
|
||||
new_filtered.pixel_scratch_bytes - old_filtered.pixel_scratch_bytes,
|
||||
16 * 16 * 64,
|
||||
"the normalized denoiser input has its own prepaid float4 plane"
|
||||
);
|
||||
// The sphere's geometry preparation can dominate both peak estimates, hiding
|
||||
// a larger pixel stage beneath that already prepaid peak. With no geometry,
|
||||
// pixel storage determines the peak and must include the normalized plane too.
|
||||
let empty = ProductScene { meshes: Vec::new(), bounds: solidcraft_geom::Aabb3::EMPTY };
|
||||
let old_pixel_peak = admission(&empty, &req, JobOptions { denoise: true, ..fixed }).unwrap();
|
||||
let new_pixel_peak = admission(&empty, &req, JobOptions { denoise: true, ..adaptive }).unwrap();
|
||||
assert_eq!(new_pixel_peak.estimated_total_bytes - old_pixel_peak.estimated_total_bytes, 16 * 16 * 64);
|
||||
let (a, exr_a) = complete_linear_fixture(executor.context.clone(), fixed);
|
||||
let (b, exr_b) = complete_linear_fixture(executor.context.clone(), adaptive);
|
||||
assert_eq!(a, b, "fixed production budget must retain the old production sample stream");
|
||||
@@ -124,3 +138,148 @@ fn adaptive_report_refuses_retired_device_before_readback() {
|
||||
let error = adaptive::report(&task).unwrap_err();
|
||||
assert_eq!(error.code, ErrorCode::HostUnavailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_preview_divides_only_unfinished_production_and_rejects_invalid_budgets() {
|
||||
let options = JobOptions {
|
||||
samples: 64,
|
||||
adaptive: Some(AdaptiveSampling { pilot_samples: 16, min_samples: 16, ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
for completed in 1..=16 {
|
||||
assert_eq!(adaptive::preview_divisor(&options, completed, None).unwrap(), 1.0);
|
||||
}
|
||||
for count in [16, 32, 64] {
|
||||
let budget = (count as f32).to_le_bytes();
|
||||
for production in 1..=64 {
|
||||
let actual = adaptive::preview_divisor(&options, 16 + production, Some(&budget)).unwrap();
|
||||
assert_eq!(actual, if production < count { production as f32 } else { 1.0 });
|
||||
}
|
||||
}
|
||||
for count in [f32::NAN, f32::INFINITY, 0.0, 15.0, 16.5, 65.0] {
|
||||
assert_eq!(adaptive::preview_divisor(&options, 17, Some(&count.to_le_bytes())).unwrap_err().code, ErrorCode::Internal);
|
||||
}
|
||||
assert_eq!(adaptive::preview_divisor(&options, 17, None).unwrap_err().code, ErrorCode::Internal);
|
||||
assert_eq!(adaptive::preview_divisor(&options, 17, Some(&[0; 3])).unwrap_err().code, ErrorCode::Internal);
|
||||
assert_eq!(adaptive::preview_divisor(&options, 0, None).unwrap_err().code, ErrorCode::Internal);
|
||||
assert_eq!(adaptive::preview_divisor(&options, 81, Some(&16.0f32.to_le_bytes())).unwrap_err().code, ErrorCode::Internal);
|
||||
assert_eq!(adaptive::preview_divisor(&JobOptions::default(), 1, None).unwrap_err().code, ErrorCode::Internal);
|
||||
let shader = include_str!("adaptive_preview.wgsl");
|
||||
let module = naga::front::wgsl::parse_str(shader).unwrap_or_else(|error| panic!("{}", error.emit_to_string(shader)));
|
||||
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_adaptive_pilot_and_production_readback_never_mutates_accumulation() {
|
||||
let Some(executor) = executor() else { return };
|
||||
let options = JobOptions {
|
||||
samples: 32,
|
||||
adaptive: Some(AdaptiveSampling { pilot_samples: 16, min_samples: 16, ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let mut task =
|
||||
Task::new(executor.context.clone(), ProductScene { meshes: Vec::new(), bounds: Aabb3::EMPTY }, request(3), options, CancelToken::default())
|
||||
.unwrap();
|
||||
task.environment = 1.0;
|
||||
task.studio = 0.0;
|
||||
for ordinal in 1..=options.preview_target().unwrap() {
|
||||
task.complete_sample().unwrap();
|
||||
let before = task.read_linear().unwrap();
|
||||
let preview = task.read_checkpoint().unwrap();
|
||||
assert_eq!(
|
||||
preview,
|
||||
vec![[1.0, 1.0, 1.0, 0.0]; 9],
|
||||
"pilot means and stopped production means have the same physical normalization at iteration {ordinal}"
|
||||
);
|
||||
assert_eq!(task.read_linear().unwrap(), before, "preview readback cannot write the accumulation");
|
||||
}
|
||||
}
|
||||
|
||||
type AdaptiveOutputs = (Vec<(u32, Vec<u8>)>, Vec<u8>, Option<Vec<u8>>);
|
||||
fn adaptive_output(context: Arc<Context>, options: JobOptions, preview: bool) -> AdaptiveOutputs {
|
||||
let mut task = Task::new(context, sphere_grid(white(), 8, 16), request(8), options, CancelToken::default()).unwrap();
|
||||
task.preview_enabled = preview;
|
||||
let mut images = Vec::new();
|
||||
for _ in 0..200_000 {
|
||||
let poll = task.step(4096).unwrap();
|
||||
if let Some(image) = task.take_preview() {
|
||||
assert!(task.take_linear_exr().is_none(), "pilot/production preview never exposes an export master");
|
||||
images.push((image.samples_per_pixel, image.png));
|
||||
}
|
||||
if let TaskPoll::Complete(png) = poll {
|
||||
return (images, png, task.take_linear_exr());
|
||||
}
|
||||
}
|
||||
panic!("adaptive presentation must complete within bounded test work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_adaptive_prefix_is_displayed_and_preview_work_keeps_final_png_exr_identical_with_denoise() {
|
||||
let Some(executor) = executor() else { return };
|
||||
for denoise in [false, true] {
|
||||
let options = JobOptions {
|
||||
samples: 32,
|
||||
max_depth: 3,
|
||||
seed: 47,
|
||||
linear_exr: true,
|
||||
denoise,
|
||||
adaptive: Some(AdaptiveSampling { pilot_samples: 16, min_samples: 16, ..Default::default() }),
|
||||
finishing: solidcraft_render::job::finishing::Finishing {
|
||||
rgb_gain: [1.2, 0.9, 1.1],
|
||||
contrast: 1.1,
|
||||
bloom: Some(solidcraft_render::job::bloom::Bloom { strength: 0.2, threshold: 0.1, radius: 1 }),
|
||||
photographic: solidcraft_render::job::photographic::Photographic { grain: 0.01, seed: 17, ..Default::default() },
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let baseline = adaptive_output(executor.context.clone(), options, false);
|
||||
let shown = adaptive_output(executor.context.clone(), options, true);
|
||||
assert_eq!(
|
||||
shown.0.iter().map(|image| image.0).collect::<Vec<_>>(),
|
||||
(1..options.preview_target().unwrap()).collect::<Vec<_>>(),
|
||||
"every complete pilot and production iteration is a real image"
|
||||
);
|
||||
assert_eq!(shown.1, baseline.1, "display readbacks, normalization, effects and denoise cannot change final PNG bytes");
|
||||
assert_eq!(shown.2, baseline.2, "raw final EXR must be byte-identical with preview work enabled");
|
||||
assert!(shown.2.is_some());
|
||||
for production in [16, 31] {
|
||||
let prefix = adaptive_output(executor.context.clone(), JobOptions { samples: production, ..options }, false);
|
||||
let provisional = &shown.0.iter().find(|image| image.0 == 16 + production).unwrap().1;
|
||||
let a = image::load_from_memory(provisional).unwrap().into_rgba8();
|
||||
let b = image::load_from_memory(&prefix.1).unwrap().into_rgba8();
|
||||
// In-place GPU final division and the display-only copy may differ by one
|
||||
// display rounding unit; both represent this exact production prefix.
|
||||
assert!(
|
||||
a.as_raw().iter().zip(b.as_raw()).all(|(a, b)| a.abs_diff(*b) <= 1),
|
||||
"the normalized production prefix must match a standalone capped final, denoise={denoise}, production={production}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_during_adaptive_preview_encoding_cannot_publish_a_preview_or_master() {
|
||||
let Some(executor) = executor() else { return };
|
||||
let options = JobOptions {
|
||||
samples: 16,
|
||||
denoise: true,
|
||||
linear_exr: true,
|
||||
adaptive: Some(AdaptiveSampling { pilot_samples: 16, min_samples: 16, ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let cancel = CancelToken::default();
|
||||
let mut task = Task::new(executor.context.clone(), sphere_grid(white(), 8, 16), request(4), options, cancel.clone()).unwrap();
|
||||
task.preview_enabled = true;
|
||||
for _ in 0..100_000 {
|
||||
task.step(1).unwrap();
|
||||
if task.encoding_preview && task.png.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(task.encoding_preview && task.png.is_some());
|
||||
cancel.cancel();
|
||||
assert_eq!(task.step(1).err().unwrap().code, ErrorCode::Cancelled);
|
||||
assert!(task.take_preview().is_none());
|
||||
assert!(task.take_linear_exr().is_none());
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
struct AgingLayer {
|
||||
identity: vec4<f32>, // kind, captured body+1 (zero = all), material class, seed
|
||||
mask: vec4<f32>, // strength, coverage, inverse mm scale, upward bias
|
||||
origin: vec4<f32>, // rebased periodic field offset
|
||||
origin: vec4<f32>, // periodic field offset; w: base5 Scratch cell carries x/y/z
|
||||
color: vec4<f32>, // linear coat RGB, perceptual roughness
|
||||
finish: vec4<f32>, // metallic, reserved
|
||||
};
|
||||
@@ -25,24 +25,102 @@ fn aging_noise(point: vec3<f32>, seed: u32) -> f32 {
|
||||
mix(aging_hash(base+vec3<i32>(0,1,1),seed),aging_hash(base+vec3<i32>(1,1,1),seed),w.x),w.y);
|
||||
return mix(low,high,w.z);
|
||||
}
|
||||
// Scratch samples the same periodic noise from bounded cell/fraction parts.
|
||||
// In particular, multiplying a large q by3 before fract must not discard the
|
||||
// authored fine phase merely because the camera moved by an integer period.
|
||||
fn aging_floor_mod4096(value:f32)->u32 {
|
||||
let bits=bitcast<u32>(floor(value));
|
||||
let biased=(bits>>23u)&255u;
|
||||
if biased<127u {return 0u;}
|
||||
let exponent=biased-127u;
|
||||
let significand=(bits&0x7fffffu)|0x800000u;
|
||||
var cell_index=0u;
|
||||
if exponent<23u {
|
||||
cell_index=(significand>>(23u-exponent))&4095u;
|
||||
} else if exponent<35u {
|
||||
cell_index=(significand<<(exponent-23u))&4095u;
|
||||
}
|
||||
if (bits&0x80000000u)!=0u {return (4096u-cell_index)&4095u;}
|
||||
return cell_index;
|
||||
}
|
||||
fn aging_noise_parts(base:vec3<i32>,f:vec3<f32>,seed:u32)->f32 {
|
||||
let w=f*f*(vec3<f32>(3.0)-2.0*f);
|
||||
let low=mix(mix(aging_hash(base,seed),aging_hash(base+vec3<i32>(1,0,0),seed),w.x),
|
||||
mix(aging_hash(base+vec3<i32>(0,1,0),seed),aging_hash(base+vec3<i32>(1,1,0),seed),w.x),w.y);
|
||||
let high=mix(mix(aging_hash(base+vec3<i32>(0,0,1),seed),aging_hash(base+vec3<i32>(1,0,1),seed),w.x),
|
||||
mix(aging_hash(base+vec3<i32>(0,1,1),seed),aging_hash(base+vec3<i32>(1,1,1),seed),w.x),w.y);
|
||||
return mix(low,high,w.z);
|
||||
}
|
||||
// Exact modulo of the represented f32 integer floor. Avoid i32 saturation
|
||||
// when admitted world/camera offsets are large compared with the layer scale.
|
||||
// The ordinary (<2^23) field path uses one shift and remainder, no loop.
|
||||
fn aging_floor_mod5120(value:f32)->u32 {
|
||||
let bits=bitcast<u32>(floor(value));
|
||||
let biased=(bits>>23u)&255u;
|
||||
if biased<127u {return 0u;}
|
||||
let exponent=biased-127u;
|
||||
let significand=(bits&0x7fffffu)|0x800000u;
|
||||
var cell_index=0u;
|
||||
if exponent<23u {
|
||||
cell_index=(significand>>(23u-exponent))%5120u;
|
||||
} else {
|
||||
cell_index=significand%5120u;
|
||||
// A finite f32 has at most104 integer bits beyond its significand.
|
||||
// The1e7 mm geometry rebase and0.01 mm minimum scale bound
|
||||
// admitted q near1e9 cells, requiring at most6 iterations.
|
||||
for(var shift=0u;shift<104u;shift+=1u) {
|
||||
if shift>=exponent-23u {break;}
|
||||
cell_index=(cell_index*2u)%5120u;
|
||||
}
|
||||
}
|
||||
if (bits&0x80000000u)!=0u && cell_index!=0u {return 5120u-cell_index;}
|
||||
return cell_index;
|
||||
}
|
||||
fn aging_scratch_band(value:f32, camera_cell:u32)->u32 {
|
||||
// floor(4*Q/5) repeats every5120 Q cells. Preserve its integer phase
|
||||
// separately from noise's4096-cell quotient; never form a20480-cell float.
|
||||
let cell_index=(aging_floor_mod5120(value)+4096u*camera_cell)%5120u;
|
||||
let numerator=4u*cell_index;
|
||||
let whole=numerator/5u;
|
||||
// Remainder0/1 cannot cross a band; the other exact thresholds are
|
||||
// 3/4,1/2,1/4. Integer/fraction separation avoids 0.8*q rounding a
|
||||
// value just below a boundary into the next band.
|
||||
let threshold=f32(5u-numerator%5u)*0.25;
|
||||
return (whole+select(0u,1u,fract(value)>=threshold))&4095u;
|
||||
}
|
||||
fn aging_mask(layer: AgingLayer, point:vec3<f32>, normal:vec3<f32>)->vec2<f32> {
|
||||
let q=point*layer.mask.z+layer.origin.xyz;
|
||||
let seed=u32(layer.identity.w);
|
||||
let coarse=aging_noise(q,seed);
|
||||
let fine=aging_noise(q*3.0+vec3<f32>(19.0,7.0,3.0),seed+1u);
|
||||
let kind=u32(layer.identity.x);
|
||||
var coarse=0.0;
|
||||
var fine=0.0;
|
||||
if kind==5u {
|
||||
let cell=vec3<u32>(aging_floor_mod4096(q.x),aging_floor_mod4096(q.y),aging_floor_mod4096(q.z));
|
||||
let fraction=fract(q);
|
||||
let scaled_fraction=fraction*3.0;
|
||||
let fine_cell=(cell*3u+vec3<u32>(floor(scaled_fraction))+vec3<u32>(19u,7u,3u))&vec3<u32>(4095u);
|
||||
coarse=aging_noise_parts(vec3<i32>(cell),fraction,seed);
|
||||
fine=aging_noise_parts(vec3<i32>(fine_cell),fract(scaled_fraction),seed+1u);
|
||||
} else {
|
||||
coarse=aging_noise(q,seed);
|
||||
fine=aging_noise(q*3.0+vec3<f32>(19.0,7.0,3.0),seed+1u);
|
||||
}
|
||||
let field=coarse*0.75+fine*0.25;
|
||||
let threshold=1.0-layer.mask.y;
|
||||
var coverage=smoothstep(threshold-0.09,threshold+0.09,field);
|
||||
if layer.mask.y>=1.0 {coverage=1.0;}
|
||||
let kind=u32(layer.identity.x);
|
||||
if kind==5u {
|
||||
// Long shallow visual scratches in a dominant-axis projection. They do
|
||||
// not create grooves, silhouettes, cavity masks or geometric curvature.
|
||||
let n=abs(normal);
|
||||
var uv=q.xy;
|
||||
if n.x>n.y && n.x>n.z {uv=q.yz;} else if n.y>n.z {uv=q.xz;}
|
||||
let band=i32(floor(uv.y*0.8));
|
||||
let line=abs(fract(uv.x*24.0+aging_hash(vec3<i32>(band,0,0),seed)*0.7)-0.5);
|
||||
let phase_code=u32(layer.origin.w);
|
||||
var camera_cell=(phase_code/5u)%5u;
|
||||
if n.x>n.y && n.x>n.z {uv=q.yz;camera_cell=phase_code/25u;} else if n.y>n.z {uv=q.xz;camera_cell=phase_code/25u;}
|
||||
let band=i32(aging_scratch_band(uv.y,camera_cell));
|
||||
// The discarded whole u contributes an integer24 phase. Reducing
|
||||
// before multiplication preserves the shallow line at distant origins.
|
||||
let line=abs(fract(fract(uv.x)*24.0+aging_hash(vec3<i32>(band,0,0),seed)*0.7)-0.5);
|
||||
coverage*=1.0-smoothstep(0.025,0.09,line);
|
||||
coverage*=smoothstep(0.18,0.48,fine);
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ impl Pipelines {
|
||||
}
|
||||
|
||||
pub(super) struct Resources {
|
||||
adaptive: Option<AdaptiveInput>,
|
||||
guide_pipeline: wgpu::ComputePipeline,
|
||||
guide_bind: wgpu::BindGroup,
|
||||
filter_pipeline: wgpu::ComputePipeline,
|
||||
@@ -99,6 +100,7 @@ impl Resources {
|
||||
scene: [&wgpu::Buffer; 5],
|
||||
accumulation: &wgpu::Buffer,
|
||||
size: u64,
|
||||
adaptive_preview: bool,
|
||||
) -> Result<Self> {
|
||||
let pipelines = Pipelines::shared(context)?;
|
||||
let device = &context.device;
|
||||
@@ -142,8 +144,10 @@ impl Resources {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let adaptive = adaptive_preview.then(|| AdaptiveInput::new(context, pipelines, accumulation, &guides, &filter_uniform, &output, size));
|
||||
checked(error_scopes)?;
|
||||
Ok(Self {
|
||||
adaptive,
|
||||
guide_pipeline: pipelines.guide.clone(),
|
||||
guide_bind,
|
||||
filter_pipeline: pipelines.filter.clone(),
|
||||
@@ -159,13 +163,20 @@ impl Resources {
|
||||
}
|
||||
|
||||
pub(super) fn run(&self, task: &Task) -> Result<()> {
|
||||
self.run_with_input(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 run_with_input(&self, task: &Task, preview: bool) -> Result<()> {
|
||||
let pixels = task.request.width * task.request.height;
|
||||
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 parameters = task.parameters(count);
|
||||
let mut parameters = task.parameters(count)?;
|
||||
if let Some(bytes) = parameters.get_mut(12..16) {
|
||||
bytes.copy_from_slice(&offset.to_le_bytes());
|
||||
}
|
||||
@@ -175,12 +186,17 @@ impl Resources {
|
||||
budget.observe(count, started.elapsed());
|
||||
offset += count;
|
||||
}
|
||||
self.run_filter(task)
|
||||
self.run_filter(task, preview)
|
||||
}
|
||||
|
||||
fn run_filter(&self, task: &Task) -> Result<()> {
|
||||
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() {
|
||||
let bind = if preview && pass == 0 {
|
||||
&self.adaptive.as_ref().ok_or_else(|| super::gpu("missing adaptive denoise preview input"))?.filter_bind
|
||||
} else {
|
||||
bind
|
||||
};
|
||||
let mut budget = super::dispatch::Budget::default();
|
||||
let mut offset = 0;
|
||||
while offset < pixels {
|
||||
@@ -201,6 +217,112 @@ impl Resources {
|
||||
}
|
||||
}
|
||||
|
||||
/// A distinct normalized input for adaptive display only. The final filter stays bound
|
||||
/// to the original production accumulation, and this pass never writes that film.
|
||||
struct AdaptiveInput {
|
||||
_pixels: wgpu::Buffer,
|
||||
uniform: wgpu::Buffer,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
bind: wgpu::BindGroup,
|
||||
filter_bind: wgpu::BindGroup,
|
||||
}
|
||||
impl AdaptiveInput {
|
||||
fn new(
|
||||
context: &Context,
|
||||
pipelines: &Pipelines,
|
||||
accumulation: &wgpu::Buffer,
|
||||
guides: &wgpu::Buffer,
|
||||
filter_uniform: &wgpu::Buffer,
|
||||
output: &wgpu::Buffer,
|
||||
size: u64,
|
||||
) -> Self {
|
||||
let device = &context.device;
|
||||
let pixels = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("adaptive preview denoise input"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("adaptive preview parameters"),
|
||||
contents: &[0; 32],
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let entries: Vec<_> = (0..3)
|
||||
.map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: if binding == 0 { wgpu::BufferBindingType::Uniform } else { wgpu::BufferBindingType::Storage { read_only: binding == 1 } },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
})
|
||||
.collect();
|
||||
let layout =
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("adaptive preview normalization"), entries: &entries });
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("adaptive preview normalization"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("adaptive preview normalization"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("adaptive_preview.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("adaptive preview normalization"),
|
||||
layout: Some(&pipeline_layout),
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let entries: Vec<_> = [&uniform, accumulation, &pixels]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(binding, buffer)| wgpu::BindGroupEntry { binding: binding as u32, resource: buffer.as_entire_binding() })
|
||||
.collect();
|
||||
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("adaptive preview normalization"),
|
||||
layout: &layout,
|
||||
entries: &entries,
|
||||
});
|
||||
let entries: Vec<_> = [filter_uniform, guides, &pixels, output]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(binding, buffer)| wgpu::BindGroupEntry { binding: binding as u32, resource: buffer.as_entire_binding() })
|
||||
.collect();
|
||||
let filter_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("adaptive preview filter"),
|
||||
layout: &pipelines.filter_layout,
|
||||
entries: &entries,
|
||||
});
|
||||
Self { _pixels: pixels, uniform, pipeline, bind, filter_bind }
|
||||
}
|
||||
fn normalize(&self, task: &Task) -> Result<()> {
|
||||
let options = task.options.adaptive.ok_or_else(|| super::gpu("missing adaptive preview options"))?;
|
||||
let pixels = task.request.width * task.request.height;
|
||||
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] {
|
||||
bytes.extend_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
task.context.queue.write_buffer(&self.uniform, 0, &bytes);
|
||||
let started = std::time::Instant::now();
|
||||
dispatch(&task.context, &self.pipeline, &self.bind, count)?;
|
||||
budget.observe(count, started.elapsed());
|
||||
offset += count;
|
||||
}
|
||||
task.cancel.check()
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(context: &Context, pipeline: &wgpu::ComputePipeline, bind: &wgpu::BindGroup, count: u32) -> Result<()> {
|
||||
context.ensure_available()?;
|
||||
let error_scopes = scopes(&context.device);
|
||||
|
||||
@@ -264,11 +264,11 @@ fn gpu_atrous_matches_f64_guides_and_leaves_input_unchanged() {
|
||||
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 filter = Resources::new(&context, &task.uniform, [&dummy, &dummy, &dummy, &dummy, &dummy], &raw, bytes.len() as u64).unwrap();
|
||||
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).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;
|
||||
|
||||
@@ -154,7 +154,7 @@ fn weathering_reference(@builtin(global_invocation_id) gid:vec3<u32>){
|
||||
let mut task =
|
||||
Task::new(context.clone(), sphere(white()), request(2), JobOptions { aging: stack, ..Default::default() }, CancelToken::default())
|
||||
.unwrap();
|
||||
context.queue.write_buffer(&task.uniform, 0, &task.parameters(4));
|
||||
context.queue.write_buffer(&task.uniform, 0, &task.parameters(4).unwrap());
|
||||
let mut encoder = context.device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
@@ -313,3 +313,602 @@ fn saved_aging_identity_reaches_real_gpu_after_cold_reopen() {
|
||||
assert!(session.execute(command, &json!({"setup":"Aging"})).unwrap_err().to_string().contains("hidden"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_world_point_is_invariant_when_only_camera_target_crosses_a_noise_period() {
|
||||
let Some(executor) = executor() else { return };
|
||||
let context = executor.context;
|
||||
let mut stack = AgingStack::default();
|
||||
stack
|
||||
.push(AgingLayer {
|
||||
kind: AgingKind::Scratches,
|
||||
scale_mm: 2.0,
|
||||
strength: 1.0,
|
||||
coverage: 1.0,
|
||||
upward_bias: 0.0,
|
||||
color_srgb: [255, 0, 0],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let mut images = Vec::new();
|
||||
for target_y in [0.0, 8192.0] {
|
||||
// Every point/translation is dyadic and exactly representable in f32.
|
||||
// Only the camera's world origin changes; no geometry, RNG or tolerance
|
||||
// changes can explain a different authored Scratch band at that point.
|
||||
let reference = format!(
|
||||
r#"
|
||||
@compute @workgroup_size(64)
|
||||
fn scratch_world_reference(@builtin(global_invocation_id) gid:vec3<u32>){{
|
||||
if gid.x>=16u{{return;}}
|
||||
let world=vec3<f32>(2.0*f32(gid.x%4u)/128.0,2.0*(2.875+1.25*f32(gid.x/4u)),0.0);
|
||||
let relative=world-vec3<f32>(0.0,{target_y:.1},0.0);
|
||||
let result=aged_surface(triangle(0u),relative,vec3<f32>(0.0,0.0,1.0));
|
||||
accumulation[gid.x]=vec4<f32>(result.base.xyz,1.0);
|
||||
}}
|
||||
"#
|
||||
);
|
||||
let shader = format!("{SHADER}\n{reference}");
|
||||
let module =
|
||||
context.device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(shader.into()) });
|
||||
let layout = context.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &[Some(&context.layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = context.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
module: &module,
|
||||
entry_point: Some("scratch_world_reference"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let mut req = request(4);
|
||||
req.camera.target = solidcore_record::Vec3::new(0.0, target_y, 0.0);
|
||||
let mut task =
|
||||
Task::new(context.clone(), sphere(white()), req, JobOptions { aging: stack, ..Default::default() }, CancelToken::default()).unwrap();
|
||||
context.queue.write_buffer(&task.uniform, 0, &task.parameters(16).unwrap());
|
||||
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;
|
||||
images.push(task.read_linear().unwrap());
|
||||
}
|
||||
assert!(images[0].iter().any(|pixel| pixel[1] < 0.99), "the fixed world-point oracle must include visible Scratch coverage");
|
||||
assert_eq!(images[0], images[1], "moving only the camera may not select different world-anchored Scratch bands");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_integer_band_matches_independent_f64_boundaries_and_signed_large_cells() {
|
||||
let Some(executor) = executor() else { return };
|
||||
let context = executor.context;
|
||||
let mut cases = Vec::new();
|
||||
for band in -256_i32..=256 {
|
||||
let point = band as f32 * 1.25;
|
||||
let bits = point.to_bits();
|
||||
let neighbours = if point == 0.0 {
|
||||
[-1.0 / 1_048_576.0, 0.0, 1.0 / 1_048_576.0]
|
||||
} else if point > 0.0 {
|
||||
[f32::from_bits(bits - 1), point, f32::from_bits(bits + 1)]
|
||||
} else {
|
||||
[f32::from_bits(bits + 1), point, f32::from_bits(bits - 1)]
|
||||
};
|
||||
for value in neighbours {
|
||||
for carry in 0..5_u32 {
|
||||
cases.push([value.to_bits(), carry]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for point in [-200000000000.0_f32, -1000000000.0, -16777218.0, -5120.25, 4095.9998, 5120.25, 16777218.0, 1000000000.0, 200000000000.0] {
|
||||
for carry in 0..5_u32 {
|
||||
cases.push([point.to_bits(), carry]);
|
||||
}
|
||||
}
|
||||
let mut random = 0xa903c717_u32;
|
||||
while cases.len() < 8192 {
|
||||
random = random.wrapping_mul(1664525).wrapping_add(1013904223);
|
||||
let exponent = 110 + (random >> 8) % 54;
|
||||
let bits = (random & 0x807fffff) | (exponent << 23);
|
||||
cases.push([bits, random % 5]);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(4 + cases.len() * 8);
|
||||
bytes.extend((cases.len() as u32).to_le_bytes());
|
||||
for case in &cases {
|
||||
for word in case {
|
||||
bytes.extend(word.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let input = context.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("independent Scratch band inputs"),
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
});
|
||||
let source = format!(
|
||||
"{SHADER}\n{}",
|
||||
r#"
|
||||
@compute @workgroup_size(32)
|
||||
fn scratch_band_reference(@builtin(global_invocation_id) gid:vec3<u32>){
|
||||
if gid.x>=geometry[0]{return;}
|
||||
let point=bitcast<f32>(geometry[1u+2u*gid.x]);
|
||||
let carry=geometry[2u+2u*gid.x];
|
||||
accumulation[gid.x]=vec4<f32>(f32(aging_scratch_band(point,carry)),f32(aging_floor_mod5120(point)),f32(aging_floor_mod4096(point)),1.0);
|
||||
}
|
||||
"#
|
||||
);
|
||||
let module = context.device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(source.into()) });
|
||||
let pipeline = context.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
module: &module,
|
||||
entry_point: Some("scratch_band_reference"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let mut task = Task::new(context.clone(), sphere(white()), request(128), JobOptions::default(), CancelToken::default()).unwrap();
|
||||
let bind = context.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 1, resource: input.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 3, resource: task.accumulation.as_entire_binding() },
|
||||
],
|
||||
});
|
||||
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, &bind, &[]);
|
||||
pass.dispatch_workgroups((cases.len() as u32).div_ceil(32), 1, 1);
|
||||
}
|
||||
context.queue.submit([encoder.finish()]);
|
||||
task.sample = 1;
|
||||
let output = task.read_linear().unwrap();
|
||||
for (index, ([point, carry], actual)) in cases.iter().zip(output).enumerate() {
|
||||
let point = f64::from(f32::from_bits(*point));
|
||||
// Independent real-coordinate arithmetic, not shader cell decomposition.
|
||||
// All inputs are within2e11: f64 resolves the represented f32 fractions
|
||||
// and the4/5 floor boundaries without the shader's integer algorithm.
|
||||
let expected = ((point + 4096.0 * f64::from(*carry)) * 4.0 / 5.0).floor().rem_euclid(4096.0) as f32;
|
||||
assert_eq!(actual[0], expected, "band case={index}, point={point}, carry={carry}");
|
||||
assert_eq!(actual[1], point.floor().rem_euclid(5120.0) as f32, "cell5120 case={index}, point={point}");
|
||||
assert_eq!(actual[2], point.floor().rem_euclid(4096.0) as f32, "cell4096 case={index}, point={point}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_analytic_triangle_hits_keep_world_phase_for_signed_camera_and_world_origins() {
|
||||
use solidcraft_geom::Mesh;
|
||||
use solidcraft_render::ProductMesh;
|
||||
let Some(executor) = executor() else { return };
|
||||
let context = executor.context;
|
||||
let source = format!(
|
||||
"{SHADER}\n{}",
|
||||
r#"
|
||||
@compute @workgroup_size(32)
|
||||
fn scratch_geometry_reference(@builtin(global_invocation_id) gid:vec3<u32>){
|
||||
if gid.x>=16u{return;}
|
||||
let t=triangle(0u);
|
||||
let bary=vec3<f32>(1.0-f32(gid.x%4u+1u)/16.0-f32(gid.x/4u+1u)/16.0,f32(gid.x%4u+1u)/16.0,f32(gid.x/4u+1u)/16.0);
|
||||
let point=surface_point(t,bary);
|
||||
let normal=t.n0.xyz;
|
||||
let hit=trace(point+normal*2.0,-normal,false);
|
||||
if hit.triangle!=0u {accumulation[gid.x]=vec4<f32>(-1.0);return;}
|
||||
let at=surface_point(t,hit.bary);
|
||||
let result=aged_surface(t,at,normal);
|
||||
accumulation[gid.x]=vec4<f32>(result.base.xyz,1.0);
|
||||
accumulation[gid.x+16u]=vec4<f32>(hit.distance,hit.bary.yz,1.0);
|
||||
let q=at*u.aging[0].mask.z+u.aging[0].origin.xyz;
|
||||
let n=abs(normal);var uv=q.xy;
|
||||
let phase_code=u32(u.aging[0].origin.w);var carry=(phase_code/5u)%5u;
|
||||
if n.x>n.y && n.x>n.z {uv=q.yz;carry=phase_code/25u;} else if n.y>n.z {uv=q.xz;carry=phase_code/25u;}
|
||||
accumulation[gid.x+32u]=vec4<f32>(f32(aging_scratch_band(uv.y,carry)),f32(aging_floor_mod4096(uv.x)),f32(aging_floor_mod4096(uv.y)),1.0);
|
||||
}
|
||||
"#
|
||||
);
|
||||
let module = context.device.create_shader_module(wgpu::ShaderModuleDescriptor { label: None, source: wgpu::ShaderSource::Wgsl(source.into()) });
|
||||
let layout = context.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &[Some(&context.layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = context.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
module: &module,
|
||||
entry_point: Some("scratch_geometry_reference"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let mut visible = false;
|
||||
for world in [0.0, 999000000.0, -999000000.0] {
|
||||
for anchored_origin in [false, true] {
|
||||
let origin = if anchored_origin { [world; 3] } else { [0.0; 3] };
|
||||
for axis in 0..3 {
|
||||
let (u_axis, v_axis) = match axis {
|
||||
0 => (1, 2),
|
||||
1 => (0, 2),
|
||||
_ => (0, 1),
|
||||
};
|
||||
let mut local = [0.0; 3];
|
||||
local[v_axis] = 5.4375;
|
||||
let p0 = Vec3::new(world + local[0], world + local[1], world + local[2]);
|
||||
let mut along_u = [0.0; 3];
|
||||
along_u[u_axis] = 0.25;
|
||||
let mut along_v = [0.0; 3];
|
||||
along_v[v_axis] = 5.0;
|
||||
let u_edge = Vec3::new(along_u[0], along_u[1], along_u[2]);
|
||||
let v_edge = Vec3::new(along_v[0], along_v[1], along_v[2]);
|
||||
let normal = u_edge.cross(v_edge).normalized().unwrap();
|
||||
let mesh = Mesh {
|
||||
positions: vec![p0, p0 + u_edge, p0 + v_edge],
|
||||
normals: vec![normal; 3],
|
||||
triangles: vec![[0, 1, 2]],
|
||||
tri_face: vec![0],
|
||||
..Default::default()
|
||||
};
|
||||
let bounds = mesh.bounds();
|
||||
let scene = ProductScene {
|
||||
meshes: vec![ProductMesh {
|
||||
name: "Scratch analytic triangle".into(),
|
||||
mesh: Arc::new(mesh),
|
||||
color: [255; 3],
|
||||
opacity: 1.0,
|
||||
surface: Some(white()),
|
||||
transmission: None,
|
||||
faces: Vec::new(),
|
||||
}],
|
||||
bounds,
|
||||
};
|
||||
let mut stack = AgingStack::default();
|
||||
stack
|
||||
.push(AgingLayer {
|
||||
kind: AgingKind::Scratches,
|
||||
scale_mm: 2.0,
|
||||
strength: 1.0,
|
||||
coverage: 1.0,
|
||||
upward_bias: 0.0,
|
||||
color_srgb: [255, 0, 0],
|
||||
origin_mm: origin,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let mut baseline: Option<Vec<[f32; 4]>> = None;
|
||||
for cell in [0_i32, -4, -3, -2, -1, 1, 2, 3, 4] {
|
||||
let target =
|
||||
Vec3::new(world + 8192.0 * f64::from(cell), world + 8192.0 * f64::from(2 * cell), world + 8192.0 * f64::from(3 * cell));
|
||||
let mut req = request(8);
|
||||
req.camera.target = target;
|
||||
let mut task =
|
||||
Task::new(context.clone(), scene.clone(), req, JobOptions { aging: stack, ..Default::default() }, CancelToken::default())
|
||||
.unwrap();
|
||||
let parameters = task.parameters(64).unwrap();
|
||||
assert_eq!(parameters.len(), PARAMETER_BYTES);
|
||||
// Thirty-three aligned vec4s precede the aging array. This
|
||||
// asserts actual host serialization, not just gpu_layers().
|
||||
let carry_offset = 33 * 16 + 11 * 4;
|
||||
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");
|
||||
context.queue.write_buffer(&task.uniform, 0, ¶meters);
|
||||
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();
|
||||
for index in 0..16 {
|
||||
let u = (index % 4 + 1) as f64 / 16.0;
|
||||
let v = (index / 4 + 1) as f64 / 16.0;
|
||||
assert_eq!(
|
||||
pixels[index + 16],
|
||||
[2.0, u as f32, v as f32, 1.0],
|
||||
"analytic hit world={world}, origin={origin:?}, axis={axis}, cell={cell}, index={index}"
|
||||
);
|
||||
let point = p0 + u_edge * u + v_edge * v;
|
||||
let coordinates = [(point.x - origin[0]) / 2.0, (point.y - origin[1]) / 2.0, (point.z - origin[2]) / 2.0];
|
||||
let band = (coordinates[v_axis] * 4.0 / 5.0).floor().rem_euclid(4096.0) as f32;
|
||||
assert_eq!(
|
||||
pixels[index + 32],
|
||||
[band, coordinates[u_axis].floor().rem_euclid(4096.0) as f32, coordinates[v_axis].floor().rem_euclid(4096.0) as f32, 1.0],
|
||||
"independent world phase world={world}, origin={origin:?}, axis={axis}, cell={cell}, index={index}"
|
||||
);
|
||||
}
|
||||
visible |= pixels[..16].iter().any(|pixel| pixel[1] < 0.99);
|
||||
if let Some(reference) = &baseline {
|
||||
assert_eq!(&pixels[..16], reference, "camera-only Scratch change world={world}, origin={origin:?}, axis={axis}, cell={cell}");
|
||||
} else {
|
||||
baseline = Some(pixels[..16].to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(visible, "the analytic world-point fixture must contain visible Scratch coverage");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_scratch_layers_preserve_exact_default_pixels_raw_master_stream_and_admission() {
|
||||
let Some(executor) = executor() else { return };
|
||||
let options = JobOptions { samples: 4, max_depth: 3, seed: 21026, linear_exr: true, ..Default::default() };
|
||||
let baseline = complete_linear_fixture(executor.context.clone(), options);
|
||||
let original = admission(&sphere(white()), &request(16), options).unwrap();
|
||||
for layer in [
|
||||
AgingLayer { kind: AgingKind::Scratches, enabled: false, ..Default::default() },
|
||||
AgingLayer { kind: AgingKind::Scratches, strength: 0.0, ..Default::default() },
|
||||
AgingLayer { kind: AgingKind::Scratches, coverage: 0.0, ..Default::default() },
|
||||
] {
|
||||
let mut aging = AgingStack::default();
|
||||
aging.push(layer).unwrap();
|
||||
let current = JobOptions { aging, ..options };
|
||||
assert_eq!(
|
||||
complete_linear_fixture(executor.context.clone(), current),
|
||||
baseline,
|
||||
"inactive Scratch cannot change PNG/raw bytes or the absolute sample stream"
|
||||
);
|
||||
let admitted = admission(&sphere(white()), &request(16), current).unwrap();
|
||||
assert_eq!(admitted.estimated_total_bytes, original.estimated_total_bytes);
|
||||
assert_eq!(admitted.preparation_bytes, original.preparation_bytes);
|
||||
assert_eq!(admitted.pixel_scratch_bytes, original.pixel_scratch_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scratch_keeps_visible_analytic_emission_and_its_raw_master_exact() {
|
||||
use solidcraft_render::job::{AreaLight, AreaLights, StudioLighting};
|
||||
let Some(executor) = executor() else { return };
|
||||
let source = ProductScene { meshes: Vec::new(), bounds: solidcraft_geom::Aabb3::EMPTY };
|
||||
let light = AreaLight { center: [0.0; 3], u: [10.0, 0.0, 0.0], v: [0.0, 10.0, 0.0], radiance: [1.5, 0.5, 0.25] };
|
||||
let complete = |aging| {
|
||||
let options = JobOptions {
|
||||
samples: 4,
|
||||
max_depth: 2,
|
||||
seed: 21026,
|
||||
linear_exr: true,
|
||||
aging,
|
||||
lighting: Some(StudioLighting {
|
||||
environment: [0.0; 3],
|
||||
sun: None,
|
||||
area_lights: AreaLights::new(&[light]).unwrap(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let mut task = Task::new(executor.context.clone(), source.clone(), request(8), options, CancelToken::default()).unwrap();
|
||||
for _ in 0..5000 {
|
||||
if let TaskPoll::Complete(png) = task.step(16384).unwrap() {
|
||||
for pixel in task.read_linear().unwrap() {
|
||||
assert_eq!(pixel, [1.5, 0.5, 0.25, 1.0], "authored analytic-emitter radiance/coverage must remain exact");
|
||||
}
|
||||
return (png, task.take_linear_exr().unwrap());
|
||||
}
|
||||
}
|
||||
panic!("bounded emission fixture did not complete")
|
||||
};
|
||||
let baseline = complete(AgingStack::default());
|
||||
let mut aging = AgingStack::default();
|
||||
aging.push(AgingLayer { kind: AgingKind::Scratches, scale_mm: 2.0, strength: 1.0, coverage: 1.0, ..Default::default() }).unwrap();
|
||||
assert_eq!(complete(aging), baseline, "an optical surface layer cannot alter visible analytic emission, PNG/raw identity or its sample stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_coat_gpu_capture_reopen_and_actual_render_match_independent_normal_incidence_layer() {
|
||||
// Independent OpenEXR scanline/FLOAT reader for this fully covered1×1 oracle.
|
||||
// No production encoder/decoder or optional image codec participates.
|
||||
fn raw_pixel(bytes: &[u8]) -> [f32; 4] {
|
||||
fn word(bytes: &[u8], at: usize) -> u32 {
|
||||
u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap())
|
||||
}
|
||||
fn string<'a>(bytes: &'a [u8], at: &mut usize) -> &'a [u8] {
|
||||
let start = *at;
|
||||
*at += bytes[start..].iter().position(|b| *b == 0).unwrap() + 1;
|
||||
&bytes[start..*at - 1]
|
||||
}
|
||||
assert_eq!(word(bytes, 0), 20_000_630);
|
||||
assert_eq!(word(bytes, 4), 2);
|
||||
let mut at = 8;
|
||||
let (mut channels, mut window, mut compression) = (false, false, false);
|
||||
while bytes[at] != 0 {
|
||||
let name = string(bytes, &mut at);
|
||||
let kind = string(bytes, &mut at);
|
||||
let length = word(bytes, at) as usize;
|
||||
at += 4;
|
||||
let data = &bytes[at..at + length];
|
||||
at += length;
|
||||
match name {
|
||||
b"channels" => {
|
||||
assert_eq!(kind, b"chlist");
|
||||
let mut c = 0;
|
||||
for expected in [b"A", b"B", b"G", b"R"] {
|
||||
assert_eq!(string(data, &mut c), expected);
|
||||
assert_eq!(word(data, c), 2); // FLOAT, never HALF
|
||||
assert_eq!(&data[c + 4..c + 8], &[0; 4]);
|
||||
assert_eq!((word(data, c + 8), word(data, c + 12)), (1, 1));
|
||||
c += 16;
|
||||
}
|
||||
assert_eq!(&data[c..], &[0]);
|
||||
channels = true;
|
||||
}
|
||||
b"dataWindow" => {
|
||||
assert_eq!(kind, b"box2i");
|
||||
assert_eq!(data, &[0; 16]);
|
||||
window = true;
|
||||
}
|
||||
b"compression" => {
|
||||
assert_eq!(kind, b"compression");
|
||||
assert_eq!(data, &[0]);
|
||||
compression = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(channels && window && compression);
|
||||
at += 1;
|
||||
let offset = u64::from_le_bytes(bytes[at..at + 8].try_into().unwrap()) as usize;
|
||||
assert_eq!((word(bytes, offset), word(bytes, offset + 4)), (0, 16));
|
||||
let channel = |i: usize| f32::from_bits(word(bytes, offset + 8 + i * 4));
|
||||
[channel(3), channel(2), channel(1), channel(0)]
|
||||
}
|
||||
use std::f64::consts::{FRAC_PI_2, PI, SQRT_2};
|
||||
let Some(executor) = executor() else { return };
|
||||
let mut session = solidcraft_engine::Session::default();
|
||||
session.execute("solid.box", &json!({"body_name":"Panel","corner":[-10,-10,-2],"length":20,"width":20,"height":2})).unwrap();
|
||||
session
|
||||
.execute(
|
||||
"appearance.assign",
|
||||
&json!({"bodies":["Panel"],"name":"Analytic substrate","color":[128,64,32],
|
||||
"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1}}),
|
||||
)
|
||||
.unwrap();
|
||||
let camera = Camera { target: Vec3::ZERO, distance: 1.0, yaw: 0.0, pitch: FRAC_PI_2, roll: 0.0, fov: 0.0, ..Default::default() };
|
||||
session
|
||||
.execute(
|
||||
"render.coat.bind",
|
||||
&json!({"name":"Coat","body_index":0,"body_name":"Panel","expected_revision":session.revision,
|
||||
"coat":{"weight":0.63,"roughness":0.61,"ior":1.7},"set":{"camera":camera,"width":1,"height":1,"job":{"method":"gpu_path","options":{
|
||||
"samples":4,"seed":81,"max_depth":1,"linear_exr":true,"lighting":{"environment":[0,0,0],"sun":null,"area_lights":[],
|
||||
"directional_lights":[{"direction":[0,0,1],"color":[1,1,1],"intensity":PI}]}}}}}),
|
||||
)
|
||||
.unwrap();
|
||||
let container = solidcraft_io::write_design_container(&session.doc).unwrap();
|
||||
session = solidcraft_engine::Session::new(solidcraft_io::read_design(&container).unwrap());
|
||||
session.execute("render.scene", &json!({})).unwrap();
|
||||
let host = ImageHost::with_executor(Arc::new(executor)).unwrap();
|
||||
session.register_render_job_host(host.clone()).unwrap();
|
||||
let document = session.doc.clone();
|
||||
let model = session.model.state();
|
||||
let evaluation_cache = session.model.cache.clone();
|
||||
let world = session.world_state().unwrap();
|
||||
let session_camera = session.camera;
|
||||
let history = (session.revision, session.model.recomputed, session.undo.len(), session.redo.len());
|
||||
let checked = session.execute("render.preflight", &json!({"setup":"Coat"})).unwrap();
|
||||
assert_eq!(checked["coats"], json!(session.doc.render.setups["Coat"].coats));
|
||||
assert_eq!(checked["resolved"]["executor"], "gpu-path");
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
let complete = |session: &mut solidcraft_engine::Session, settings: serde_json::Value| {
|
||||
let start = session.execute("render.start", &json!({"settings":settings})).unwrap();
|
||||
let id: JobId = serde_json::from_value(start["job"].clone()).unwrap();
|
||||
let handle = session.render_job_handle(id).unwrap();
|
||||
let status = handle.wait_timeout(Duration::from_secs(60)).unwrap();
|
||||
assert_eq!(status.progress.phase, Phase::Completed, "{status:?}");
|
||||
handle.frame().unwrap().unwrap()
|
||||
};
|
||||
let saved = session.doc.render.setups["Coat"].clone();
|
||||
let coated = complete(&mut session, json!(saved));
|
||||
let raw = raw_pixel(coated.linear_exr().unwrap());
|
||||
assert_eq!(raw[3], 1.0);
|
||||
// Independent normal-incidence dielectric GGX coat and its documented
|
||||
// conservative entry/exit attenuation, over an IOR1 Lambert substrate.
|
||||
let f0 = ((1.7_f64 - 1.0) / (1.7 + 1.0)).powi(2);
|
||||
let bound = f0 + (1.0 - f0) * (1.0 - 1.0 / SQRT_2).powi(5);
|
||||
let attenuation = (1.0 - 0.63 * bound).powi(2);
|
||||
let highlight = 0.63 * f0 / (4.0 * 0.61_f64.powi(4));
|
||||
let expected = [128_u8, 64, 32].map(|code| {
|
||||
let encoded = f64::from(code) / 255.0;
|
||||
let linear = if encoded <= 0.04045 { encoded / 12.92 } else { ((encoded + 0.055) / 1.055).powf(2.4) };
|
||||
linear * attenuation + highlight
|
||||
});
|
||||
for (actual, expected) in raw[..3].iter().zip(expected) {
|
||||
assert!((f64::from(*actual) - expected).abs() < 2e-6, "GPU coat radiance {actual} vs independent {expected}");
|
||||
}
|
||||
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!(checked["retained_options_bytes"], baseline_check["retained_options_bytes"]);
|
||||
let mut zero = saved;
|
||||
let row = zero.coats.iter().next().unwrap().clone();
|
||||
zero.coats
|
||||
.set(
|
||||
solidcraft_engine::doc::render_coat::SavedCoat {
|
||||
coat: solidcraft_engine::doc::render_coat::CoatMaterial { weight: 0.0, ..row.coat },
|
||||
..row
|
||||
},
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let baseline = complete(&mut session, json!(plain));
|
||||
let identity = complete(&mut session, json!(zero));
|
||||
assert_eq!(baseline.png(), identity.png());
|
||||
assert_eq!(baseline.linear_exr().unwrap(), identity.linear_exr().unwrap());
|
||||
assert_ne!(coated.linear_exr().unwrap(), baseline.linear_exr().unwrap());
|
||||
assert!(Arc::ptr_eq(&document, &session.doc));
|
||||
assert!(Arc::ptr_eq(&model, &session.model.state()));
|
||||
assert!(Arc::ptr_eq(&evaluation_cache, &session.model.cache));
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state().unwrap()));
|
||||
assert_eq!(session.camera, session_camera);
|
||||
assert_eq!((session.revision, session.model.recomputed, session.undo.len(), session.redo.len()), history);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_gpu_coat_survives_builtin_maps_aging_and_guided_filter_without_changing_the_raw_master() {
|
||||
let Some(executor) = executor() else { return };
|
||||
let mut session = solidcraft_engine::Session::default();
|
||||
session.execute("solid.box", &json!({"body_name":"Panel","corner":[-10,-10,-2],"length":20,"width":20,"height":2})).unwrap();
|
||||
session
|
||||
.execute("appearance.assign", &json!({"bodies":["Panel"],"color":[128,64,32],"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1}}))
|
||||
.unwrap();
|
||||
let camera = Camera { distance: 10.0, yaw: 0.0, pitch: std::f64::consts::FRAC_PI_2, roll: 0.0, fov: 0.0, ..Default::default() };
|
||||
session.execute("render.coat.bind",&json!({"name":"Coat","body_index":0,"body_name":"Panel","expected_revision":session.revision,
|
||||
"coat":{"weight":0.63,"roughness":0.61,"ior":1.7},"set":{"camera":camera,"width":16,"height":16,"job":{"method":"gpu_path","options":{
|
||||
"samples":4,"seed":81,"max_depth":2,"linear_exr":true,"denoise":true,"lighting":{"environment":[0,0,0],"sun":null,"area_lights":[],
|
||||
"directional_lights":[{"direction":[0,0,1],"color":[1,1,1],"intensity":3}]},"aging":[{"kind":"dust","strength":0.2,"coverage":1}]}}}})).unwrap();
|
||||
session
|
||||
.execute("render.library.surface.bind", &json!({"name":"Coat","surface":{"id":"SURF-001","body_index":0,"body_name":"Panel","scale":10}}))
|
||||
.unwrap();
|
||||
let bytes = solidcraft_io::write_design_container(&session.doc).unwrap();
|
||||
session = solidcraft_engine::Session::new(solidcraft_io::read_design(&bytes).unwrap());
|
||||
session.execute("render.scene", &json!({})).unwrap();
|
||||
let host = ImageHost::with_executor(Arc::new(executor)).unwrap();
|
||||
session.register_render_job_host(host).unwrap();
|
||||
let document = session.doc.clone();
|
||||
let model = session.model.state();
|
||||
let evaluation_cache = session.model.cache.clone();
|
||||
let world = session.world_state().unwrap();
|
||||
let session_camera = session.camera;
|
||||
let history = (session.revision, session.model.recomputed, session.undo.len(), session.redo.len());
|
||||
let complete = |session: &mut solidcraft_engine::Session, setup: solidcraft_engine::doc::render_settings::RenderSetup| {
|
||||
let reply = session.execute("render.start", &json!({"settings":setup})).unwrap();
|
||||
let id: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
|
||||
let handle = session.render_job_handle(id).unwrap();
|
||||
let status = handle.wait_timeout(Duration::from_secs(60)).unwrap();
|
||||
assert_eq!(status.progress.phase, Phase::Completed, "{status:?}");
|
||||
handle.frame().unwrap().unwrap()
|
||||
};
|
||||
let saved = session.doc.render.setups["Coat"].clone();
|
||||
let actual = complete(&mut session, saved.clone());
|
||||
let mut uncoated = saved.clone();
|
||||
uncoated.coats = Default::default();
|
||||
let plain = complete(&mut session, uncoated);
|
||||
assert_ne!(actual.linear_exr().unwrap(), plain.linear_exr().unwrap(), "maps and aging cannot erase the captured coat");
|
||||
let mut unmapped = saved.clone();
|
||||
unmapped.surfaces = Default::default();
|
||||
let no_map = complete(&mut session, unmapped);
|
||||
assert_ne!(actual.linear_exr().unwrap(), no_map.linear_exr().unwrap(), "maps still modify the coated substrate");
|
||||
let mut unaged = saved.clone();
|
||||
let Some(solidcraft_engine::doc::render_settings::RenderJob::GpuPath { options, .. }) = &mut unaged.job else { panic!("GPU") };
|
||||
options.aging = Default::default();
|
||||
let no_age = complete(&mut session, unaged);
|
||||
assert_ne!(actual.linear_exr().unwrap(), no_age.linear_exr().unwrap(), "aging still modifies the mapped coat/substrate");
|
||||
let mut unfiltered = saved.clone();
|
||||
let Some(solidcraft_engine::doc::render_settings::RenderJob::GpuPath { options, .. }) = &mut unfiltered.job else { panic!("GPU") };
|
||||
options.denoise = false;
|
||||
let raw = complete(&mut session, unfiltered);
|
||||
assert_eq!(actual.linear_exr().unwrap(), raw.linear_exr().unwrap(), "denoising cannot enter coated raw transport");
|
||||
assert_eq!(session.doc.render.setups["Coat"], saved);
|
||||
assert!(Arc::ptr_eq(&document, &session.doc));
|
||||
assert!(Arc::ptr_eq(&model, &session.model.state()));
|
||||
assert!(Arc::ptr_eq(&evaluation_cache, &session.model.cache));
|
||||
assert!(Arc::ptr_eq(&world, &session.world_state().unwrap()));
|
||||
assert_eq!(session.camera, session_camera);
|
||||
assert_eq!((session.revision, session.model.recomputed, session.undo.len(), session.redo.len()), history);
|
||||
}
|
||||
|
||||
+215
-40
@@ -1,6 +1,7 @@
|
||||
//! Native progressive product rendering. Software BVH traversal needs no ray-query
|
||||
//! features. Small measured quanta adapt to the device, then check cancellation.
|
||||
use solidcore_record::Vec3;
|
||||
use solidcraft_geom::Vec3Geom;
|
||||
#[cfg(test)]
|
||||
use solidcraft_render::job::ToneMap;
|
||||
use solidcraft_render::{
|
||||
@@ -39,15 +40,28 @@ const SHADER: &str = concat!(
|
||||
include_str!("volume.wgsl"),
|
||||
"
|
||||
",
|
||||
include_str!("aging.wgsl")
|
||||
include_str!("aging.wgsl"),
|
||||
"\n",
|
||||
include_str!("punctual.wgsl")
|
||||
);
|
||||
const MAX_TRIANGLES: u64 = MAX_GPU_PATH_TRIANGLES;
|
||||
const PARAMETER_BYTES: usize = 1184;
|
||||
const PARAMETER_BYTES: usize = 2080;
|
||||
// Five vec4s per source; exact device+upload growth is additional to v2's budget.
|
||||
const PUNCTUAL_PARAMETER_BYTES: u64 = 8 * 80;
|
||||
// Eight inline 32-byte hard directions: device uniform plus host upload bytes.
|
||||
const DIRECTIONAL_PARAMETER_BYTES: u64 = 8 * 32;
|
||||
// Use 32 invocations per production workgroup: the physical Radeon produced
|
||||
// nonfinite samples with 64. The structural and actual-tail tests
|
||||
// lock this host/shader contract; counter-based per-pixel RNG is group independent.
|
||||
const PATH_WORKGROUP_SIZE: u32 = 32;
|
||||
// Eight u32 words: count low, pixel, bounce, sample, code, count high, first-writer flag, spare.
|
||||
const FAILURE_BYTES: u64 = 32;
|
||||
const FAILURE_BYTES: u64 = 56;
|
||||
// Original medium words0..7 remain unchanged; three low/high punctual counters
|
||||
// occupy words8..13. Both device storage and complete-pass readback are prepaid.
|
||||
const PUNCTUAL_COUNTER_BYTES: u64 = 24;
|
||||
// A path that cannot classify its glass medium ends there. Isolated paths at the
|
||||
// limits of f32 geometry are tolerated and logged; more than this fraction of all
|
||||
// paths (any systematic error such as a camera inside glass) fails the job.
|
||||
// paths (any systematic error such as an ambiguous boundary origin) fails the job.
|
||||
const FAILURE_FRACTION: f64 = 1e-6;
|
||||
const TIMEOUT: Duration = Duration::from_secs(30);
|
||||
// Software Vulkan drivers can compile a pipeline at its first dispatch, which on a cold
|
||||
@@ -371,6 +385,7 @@ fn admission(scene: &ProductScene, request: &ImageRequest, options: JobOptions)
|
||||
));
|
||||
}
|
||||
let camera = request.camera;
|
||||
validate_punctual_origin(options, camera.target)?;
|
||||
if options.lens.is_some_and(|lens| lens.aperture_radius_mm > 0.0)
|
||||
&& (camera.distance < 1e-6 || !(1e-6..=1e6).contains(&(camera.half_height() / camera.distance)) || camera.fov == 0.0)
|
||||
{
|
||||
@@ -398,7 +413,7 @@ fn admission(scene: &ProductScene, request: &ImageRequest, options: JobOptions)
|
||||
// Peak preparation: f64 triangles + nodes + serialized upload. After preparation:
|
||||
// serialized + device geometry + accumulation/readback/RGBA/PNG. These phases don't overlap.
|
||||
let pixels = u64::from(request.width) * u64::from(request.height);
|
||||
let denoise_bytes = pixels * if options.denoise { denoise::BYTES_PER_PIXEL } else { 0 };
|
||||
let denoise_bytes = pixels * if options.denoise { denoise::BYTES_PER_PIXEL + if options.adaptive.is_some() { 16 } else { 0 } } else { 0 };
|
||||
admission.pixel_scratch_bytes = pixels * 64 + denoise_bytes;
|
||||
let vertex_bytes = scene.meshes.iter().map(|m| m.mesh.positions.len() as u64 * 32).sum::<u64>();
|
||||
let triangle_preparation =
|
||||
@@ -406,7 +421,10 @@ fn admission(scene: &ProductScene, request: &ImageRequest, options: JobOptions)
|
||||
admission.preparation_bytes = (triangle_preparation + vertex_bytes * 2 + admission.face_appearances * 8)
|
||||
.max(admission.triangles * 264 + vertex_bytes * 2 + pixels * 32 + denoise_bytes)
|
||||
.max(admission.triangles * 132 + vertex_bytes + admission.pixel_scratch_bytes)
|
||||
+ 1_048_576;
|
||||
+ 1_048_576
|
||||
+ 2 * DIRECTIONAL_PARAMETER_BYTES
|
||||
+ 2 * PUNCTUAL_PARAMETER_BYTES
|
||||
+ 2 * PUNCTUAL_COUNTER_BYTES;
|
||||
admission.preparation_bytes += request.environment.as_ref().map_or(0, |source| source.admitted_bytes());
|
||||
if let Some(textures) = &request.textures {
|
||||
textures.validate_scene(scene)?;
|
||||
@@ -430,7 +448,9 @@ fn admission(scene: &ProductScene, request: &ImageRequest, options: JobOptions)
|
||||
let photo_bytes = options.finishing.photographic.scratch_bytes(request.width, request.height)?;
|
||||
admission.pixel_scratch_bytes += photo_bytes;
|
||||
admission.preparation_bytes += photo_bytes;
|
||||
let adaptive_bytes = pixels * if options.adaptive.is_some() { 32 } else { 0 };
|
||||
// Two pilot/budget planes plus a second readback plane. Adaptive denoising's
|
||||
// independent normalized preview input is included in denoise_bytes above.
|
||||
let adaptive_bytes = pixels * if options.adaptive.is_some() { 48 } else { 0 };
|
||||
admission.pixel_scratch_bytes += adaptive_bytes;
|
||||
admission.preparation_bytes += adaptive_bytes;
|
||||
admission.estimated_total_bytes = admission.geometry_bytes + admission.preparation_bytes;
|
||||
@@ -441,6 +461,9 @@ fn admission(scene: &ProductScene, request: &ImageRequest, options: JobOptions)
|
||||
}
|
||||
|
||||
impl ImageExecutor for Executor {
|
||||
fn preview_samples(&self, options: JobOptions) -> Option<u32> {
|
||||
options.preview_target().ok()
|
||||
}
|
||||
fn supports_aging(&self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -484,7 +507,14 @@ impl ImageExecutor for Executor {
|
||||
fn admit(&self, scene: &ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission> {
|
||||
self.context.ensure_available()?;
|
||||
let a = admission(scene, request, options)?;
|
||||
let limit = self.context.device.limits().max_storage_buffer_binding_size;
|
||||
let device_limits = self.context.device.limits();
|
||||
if PARAMETER_BYTES as u64 > device_limits.max_uniform_buffer_binding_size
|
||||
|| PARAMETER_BYTES as u64 > device_limits.max_buffer_size
|
||||
|| FAILURE_BYTES > device_limits.max_storage_buffer_binding_size
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "GPU light parameters or diagnostic storage exceed device buffer limits"));
|
||||
}
|
||||
let limit = device_limits.max_storage_buffer_binding_size;
|
||||
let geometry_bytes = 16 + a.triangles.max(1) * 100 + scene.meshes.iter().map(|m| m.mesh.positions.len() as u64 * 32).sum::<u64>();
|
||||
let texture_bytes = request
|
||||
.textures
|
||||
@@ -506,7 +536,9 @@ impl ImageExecutor for Executor {
|
||||
}
|
||||
fn begin(&self, scene: ProductScene, request: ImageRequest, options: JobOptions, cancel: CancelToken) -> Result<Box<dyn ImageWork>> {
|
||||
self.admit(&scene, &request, options)?;
|
||||
Ok(Box::new(Task::new(self.context.clone(), scene, request, options, cancel)?))
|
||||
let mut task = Task::new(self.context.clone(), scene, request, options, cancel)?;
|
||||
task.preview_enabled = true;
|
||||
Ok(Box::new(task))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,6 +569,39 @@ fn prepare_environment(request: &ImageRequest, cancel: &CancelToken) -> Result<V
|
||||
Ok(environment_bytes)
|
||||
}
|
||||
|
||||
fn punctual_parameters(light: &solidcraft_render::job::PunctualLightOptions, origin: Vec3) -> [[f32; 4]; 5] {
|
||||
let p = Vec3::new(light.position_mm[0], light.position_mm[1], light.position_mm[2]) - origin;
|
||||
let hi = [p.x as f32, p.y as f32, p.z as f32];
|
||||
let lo = [(p.x - f64::from(hi[0])) as f32, (p.y - f64::from(hi[1])) as f32, (p.z - f64::from(hi[2])) as f32];
|
||||
let (axis, inner, outer, enabled) = if let Some(cone) = light.cone {
|
||||
let [x, y, z] = cone.emitted_axis;
|
||||
let scale = x.abs().max(y.abs()).max(z.abs());
|
||||
let v = Vec3::new(x / scale, y / scale, z / scale);
|
||||
let v = v / v.len();
|
||||
([v.x as f32, v.y as f32, v.z as f32], cone.inner_half_angle_radians.cos() as f32, cone.outer_half_angle_radians.cos() as f32, 1.0)
|
||||
} else {
|
||||
([0.0; 3], 0.0, 0.0, 0.0)
|
||||
};
|
||||
[
|
||||
[hi[0], hi[1], hi[2], light.reference_distance_mm as f32],
|
||||
[lo[0], lo[1], lo[2], light.minimum_distance_mm as f32],
|
||||
[light.color[0] as f32, light.color[1] as f32, light.color[2] as f32, light.intensity as f32],
|
||||
[axis[0], axis[1], axis[2], inner],
|
||||
[outer, enabled, 0.0, 0.0],
|
||||
]
|
||||
}
|
||||
fn validate_punctual_origin(options: JobOptions, origin: Vec3) -> Result<()> {
|
||||
if let Some(lighting) = options.lighting {
|
||||
for light in lighting.punctual_lights.iter() {
|
||||
light.validate_gpu()?;
|
||||
if punctual_parameters(light, origin).iter().flatten().any(|v| !v.is_finite()) {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "point or spot position is outside the GPU camera-relative range"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Task {
|
||||
dispatch_budget: dispatch::Budget,
|
||||
context: Arc<Context>,
|
||||
@@ -552,12 +617,16 @@ struct Task {
|
||||
sample: u32,
|
||||
lane: u32,
|
||||
sampling_report: Option<solidcraft_render::job::adaptive::SamplingReport>,
|
||||
punctual_diagnostics: std::cell::Cell<solidcraft_render::job::PunctualDiagnostics>,
|
||||
volume: [[f32; 4]; 3],
|
||||
aging: [[f32; 20]; 8],
|
||||
photographic: Option<solidcraft_render::job::photographic::PhotographicTask>,
|
||||
offset: u32,
|
||||
epsilon: f32,
|
||||
png: Option<PngTask>,
|
||||
encoding_preview: bool,
|
||||
preview_enabled: bool,
|
||||
preview: Option<solidcraft_render::job::ImageWorkPreview>,
|
||||
exr_work: Option<solidcraft_render::job::linear_exr::ExrTask>,
|
||||
bloom_work: Option<solidcraft_render::job::bloom::BloomTask>,
|
||||
exr: Option<Vec<u8>>,
|
||||
@@ -572,6 +641,8 @@ struct Task {
|
||||
impl Task {
|
||||
fn new(context: Arc<Context>, scene: ProductScene, mut request: ImageRequest, options: JobOptions, cancel: CancelToken) -> Result<Self> {
|
||||
cancel.check()?;
|
||||
options.validate()?;
|
||||
validate_punctual_origin(options, request.camera.target)?;
|
||||
let start = Instant::now();
|
||||
let aging = options.aging.gpu_layers([request.camera.target.x, request.camera.target.y, request.camera.target.z])?;
|
||||
let volume = if let Some(v) = options.volume.filter(|v| v.active()) {
|
||||
@@ -623,7 +694,7 @@ impl Task {
|
||||
);
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("path readback"),
|
||||
size: size + FAILURE_BYTES,
|
||||
size: size * if options.adaptive.is_some() { 2 } else { 1 } + FAILURE_BYTES,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -642,6 +713,7 @@ impl Task {
|
||||
[&triangles, &nodes, &environment, &texture_buffer, &failures],
|
||||
&accumulation,
|
||||
size,
|
||||
options.adaptive.is_some(),
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
@@ -668,12 +740,16 @@ impl Task {
|
||||
sample: 0,
|
||||
lane: 0,
|
||||
sampling_report: None,
|
||||
punctual_diagnostics: Default::default(),
|
||||
volume,
|
||||
aging,
|
||||
photographic: None,
|
||||
offset: 0,
|
||||
epsilon: upload.epsilon,
|
||||
png: None,
|
||||
encoding_preview: false,
|
||||
preview_enabled: false,
|
||||
preview: None,
|
||||
exr_work: None,
|
||||
bloom_work: None,
|
||||
exr: None,
|
||||
@@ -685,8 +761,11 @@ impl Task {
|
||||
metrics: (bvh_ms, upload_bytes, upload_ms),
|
||||
})
|
||||
}
|
||||
fn parameters(&self, count: u32) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity(PARAMETER_BYTES);
|
||||
fn parameters(&self, count: u32) -> Result<Vec<u8>> {
|
||||
let mut bytes = scene::reserved(PARAMETER_BYTES)?;
|
||||
if bytes.capacity() != PARAMETER_BYTES {
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "GPU parameter upload exceeds its prepaid capacity"));
|
||||
}
|
||||
for x in [
|
||||
self.request.width,
|
||||
self.request.height,
|
||||
@@ -722,7 +801,11 @@ impl Task {
|
||||
Vec3::new(lighting.environment[0], lighting.environment[1], lighting.environment[2]),
|
||||
if self.options.lighting.is_some() || self.request.environment.is_some() { 1.0 } else { 0.0 },
|
||||
);
|
||||
scene::vector(&mut bytes, Vec3::ZERO, lighting.area_lights.iter().count() as f32);
|
||||
scene::vector(
|
||||
&mut bytes,
|
||||
Vec3::new(lighting.directional_lights.iter().count() as f64, lighting.punctual_lights.iter().count() as f64, 0.0),
|
||||
lighting.area_lights.iter().count() as f32,
|
||||
);
|
||||
for light in lighting.area_lights.iter() {
|
||||
let vector = |[x, y, z]: [f64; 3]| Vec3::new(x, y, z);
|
||||
scene::vector(&mut bytes, vector(light.center) - camera.target, light.area() as f32);
|
||||
@@ -769,11 +852,30 @@ impl Task {
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
}
|
||||
for light in lighting.directional_lights.iter() {
|
||||
// Validation rejects zero/nonfinite input; scaled normalization preserves
|
||||
// every bounded CPU directional intent, including tiny finite vectors.
|
||||
let [x, y, z] = light.direction;
|
||||
let scale = x.abs().max(y.abs()).max(z.abs());
|
||||
let direction = Vec3::new(x / scale, y / scale, z / scale);
|
||||
scene::vector(&mut bytes, direction / direction.len(), light.intensity as f32);
|
||||
let [r, g, b] = light.color;
|
||||
scene::vector(&mut bytes, Vec3::new(r, g, b), 0.0);
|
||||
}
|
||||
bytes.resize(PARAMETER_BYTES - PUNCTUAL_PARAMETER_BYTES as usize - 16, 0);
|
||||
for light in lighting.punctual_lights.iter() {
|
||||
for vector in punctual_parameters(light, camera.target) {
|
||||
for value in vector {
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
bytes.resize(PARAMETER_BYTES - 16, 0);
|
||||
let weights = self.request.studio_weights.unwrap_or([1.0; 3]);
|
||||
for value in [weights[0] as f32, weights[1] as f32, weights[2] as f32, 0.0] {
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
Ok(bytes)
|
||||
}
|
||||
fn normalization_samples(&self) -> u32 {
|
||||
if self.options.adaptive.is_some() { 1 } else { self.sample }
|
||||
@@ -789,13 +891,13 @@ impl Task {
|
||||
let count = self.dispatch_budget.count(pixels - self.offset);
|
||||
let context = &self.context;
|
||||
let scopes = scopes(&context.device);
|
||||
context.queue.write_buffer(&self.uniform, 0, &self.parameters(count));
|
||||
context.queue.write_buffer(&self.uniform, 0, &self.parameters(count)?);
|
||||
let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("path tile") });
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("path tile"), timestamp_writes: None });
|
||||
pass.set_pipeline(&context.pipeline);
|
||||
pass.set_bind_group(0, &self.bind, &[]);
|
||||
pass.dispatch_workgroups(count.div_ceil(64), 1, 1);
|
||||
pass.dispatch_workgroups(count.div_ceil(PATH_WORKGROUP_SIZE), 1, 1);
|
||||
}
|
||||
let submission = context.queue.submit([encoder.finish()]);
|
||||
checked(scopes)?;
|
||||
@@ -833,13 +935,24 @@ impl Task {
|
||||
self.read_linear_buffer(&self.accumulation)
|
||||
}
|
||||
fn read_linear_buffer(&self, source: &wgpu::Buffer) -> Result<Vec<[f32; 4]>> {
|
||||
self.read_pixels(source, false)
|
||||
}
|
||||
fn read_checkpoint(&self) -> Result<Vec<[f32; 4]>> {
|
||||
self.read_pixels(&self.accumulation, self.options.adaptive.is_some())
|
||||
}
|
||||
fn read_pixels(&self, source: &wgpu::Buffer, adaptive_preview: bool) -> Result<Vec<[f32; 4]>> {
|
||||
self.cancel.check()?;
|
||||
let context = &self.context;
|
||||
let scopes = scopes(&context.device);
|
||||
let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("path readback") });
|
||||
let pixel_bytes = u64::from(self.request.width) * u64::from(self.request.height) * 16;
|
||||
encoder.copy_buffer_to_buffer(source, 0, &self.readback, 0, pixel_bytes);
|
||||
encoder.copy_buffer_to_buffer(&self.failures, 0, &self.readback, pixel_bytes, FAILURE_BYTES);
|
||||
let pilot = adaptive_preview && self.options.adaptive.is_some_and(|options| self.sample <= options.pilot_samples);
|
||||
encoder.copy_buffer_to_buffer(source, if pilot { pixel_bytes } else { 0 }, &self.readback, 0, pixel_bytes);
|
||||
if adaptive_preview && !pilot {
|
||||
encoder.copy_buffer_to_buffer(&self.accumulation, pixel_bytes * 2, &self.readback, pixel_bytes, pixel_bytes);
|
||||
}
|
||||
let failure_offset = pixel_bytes * if self.options.adaptive.is_some() { 2 } else { 1 };
|
||||
encoder.copy_buffer_to_buffer(&self.failures, 0, &self.readback, failure_offset, FAILURE_BYTES);
|
||||
let submission = context.queue.submit([encoder.finish()]);
|
||||
checked(scopes)?;
|
||||
let slice = self.readback.slice(..);
|
||||
@@ -851,37 +964,60 @@ impl Task {
|
||||
rx.try_recv().map_err(gpu)?.map_err(gpu)?;
|
||||
let data = slice.get_mapped_range().map_err(gpu)?;
|
||||
let result = (|| {
|
||||
let (pixels, failures) = data.split_at(data.len().saturating_sub(FAILURE_BYTES as usize));
|
||||
let pixels = data.get(..pixel_bytes as usize).ok_or_else(|| gpu("missing image readback"))?;
|
||||
let failures = data.get(failure_offset as usize..).ok_or_else(|| gpu("missing failure readback"))?;
|
||||
let budgets = if adaptive_preview && !pilot {
|
||||
Some(data.get(pixel_bytes as usize..(pixel_bytes * 2) as usize).ok_or_else(|| gpu("missing adaptive preview budgets"))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let word = |i: usize| failures.as_chunks::<4>().0.get(i).map_or(0, |w| u32::from_le_bytes(*w));
|
||||
if word(7) != 0 {
|
||||
return Err(JobError::new(ErrorCode::Internal, "nonfinite or negative pilot/production path radiance"));
|
||||
}
|
||||
self.punctual_diagnostics.set(solidcraft_render::job::PunctualDiagnostics {
|
||||
near_source: u64::from(word(8)) | (u64::from(word(9)) << 32),
|
||||
radiance_limit: u64::from(word(10)) | (u64::from(word(11)) << 32),
|
||||
shadow_offset_crossed: u64::from(word(12)) | (u64::from(word(13)) << 32),
|
||||
});
|
||||
let failed = u64::from(word(0)) | (u64::from(word(5)) << 32);
|
||||
if failed > 0 {
|
||||
let pixels = u64::from(self.request.width) * u64::from(self.request.height);
|
||||
let paths = self
|
||||
.sampling_report
|
||||
.as_ref()
|
||||
.map_or(pixels * u64::from(self.sample.max(1)) * u64::from(self.options.transmission_samples), |report| {
|
||||
report.production_samples * u64::from(report.transmission_strata) + pixels * u64::from(report.pilot_samples_per_pixel)
|
||||
});
|
||||
let paths = if adaptive_preview {
|
||||
adaptive::preview_paths(self, budgets)?
|
||||
} else {
|
||||
self.sampling_report.as_ref().map_or(
|
||||
pixels * u64::from(self.sample.max(1)) * u64::from(self.options.transmission_samples),
|
||||
|report| {
|
||||
report.production_samples * u64::from(report.transmission_strata) + pixels * u64::from(report.pilot_samples_per_pixel)
|
||||
},
|
||||
)
|
||||
};
|
||||
let detail =
|
||||
format!("{failed} of {paths} paths, first at pixel {}, bounce {}, sample {}, code {}", word(1), word(2), word(3), word(4));
|
||||
if !medium_failures_tolerated(failed, paths) {
|
||||
return Err(JobError::new(
|
||||
ErrorCode::InvalidScene,
|
||||
format!(
|
||||
"inconsistent glass medium paths ({detail}): camera must start outside volumes; overlapping solids or more than 16 nested media are unsupported"
|
||||
"inconsistent glass medium paths ({detail}): ambiguous boundary origin or invalid containment; overlapping solids or more than 16 nested media are unsupported"
|
||||
),
|
||||
));
|
||||
}
|
||||
log::warn!("gpu-path ended {detail} at an unclassifiable glass medium");
|
||||
}
|
||||
let mut values = scene::reserved((self.request.width * self.request.height) as usize)?;
|
||||
for pixel in pixels.as_chunks::<16>().0 {
|
||||
for (index, pixel) in pixels.as_chunks::<16>().0.iter().enumerate() {
|
||||
if index % 4096 == 0 {
|
||||
self.cancel.check()?;
|
||||
}
|
||||
let divisor = if adaptive_preview {
|
||||
adaptive::preview_divisor(&self.options, self.sample, budgets.and_then(|values| values.get(index * 16..index * 16 + 4)))?
|
||||
} else {
|
||||
self.normalization_samples() as f32
|
||||
};
|
||||
let mut value = [0.0; 4];
|
||||
for (v, bytes) in value.iter_mut().zip(pixel.as_chunks::<4>().0) {
|
||||
*v = f32::from_le_bytes(*bytes) / self.normalization_samples() as f32;
|
||||
*v = f32::from_le_bytes(*bytes) / divisor;
|
||||
if !v.is_finite() || *v < 0.0 {
|
||||
return Err(JobError::new(ErrorCode::Internal, "nonfinite or negative path radiance"));
|
||||
}
|
||||
@@ -899,6 +1035,13 @@ impl Task {
|
||||
use solidcraft_render::job::presentation::Display;
|
||||
|
||||
impl ImageWork for Task {
|
||||
fn punctual_diagnostics(&self) -> Option<solidcraft_render::job::PunctualDiagnostics> {
|
||||
self.options.lighting.filter(|l| !l.punctual_lights.is_empty()).map(|_| self.punctual_diagnostics.get())
|
||||
}
|
||||
|
||||
fn take_preview(&mut self) -> Option<solidcraft_render::job::ImageWorkPreview> {
|
||||
self.preview.take()
|
||||
}
|
||||
fn sampling_report(&self) -> Option<solidcraft_render::job::adaptive::SamplingReport> {
|
||||
self.sampling_report.clone()
|
||||
}
|
||||
@@ -908,8 +1051,25 @@ impl ImageWork for Task {
|
||||
fn step(&mut self, units: usize) -> Result<TaskPoll> {
|
||||
self.cancel.check()?;
|
||||
self.context.ensure_available()?;
|
||||
// Direct executor consumers may ignore previews; dropping their old slot
|
||||
// here never changes accumulated samples or final output. The image host
|
||||
// takes every publication immediately after this quantum.
|
||||
self.preview = None;
|
||||
if let Some(png) = &mut self.png {
|
||||
return png.step(units);
|
||||
let poll = png.step(units)?;
|
||||
if self.encoding_preview
|
||||
&& let TaskPoll::Complete(png) = poll
|
||||
{
|
||||
self.png = None;
|
||||
self.encoding_preview = false;
|
||||
self.preview = Some(solidcraft_render::job::ImageWorkPreview { png, samples_per_pixel: self.sample });
|
||||
return Ok(TaskPoll::Pending(Progress {
|
||||
phase: Phase::Running,
|
||||
done: u64::from(self.sample),
|
||||
total: u64::from(self.target_samples()),
|
||||
}));
|
||||
}
|
||||
return Ok(poll);
|
||||
}
|
||||
if let Some(mut task) = self.photographic.take() {
|
||||
if !task.step(units)? {
|
||||
@@ -920,13 +1080,6 @@ impl ImageWork for Task {
|
||||
self.png = Some(PngTask::new(&self.request, task.finish()?, self.cancel.clone())?);
|
||||
return Ok(TaskPoll::Pending(Progress { phase: Phase::Encoding, done: 0, total: u64::from(self.request.height) }));
|
||||
}
|
||||
if self.sample < self.target_samples() {
|
||||
self.dispatch()?;
|
||||
return Ok(TaskPoll::Pending(Progress { phase: Phase::Running, done: u64::from(self.sample), total: u64::from(self.target_samples()) }));
|
||||
}
|
||||
if self.sampling_report.is_none() && (self.options.adaptive.is_some() || self.options.transmission_samples != 1) {
|
||||
self.sampling_report = Some(adaptive::report(self)?);
|
||||
}
|
||||
if let Some(mut bloom) = self.bloom_work.take() {
|
||||
if !bloom.step(8)? {
|
||||
let done = bloom.completed_lines();
|
||||
@@ -937,10 +1090,25 @@ impl ImageWork for Task {
|
||||
let (pixels, glow) = bloom.finish()?;
|
||||
return self.encode_output(pixels, Some(glow));
|
||||
}
|
||||
if self.sample < self.target_samples() {
|
||||
let before = self.sample;
|
||||
self.dispatch()?;
|
||||
if self.preview_enabled && self.sample > before && self.sample < self.target_samples() {
|
||||
// sample advances only after every tile and Fresnel stratum of
|
||||
// the pass. Readback cannot observe a partially updated film.
|
||||
self.encoding_preview = true;
|
||||
let linear = self.read_checkpoint()?;
|
||||
return self.finish_output(linear);
|
||||
}
|
||||
return Ok(TaskPoll::Pending(Progress { phase: Phase::Running, done: u64::from(self.sample), total: u64::from(self.target_samples()) }));
|
||||
}
|
||||
if self.sampling_report.is_none() && (self.options.adaptive.is_some() || self.options.transmission_samples != 1) {
|
||||
self.sampling_report = Some(adaptive::report(self)?);
|
||||
}
|
||||
if self.exr_work.is_none() {
|
||||
log::info!("gpu-path {} spp, {:.3} ms/spp", self.sample, self.started.elapsed().as_secs_f64() * 1000.0 / f64::from(self.sample));
|
||||
}
|
||||
let mut linear = if let Some(mut work) = self.exr_work.take() {
|
||||
let linear = if let Some(mut work) = self.exr_work.take() {
|
||||
if !work.step(4)? {
|
||||
let done = work.completed_rows();
|
||||
self.exr_work = Some(work);
|
||||
@@ -958,11 +1126,20 @@ impl ImageWork for Task {
|
||||
}
|
||||
pixels
|
||||
};
|
||||
self.finish_output(linear)
|
||||
}
|
||||
}
|
||||
impl Task {
|
||||
fn finish_output(&mut self, mut linear: Vec<[f32; 4]>) -> Result<TaskPoll> {
|
||||
if let Some(denoiser) = &self.denoiser {
|
||||
// Diagnose the raw path buffer before filtering: never hide invalid media
|
||||
// or nonfinite radiance by replacing a pixel with neighbouring values.
|
||||
drop(linear);
|
||||
denoiser.run(self)?;
|
||||
if self.encoding_preview && self.options.adaptive.is_some() {
|
||||
denoiser.run_adaptive_preview(self)?;
|
||||
} else {
|
||||
denoiser.run(self)?;
|
||||
}
|
||||
linear = self.read_linear_buffer(denoiser.output())?;
|
||||
}
|
||||
if let Some(options) = self.options.finishing.bloom.filter(|options| options.active()) {
|
||||
@@ -976,8 +1153,6 @@ impl ImageWork for Task {
|
||||
}
|
||||
self.encode_output(linear, None)
|
||||
}
|
||||
}
|
||||
impl Task {
|
||||
fn encode_output(&mut self, linear: Vec<[f32; 4]>, bloom: Option<Vec<[f32; 3]>>) -> Result<TaskPoll> {
|
||||
let encoded_len = linear.len().checked_mul(4).ok_or_else(|| gpu("display byte length overflow"))?;
|
||||
if bloom.as_ref().is_some_and(|pixels| pixels.len() != linear.len()) {
|
||||
|
||||
@@ -137,3 +137,451 @@ fn subsurface_random_walk_preserves_zero_rate_and_matches_energy_and_absorption_
|
||||
eprintln!("absorbing homogeneous interior RGB={mean:?}");
|
||||
assert!(mean[0] < average && mean[0] > mean[1] && mean[1] > mean[2] && mean[2] > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_media_camera_inside_before_opaque_geometry_attenuates_the_visible_emitter_from_the_actual_origin() {
|
||||
use solidcraft_render::job::{AreaLight, AreaLights, StudioLighting};
|
||||
let Some(executor) = executor() else { return };
|
||||
let radiance = [2.0, 3.0, 5.0];
|
||||
let render = |inside: bool, absorbing: bool, reverse: bool| {
|
||||
let outer_sigma = if absorbing { Vec3::new(2.0_f64.ln(), 2.0_f64.ln(), 0.0) } else { Vec3::ZERO };
|
||||
let inner_sigma = if absorbing { Vec3::new(2.0_f64.ln(), 4.0_f64.ln(), 0.0) } else { Vec3::ZERO };
|
||||
// Both index-matched media have no Fresnel/refraction uncertainty. The inner
|
||||
// medium replaces, rather than adds to, the outer absorption along its part.
|
||||
let outer = slab_body("Outer origin medium", Vec3::new(1.0, 1.0, 8.0), Vec3::ZERO, 1.0, outer_sigma);
|
||||
let inner = slab_body("Inner origin medium", Vec3::new(0.5, 0.5, 4.0), Vec3::ZERO, 1.0, inner_sigma);
|
||||
let opaque_mesh = Mesh {
|
||||
positions: vec![Vec3::new(-3.5, -3.5, -1.5), Vec3::new(3.5, -3.5, -1.5), Vec3::new(3.5, 3.5, -1.5), Vec3::new(-3.5, 3.5, -1.5)],
|
||||
normals: vec![Vec3::Z; 4],
|
||||
triangles: vec![[0, 1, 2], [0, 2, 3]],
|
||||
tri_face: vec![0, 0],
|
||||
..Default::default()
|
||||
};
|
||||
let opaque = ProductMesh {
|
||||
name: "First inside geometry is opaque".into(),
|
||||
mesh: Arc::new(opaque_mesh),
|
||||
color: [0; 3],
|
||||
opacity: 1.0,
|
||||
surface: Some(OpaqueSurface::new(Vec3::ZERO, 0.0, 0.5, 1.0).unwrap()),
|
||||
transmission: None,
|
||||
faces: Vec::new(),
|
||||
};
|
||||
let mut image = request(4);
|
||||
image.camera.fov = 0.0; // Orthographic rays all have the same exact axial lengths.
|
||||
image.camera.distance = if inside { 0.5 } else { 5.0 };
|
||||
image.camera.target = if inside { Vec3::new(0.0, 0.0, -2.0) } else { Vec3::ZERO };
|
||||
assert_eq!((outer.mesh.bounds().min.z, outer.mesh.bounds().max.z), (-4.0, 4.0));
|
||||
assert_eq!((inner.mesh.bounds().min.z, inner.mesh.bounds().max.z), (-2.0, 2.0));
|
||||
assert_eq!((opaque.mesh.bounds().min.z, opaque.mesh.bounds().max.z), (-1.5, -1.5));
|
||||
// In the inside case the geometry ray first meets the opaque plane at1.5mm,
|
||||
// before the inner exit at2mm. The front-facing analytic light at1mm must
|
||||
// still acquire Beer attenuation from the camera's actual inner medium.
|
||||
let light = AreaLight { center: [0.0, 0.0, -1.0], u: [3.0, 0.0, 0.0], v: [0.0, 3.0, 0.0], radiance };
|
||||
let bounds = outer.mesh.bounds();
|
||||
let mut source = ProductScene { bounds, meshes: vec![outer, inner, opaque] };
|
||||
if reverse {
|
||||
source.meshes.reverse();
|
||||
}
|
||||
assert!(
|
||||
scene::flatten_with_media(&source, Vec3::ZERO, &CancelToken::default(), true).is_ok(),
|
||||
"opaque backing inside two separated certified closed media is valid input"
|
||||
);
|
||||
let options = JobOptions {
|
||||
nested_media: true,
|
||||
samples: 4,
|
||||
max_depth: 4,
|
||||
seed: 71,
|
||||
lighting: Some(StudioLighting {
|
||||
sun: None,
|
||||
environment: [0.0; 3],
|
||||
area_lights: AreaLights::new(&[light]).unwrap(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let mut task = Task::new(executor.context.clone(), source, image, options, CancelToken::default()).unwrap();
|
||||
// Orthographic GPU launches use target + back*(4*distance), not Camera::eye.
|
||||
// Inspect the actual packed primary origin before measuring physical Beer.
|
||||
let packed = task.parameters(0).unwrap();
|
||||
let coordinate = |axis: usize| f64::from(f32::from_le_bytes(packed[32 + axis * 4..36 + axis * 4].try_into().unwrap()));
|
||||
let actual_origin = Vec3::new(coordinate(0), coordinate(1), coordinate(2)) + task.request.camera.target;
|
||||
assert_eq!(f32::from_le_bytes(packed[44..48].try_into().unwrap()), 0.0, "this fixture uses the actual orthographic GPU branch");
|
||||
assert!((actual_origin.z - if inside { 0.0 } else { 20.0 }).abs() < 1e-14, "actual packed GPU world origin: {actual_origin:?}");
|
||||
for _ in 0..options.samples {
|
||||
task.complete_sample().unwrap();
|
||||
}
|
||||
task.read_linear().unwrap()
|
||||
};
|
||||
// Controls run before the expected-red inside/absorbing cases. Outside rays
|
||||
// traverse outer4→2 (2mm), then inner2→-1 (3mm); inside rays see inner0→-1 (1mm).
|
||||
// Thus absorbing outside RGB is exactly[1/16,3/256,5], inside is[1,3/4,5].
|
||||
for (inside, absorbing, reverse) in
|
||||
[(false, false, false), (true, false, false), (false, true, false), (false, true, true), (true, true, false), (true, true, true)]
|
||||
{
|
||||
let expected: [f64; 3] = if !absorbing {
|
||||
radiance
|
||||
} else if inside {
|
||||
[1.0, 0.75, 5.0]
|
||||
} else {
|
||||
[1.0 / 16.0, 3.0 / 256.0, 5.0]
|
||||
};
|
||||
for (pixel, value) in render(inside, absorbing, reverse).into_iter().enumerate() {
|
||||
assert_eq!(value[3], 1.0, "visible analytic emitter has full coverage");
|
||||
if !absorbing {
|
||||
assert_eq!(value, [2.0, 3.0, 5.0, 1.0], "lossless same-IOR control must retain exact source radiance");
|
||||
}
|
||||
for channel in 0..3 {
|
||||
assert!(
|
||||
(f64::from(value[channel]) - expected[channel]).abs() < 2e-5,
|
||||
"inside={inside} absorbing={absorbing} reverse={reverse} pixel={pixel}: {value:?} vs independent Beer {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_origin_query_uses_only_closed_boundaries_and_refuses_surface_and_silhouette_ambiguity() {
|
||||
let Some(executor) = executor() else { return };
|
||||
let context = &executor.context;
|
||||
let source = [
|
||||
SHADER,
|
||||
r#"
|
||||
@compute @workgroup_size(1) fn origin_reference() {
|
||||
let boundary=origin_boundary(u.eye.xyz,u.right.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.right.xyz,&media,&count); }
|
||||
var sigma=vec3<f32>(0.0);var scatter=vec2<f32>(0.0);var outer=0.0;var top=0.0;
|
||||
if valid && count>0u { sigma=media[count-1u].sigma;scatter=media[count-1u].scatter;outer=media[0].ior;top=media[count-1u].ior; }
|
||||
accumulation[0]=vec4<f32>(sigma,select(0.0,1.0,valid));
|
||||
accumulation[1]=vec4<f32>(outer,top,f32(count),select(0.0,1.0,boundary.hit.triangle!=0xffffffffu));
|
||||
accumulation[2]=vec4<f32>(scatter,0.0,1.0);
|
||||
}
|
||||
"#,
|
||||
]
|
||||
.concat();
|
||||
let shader = context.device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("actual origin classification"),
|
||||
source: wgpu::ShaderSource::Wgsl(source.into()),
|
||||
});
|
||||
let layout = context.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &[Some(&context.layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = context.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
module: &shader,
|
||||
entry_point: Some("origin_reference"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
for reverse in [false, true] {
|
||||
let mut source = nested();
|
||||
source.meshes[0].surface = Some(OpaqueSurface::new(Vec3::ZERO, 0.0, 0.1, 1.125).unwrap());
|
||||
source.meshes[1].surface = Some(OpaqueSurface::new(Vec3::ZERO, 0.0, 0.1, 1.25).unwrap());
|
||||
source.meshes[0].transmission =
|
||||
Some(source.meshes[0].transmission.unwrap().with_subsurface(Subsurface { scattering_per_mm: 0.1, anisotropy: 0.2 }).unwrap());
|
||||
source.meshes[1].transmission =
|
||||
Some(source.meshes[1].transmission.unwrap().with_subsurface(Subsurface { scattering_per_mm: 0.3, anisotropy: 0.35 }).unwrap());
|
||||
let mut opaque = directional_receiver().meshes.remove(0);
|
||||
for p in &mut Arc::make_mut(&mut opaque.mesh).positions {
|
||||
p.z = -0.25;
|
||||
}
|
||||
opaque.name = "Opaque first hit".into();
|
||||
source.meshes.push(opaque);
|
||||
let mut thin = glass_slab(true).meshes.remove(0);
|
||||
thin.name = "Outside thin sheet".into();
|
||||
for p in &mut Arc::make_mut(&mut thin.mesh).positions {
|
||||
p.z = 3.0;
|
||||
}
|
||||
source.meshes.push(thin);
|
||||
if reverse {
|
||||
source.meshes.reverse();
|
||||
}
|
||||
// GPU12's original 1x1/seed71/sample4 ray rounds both projected outer
|
||||
// top-face triangles onto their shared diagonal. Prove the uploaded
|
||||
// topology and exact arithmetic independently before querying it.
|
||||
let upload = scene::flatten_with_media(&source, Vec3::ZERO, &CancelToken::default(), true).unwrap();
|
||||
let word = |at: usize| u32::from_le_bytes(upload.geometry[at * 4..at * 4 + 4].try_into().unwrap());
|
||||
let vertex = |index: u32| {
|
||||
let start = word(1) as usize + index as usize * 8;
|
||||
std::array::from_fn::<_, 3, _>(|axis| f32::from_bits(word(start + axis)))
|
||||
};
|
||||
let tops: Vec<_> = (0..word(0) as usize)
|
||||
.filter_map(|index| {
|
||||
let positions = [vertex(word(4 + index * 4)), vertex(word(5 + index * 4)), vertex(word(6 + index * 4))];
|
||||
positions.iter().all(|p| p[2] == 2.0).then_some((index, positions))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(tops.len(), 2);
|
||||
assert_eq!(word(word(3) as usize + tops[0].0), word(word(3) as usize + tops[1].0), "the actual uploaded face has one certified patch");
|
||||
let coefficients = |index: usize| {
|
||||
let material = word(2) as usize + word(7 + index * 4) as usize * 20;
|
||||
[6, 8, 9, 10, 11, 16, 17, 18, 19].map(|offset| word(material + offset))
|
||||
};
|
||||
assert_eq!(coefficients(tops[0].0), coefficients(tops[1].0), "actual IOR/absorption/medium/SSS/parent coefficients agree");
|
||||
let origin = [0.0_f32, f32::from_bits(0xb686_37bd), 4.0];
|
||||
let direction = [f32::from_bits(0xb69a_9748), f32::from_bits(0xb643_2e36), -1.0];
|
||||
// Products of two binary32 values fit exactly in binary64 here. This
|
||||
// independent determinant rounds their exact difference once to f32.
|
||||
let determinant = |a: f32, b: f32, c: f32, d: f32| (f64::from(a) * f64::from(b) - f64::from(c) * f64::from(d)) as f32;
|
||||
let edges: Vec<_> = tops
|
||||
.iter()
|
||||
.map(|(_, p)| {
|
||||
let q = p.map(|v| {
|
||||
let relative = std::array::from_fn::<_, 3, _>(|axis| v[axis] - origin[axis]);
|
||||
[
|
||||
determinant(relative[0], direction[2], direction[0], relative[2]),
|
||||
determinant(relative[1], direction[2], direction[1], relative[2]),
|
||||
]
|
||||
});
|
||||
let [a, b, c] = q;
|
||||
let weights = [determinant(b[0], c[1], b[1], c[0]), determinant(c[0], a[1], c[1], a[0]), determinant(a[0], b[1], a[1], b[0])];
|
||||
assert_eq!(weights.iter().filter(|&&weight| weight == 0.0).count(), 1, "only the internal edge is zero, not a vertex");
|
||||
let zero = weights.iter().position(|&weight| weight == 0.0).unwrap();
|
||||
[p[(zero + 1) % 3], p[(zero + 2) % 3]]
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(edges[0], [edges[1][1], edges[1][0]], "the shared edge is exact and oppositely directed, not merely two same-patch hits");
|
||||
let mut task = Task::new(
|
||||
context.clone(),
|
||||
source,
|
||||
request(2),
|
||||
JobOptions { nested_media: true, samples: 1, ..Default::default() },
|
||||
CancelToken::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let epsilon = f64::from(task.epsilon);
|
||||
let cases = [
|
||||
(Vec3::new(0.25, 0.125, 0.0), -Vec3::Z, Some((2, Vec3::new(0.3, 0.4, 0.0), 1.125, 1.25))),
|
||||
(Vec3::new(0.25, 0.125, 1.5), -Vec3::Z, Some((1, Vec3::new(0.1, 0.2, 0.0), 1.125, 1.125))),
|
||||
(Vec3::new(0.25, 0.125, 3.5), -Vec3::Z, Some((0, Vec3::ZERO, 0.0, 0.0))),
|
||||
(Vec3::ZERO, -Vec3::Z, Some((2, Vec3::new(0.3, 0.4, 0.0), 1.125, 1.25))),
|
||||
(Vec3::new(0.0, 0.0, 1.5), -Vec3::Z, Some((1, Vec3::new(0.1, 0.2, 0.0), 1.125, 1.125))),
|
||||
(Vec3::new(0.0, 0.0, 3.5), -Vec3::Z, Some((0, Vec3::ZERO, 0.0, 0.0))),
|
||||
(
|
||||
Vec3::new(f64::from(origin[0]), f64::from(origin[1]), f64::from(origin[2])),
|
||||
Vec3::new(f64::from(direction[0]), f64::from(direction[1]), f64::from(direction[2])),
|
||||
Some((0, Vec3::ZERO, 0.0, 0.0)),
|
||||
),
|
||||
(Vec3::new(30.0, 0.125, 0.0), Vec3::X, Some((0, Vec3::ZERO, 0.0, 0.0))),
|
||||
(Vec3::new(0.25, 0.125, 1.0), -Vec3::Z, None),
|
||||
(Vec3::new(0.25, 0.125, 1.0), Vec3::Z, None),
|
||||
(Vec3::new(5.0, 5.0, 1.0), -Vec3::Z, None),
|
||||
(Vec3::new(10.0, 0.0, 3.5), -Vec3::Z, None),
|
||||
(Vec3::new(10.0, 10.0, 3.5), -Vec3::Z, None),
|
||||
(Vec3::new(0.25, 0.125, 1.0 + epsilon * 0.5), -Vec3::Z, None),
|
||||
(Vec3::new(0.25, 0.125, 1.0 - epsilon * 0.5), Vec3::Z, None),
|
||||
(Vec3::new(0.25, 0.125, 1_000_000_000.0), -Vec3::Z, None),
|
||||
(Vec3::new(-8.0, 5.0, 0.25), Vec3::X, None),
|
||||
(Vec3::new(-8.0, 2.0, 0.0), Vec3::new(1.0, 1.0, 0.0) / 2.0_f64.sqrt(), None),
|
||||
];
|
||||
for (origin, direction, expected) in cases {
|
||||
let mut params = task.parameters(1).unwrap();
|
||||
let mut values = Vec::new();
|
||||
scene::vector(&mut values, origin, 0.0);
|
||||
scene::vector(&mut values, direction, 0.0);
|
||||
params[32..64].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 value = task.read_linear().unwrap();
|
||||
if let Some((count, sigma, outer, top)) = expected {
|
||||
assert_eq!(
|
||||
value[0],
|
||||
[sigma.x as f32, sigma.y as f32, sigma.z as f32, 1.0],
|
||||
"reverse={reverse} origin={origin:?} direction={direction:?}"
|
||||
);
|
||||
assert_eq!(value[1][..3], [outer as f32, top as f32, count as f32]);
|
||||
let scatter = match count {
|
||||
2 => [0.3_f32, 0.35],
|
||||
1 => [0.1, 0.2],
|
||||
_ => [0.0, 0.0],
|
||||
};
|
||||
assert_eq!(value[2][..2], scatter, "initial-medium classification preserves the actual homogeneous random-walk parameters");
|
||||
} else {
|
||||
assert_eq!(
|
||||
value[0][3], 0.0,
|
||||
"an actual boundary/silhouette cannot certify an arbitrary incident normal: reverse={reverse} origin={origin:?} direction={direction:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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].
|
||||
assert_eq!((glass.mesh.bounds().min.x, glass.mesh.bounds().max.x), (-10.0, 0.0));
|
||||
let mut opaque = directional_receiver().meshes.remove(0);
|
||||
opaque.name = "Opaque behind emitter".into();
|
||||
for p in &mut Arc::make_mut(&mut opaque.mesh).positions {
|
||||
p.z = -1.5;
|
||||
}
|
||||
opaque.surface = Some(OpaqueSurface::new(Vec3::ZERO, 0.0, 0.5, 1.0).unwrap());
|
||||
glass.transmission = Some(Transmission::new(Vec3::new(2.0_f64.ln(), 4.0_f64.ln(), 0.0), None).unwrap());
|
||||
ProductScene { bounds: glass.mesh.bounds(), meshes: vec![glass, opaque] }
|
||||
}
|
||||
#[test]
|
||||
fn gpu_actual_sampled_pupils_normalize_initial_beer_before_visible_emission_without_new_variates() {
|
||||
use solidcraft_render::job::{AreaLight, AreaLights, StudioLighting};
|
||||
let Some(executor) = executor() else { return };
|
||||
let mut image = request(1);
|
||||
image.camera.target = Vec3::new(0.25, 0.0, -0.5);
|
||||
image.camera.distance = 0.5;
|
||||
image.camera.fov = 0.2;
|
||||
let samples = 16;
|
||||
let seed = 71;
|
||||
let lens = solidcraft_render::job::ThinLens { aperture_radius_mm: 1.0, focus_distance_mm: 2.0 };
|
||||
let radiance = [2.0, 3.0, 5.0];
|
||||
let light = AreaLight { center: [0.0, 0.0, -1.0], u: [3.0, 0.0, 0.0], v: [0.0, 3.0, 0.0], radiance };
|
||||
let options = JobOptions {
|
||||
nested_media: true,
|
||||
samples,
|
||||
seed,
|
||||
max_depth: 8,
|
||||
lens: Some(lens),
|
||||
lighting: Some(StudioLighting { sun: None, environment: [0.0; 3], area_lights: AreaLights::new(&[light]).unwrap(), ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let mut expected = [0.0; 3];
|
||||
let mut inside = 0;
|
||||
let mut outside = 0;
|
||||
let (right, up, back) = image.camera.basis();
|
||||
for ordinal in 0..samples {
|
||||
let mut rng = ordinal.wrapping_mul(0x9e3779b9) ^ seed;
|
||||
let mut random = || {
|
||||
rng = rng.wrapping_mul(747796405).wrapping_add(2891336453);
|
||||
let word = ((rng >> ((rng >> 28) + 4)) ^ rng).wrapping_mul(277803737);
|
||||
f64::from(((word >> 22) ^ word) >> 8) / 16777216.0
|
||||
};
|
||||
let x = random();
|
||||
let y = random();
|
||||
let u = random();
|
||||
let v = random();
|
||||
let displacement =
|
||||
(right * (std::f64::consts::TAU * v).cos() + up * (std::f64::consts::TAU * v).sin()) * (lens.aperture_radius_mm * u.sqrt());
|
||||
let origin = image.camera.eye() + displacement;
|
||||
let pinhole = image.camera.ray(x, y, 1.0, 1.0).1;
|
||||
let aimed = pinhole * (lens.focus_distance_mm / pinhole.dot(-back)) - displacement;
|
||||
let direction = aimed / aimed.len();
|
||||
if origin.x < 0.0 {
|
||||
inside += 1;
|
||||
} else {
|
||||
outside += 1;
|
||||
}
|
||||
let end = (-1.0 - origin.z) / direction.z;
|
||||
assert!(end > 0.0);
|
||||
let mut enter = f64::NEG_INFINITY;
|
||||
let mut exit = f64::INFINITY;
|
||||
for (p, d, lo, hi) in [(origin.x, direction.x, -10.0, 0.0), (origin.y, direction.y, -10.0, 10.0), (origin.z, direction.z, -2.0, 2.0)] {
|
||||
if d == 0.0 {
|
||||
if p < lo || p > hi {
|
||||
exit = f64::NEG_INFINITY;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
let a = (lo - p) / d;
|
||||
let b = (hi - p) / d;
|
||||
enter = enter.max(a.min(b));
|
||||
exit = exit.min(a.max(b));
|
||||
}
|
||||
}
|
||||
let length = (exit.min(end) - enter.max(0.0)).max(0.0);
|
||||
for (channel, sigma) in [2.0_f64.ln(), 4.0_f64.ln(), 0.0].into_iter().enumerate() {
|
||||
expected[channel] += radiance[channel] * (-sigma * length).exp() / f64::from(samples);
|
||||
}
|
||||
}
|
||||
assert!(inside > 0 && outside > 0, "one actual pupil disk must exercise both initial media");
|
||||
let render = |absorbing: bool| {
|
||||
let mut source = origin_emitter_fixture();
|
||||
if !absorbing {
|
||||
source.meshes[0].transmission = Some(clear_glass());
|
||||
}
|
||||
let mut task = Task::new(executor.context.clone(), source, image.clone(), options, CancelToken::default()).unwrap();
|
||||
let packed = task.parameters(0).unwrap();
|
||||
let coordinate = |axis: usize| f64::from(f32::from_le_bytes(packed[32 + axis * 4..36 + axis * 4].try_into().unwrap()));
|
||||
let actual_eye = Vec3::new(coordinate(0), coordinate(1), coordinate(2)) + image.camera.target;
|
||||
assert_eq!(f32::from_le_bytes(packed[44..48].try_into().unwrap()), 1.0, "pupil fixture uses the actual perspective GPU branch");
|
||||
assert!(actual_eye.dist(image.camera.eye()) < 1e-12, "the independent per-pupil oracle starts at the actual packed perspective eye");
|
||||
while task.sample < samples {
|
||||
task.complete_sample().unwrap();
|
||||
}
|
||||
task.read_linear().unwrap()[0]
|
||||
};
|
||||
assert_eq!(render(false), [2.0, 3.0, 5.0, 1.0], "same-IOR lossless pupil paths preserve exact source radiance");
|
||||
let actual = render(true);
|
||||
assert_eq!(actual[3], 1.0);
|
||||
for (a, e) in actual[..3].iter().zip(expected) {
|
||||
assert!((f64::from(*a) - e).abs() < 2e-5, "actual per-pupil origin and unchanged RNG: {actual:?} vs{expected:?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn gpu_boundary_origins_refuse_through_existing_diagnostics_and_non_nested_outside_pixels_stay_exact() {
|
||||
use solidcraft_render::job::{AreaLight, AreaLights, StudioLighting};
|
||||
let Some(executor) = executor() else { return };
|
||||
for z in [-2.0, 2.0] {
|
||||
let mut image = request(4);
|
||||
image.camera.fov = 0.0;
|
||||
image.camera.distance = 0.5;
|
||||
image.camera.target = Vec3::new(-5.0, 0.0, z - 2.0);
|
||||
let mut task = Task::new(
|
||||
executor.context.clone(),
|
||||
origin_emitter_fixture(),
|
||||
image,
|
||||
JobOptions { nested_media: true, samples: 1, ..Default::default() },
|
||||
CancelToken::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let packed = task.parameters(0).unwrap();
|
||||
let actual_z = f64::from(f32::from_le_bytes(packed[40..44].try_into().unwrap())) + task.request.camera.target.z;
|
||||
assert_eq!(actual_z, z, "actual packed orthographic origin lies on the selected boundary");
|
||||
task.complete_sample().unwrap();
|
||||
let error = task.read_linear().unwrap_err();
|
||||
assert_eq!(error.code, ErrorCode::InvalidScene);
|
||||
assert!(error.message.contains("code 4"));
|
||||
}
|
||||
let light = AreaLight { center: [-5.0, 0.0, -1.0], u: [3.0, 0.0, 0.0], v: [0.0, 3.0, 0.0], radiance: [2.0, 3.0, 5.0] };
|
||||
let mut image = request(4);
|
||||
image.camera.fov = 0.0;
|
||||
image.camera.target = Vec3::new(-5.0, 0.0, 0.0);
|
||||
image.camera.distance = 5.0;
|
||||
let render = |nested_media| {
|
||||
let options = JobOptions {
|
||||
nested_media,
|
||||
samples: 4,
|
||||
max_depth: 4,
|
||||
seed: 71,
|
||||
lighting: Some(StudioLighting {
|
||||
sun: None,
|
||||
environment: [0.0; 3],
|
||||
area_lights: AreaLights::new(&[light]).unwrap(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let mut task = Task::new(executor.context.clone(), origin_emitter_fixture(), image.clone(), options, CancelToken::default()).unwrap();
|
||||
let packed = task.parameters(0).unwrap();
|
||||
assert_eq!(
|
||||
f64::from(f32::from_le_bytes(packed[40..44].try_into().unwrap())) + image.camera.target.z,
|
||||
20.0,
|
||||
"the established outside ortho origin is not generic Camera::eye"
|
||||
);
|
||||
while task.sample < options.samples {
|
||||
task.complete_sample().unwrap();
|
||||
}
|
||||
task.read_linear().unwrap()
|
||||
};
|
||||
let legacy = render(false);
|
||||
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");
|
||||
}
|
||||
|
||||
+338
-23
@@ -11,6 +11,8 @@ struct Triangle {
|
||||
};
|
||||
struct Node { lo: vec3<f32>, first: u32, hi: vec3<f32>, count: u32 };
|
||||
struct AreaLight { center: vec4<f32>, u: vec4<f32>, v: vec4<f32>, radiance: vec4<f32> };
|
||||
struct DirectionalLight { direction: vec4<f32>, color: vec4<f32> };
|
||||
struct PunctualLight { position: vec4<f32>, position_low: vec4<f32>, color: vec4<f32>, axis: vec4<f32>, cone: vec4<f32> };
|
||||
struct Params {
|
||||
// width, height, sample index, pixel offset
|
||||
image: vec4<u32>,
|
||||
@@ -20,7 +22,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>, // w: rectangle count
|
||||
light_count: vec4<f32>, // x: hard directional count, y: point/spot count, w: 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
|
||||
@@ -31,6 +33,8 @@ struct Params {
|
||||
volume_max: vec4<f32>, // camera-relative bounds maximum, HG mean cosine
|
||||
volume_albedo: vec4<f32>, // RGB single-scattering albedo, reserved
|
||||
aging: array<AgingLayer,8>,
|
||||
directional_lights: array<DirectionalLight,8>,
|
||||
punctual_lights: array<PunctualLight,8>,
|
||||
studio_weights: vec4<f32>,
|
||||
|
||||
};
|
||||
@@ -45,7 +49,7 @@ struct EnvironmentData { header: vec4<u32>, texels: array<EnvironmentTexel> };
|
||||
// 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>>;
|
||||
// Codes: 1 escaped inside a medium, 2 exit with no medium, 3 exit from another
|
||||
// medium, 4 entry while inside a medium or beyond 16 nested media.
|
||||
// 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> {
|
||||
if atomicAdd(&failures[0],1u)==0xffffffffu { atomicAdd(&failures[5],1u); }
|
||||
if atomicExchange(&failures[6],1u)==0u {
|
||||
@@ -331,10 +335,70 @@ fn triangle_ahead_precise(t: Triangle, o: vec3<f32>, o_low: vec3<f32>, d: vec3<f
|
||||
if any((d==vec3<f32>(0.0))&((lo>vec3<f32>(0.0))|(hi<vec3<f32>(0.0)))) { return false; }
|
||||
return true;
|
||||
}
|
||||
// The host keeps studio_weights.w at positive zero. The integer dependency
|
||||
// materializes scalar intermediates and prevents floating reassociation.
|
||||
fn split_materialize(value: f32) -> f32 {
|
||||
return bitcast<f32>(bitcast<u32>(value)^bitcast<u32>(u.studio_weights.w));
|
||||
}
|
||||
struct SplitScaled { value: f32, exponent: i32 };
|
||||
fn split_scaled(value: f32) -> SplitScaled {
|
||||
let bits=bitcast<u32>(value);let exponent=(bits>>23u)&255u;let fraction=bits&0x7fffffu;
|
||||
if (bits&0x7fffffffu)==0u || exponent==255u { return SplitScaled(value,0); }
|
||||
let sig=fraction|select(0u,0x800000u,exponent!=0u);
|
||||
let shift=countLeadingZeros(sig)-8u;
|
||||
let normalized=(bits&0x80000000u)|0x3f800000u|((sig<<shift)&0x7fffffu);
|
||||
return SplitScaled(bitcast<f32>(normalized),select(-126,i32(exponent)-127,exponent!=0u)-i32(shift));
|
||||
}
|
||||
fn split_add(a: f32, b: f32) -> f32 {
|
||||
return bitcast<f32>(fp_add_bits(bitcast<u32>(a),bitcast<u32>(b)));
|
||||
}
|
||||
// With normalized operands, all four12-by12-bit partial products and these
|
||||
// subtractions are exactly representable, even if p chose either faithful neighbor.
|
||||
fn split_product(a: f32, b: f32) -> vec2<f32> {
|
||||
let ah=bitcast<f32>(bitcast<u32>(a)&0xfffff000u);let al=split_materialize(a-ah);
|
||||
let bh=bitcast<f32>(bitcast<u32>(b)&0xfffff000u);let bl=split_materialize(b-bh);
|
||||
let p=split_materialize(a*b);
|
||||
let e1=split_materialize(p-ah*bh);
|
||||
let e2=split_materialize(e1-al*bh);
|
||||
let e3=split_materialize(e2-ah*bl);
|
||||
return vec2<f32>(p,split_materialize(al*bl-e3));
|
||||
}
|
||||
fn split_two_sum(a: f32, b: f32) -> vec2<f32> {
|
||||
let swap=abs(a)<abs(b);let large=select(a,b,swap);let small=select(b,a,swap);
|
||||
let sum=split_add(large,small);
|
||||
let delta=split_materialize(sum-large);
|
||||
return vec2<f32>(sum,split_materialize(small-delta));
|
||||
}
|
||||
fn split_rescale(value: f32, exponent: i32) -> f32 {
|
||||
if value==0.0 { return value; }
|
||||
let scaled=split_scaled(value);let combined=scaled.exponent+exponent+1;
|
||||
if combined>128 { return bitcast<f32>((bitcast<u32>(value)&0x80000000u)|0x7f800000u); }
|
||||
return ldexp(scaled.value*0.5,combined);
|
||||
}
|
||||
fn split_difference_products(a: f32, b: f32, c: f32, d: f32) -> f32 {
|
||||
let aa=split_scaled(a);let bb=split_scaled(b);let cc=split_scaled(c);let dd=split_scaled(d);
|
||||
let p=split_product(aa.value,bb.value);let q=split_product(cc.value,dd.value);
|
||||
let ep=aa.exponent+bb.exponent;let eq=cc.exponent+dd.exponent;
|
||||
if p.x==0.0 { return split_rescale(-split_add(q.x,q.y),eq); }
|
||||
if q.x==0.0 { return split_rescale(split_add(p.x,p.y),ep); }
|
||||
let exponent=max(ep,eq);
|
||||
let pp=ldexp(p,vec2<i32>(ep-exponent));let qq=ldexp(q,vec2<i32>(eq-exponent));
|
||||
let high=split_materialize(pp.x-qq.x);
|
||||
let tail=split_two_sum(pp.y,-qq.y);
|
||||
if high==0.0 { return split_rescale(tail.x,exponent); }
|
||||
if tail.x==0.0 { return split_rescale(high,exponent); }
|
||||
let total=split_two_sum(high,tail.x);
|
||||
return split_rescale(split_add(total.x,split_add(total.y,tail.y)),exponent);
|
||||
}
|
||||
fn difference_products(a: f32, b: f32, c: f32, d: f32) -> f32 {
|
||||
let cd = c*d;
|
||||
let error = fma(-c,d,cd);
|
||||
return fma(a,b,-cd)+error;
|
||||
let ab=a*b;let cd=c*d;
|
||||
// Four cancelled leading bits select the compensated method; this is an
|
||||
// arithmetic strategy, not a change to any intersection tolerance.
|
||||
if abs(ab-cd)>max(abs(ab),abs(cd))*(1.0/16.0) {
|
||||
let error=fma(-c,d,cd);
|
||||
return fma(a,b,-cd)+error;
|
||||
}
|
||||
return split_difference_products(a,b,c,d);
|
||||
}
|
||||
struct Hit { distance: f32, triangle: u32, bary: vec3<f32> };
|
||||
// A closed continuation that leaves its producing triangle at a shared edge or
|
||||
@@ -360,15 +424,17 @@ fn tie_preferred(t: Triangle, d: vec3<f32>, inside: bool) -> bool {
|
||||
return front!=inside;
|
||||
}
|
||||
fn trace_precise(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, any_hit: bool, excluded: u32, minimum: f32, inside: bool) -> Hit {
|
||||
if all(o_low==vec3<f32>(0.0)) { return trace_ordered_within(o,d,any_hit,excluded,minimum,inside,FAR,-1.0); }
|
||||
return trace_precise_within(o,o_low,d,any_hit,excluded,minimum,inside,FAR,-1.0);
|
||||
}
|
||||
// Only hits closer than `limit` are reported, and nodes beyond it are culled.
|
||||
// An opaque or shadow-blocking hit closer than `blocker` ends traversal at once:
|
||||
// a shadow connection is blocked whichever such hit is closest.
|
||||
fn trace_precise_within(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, any_hit: bool, excluded: u32, minimum: f32, inside: bool, limit: f32, blocker: f32) -> Hit {
|
||||
// Dispatch once before traversal. Ordinary rays keep the original lean BVH
|
||||
// loop, without precision-helper branches inside every candidate/node test.
|
||||
if all(o_low==vec3<f32>(0.0)) { return trace_ordered_within(o,d,any_hit,excluded,minimum,inside,limit,blocker); }
|
||||
// One shared walk bounds native inlining for primary, surface and volume
|
||||
// connections. The ordinary branch retains its original candidate arithmetic;
|
||||
// only a retained low origin selects the compensated candidate policy.
|
||||
let has_origin_residual=any(o_low!=vec3<f32>(0.0));
|
||||
var hit = Hit(limit, 0xffffffffu, vec3<f32>(0.0));
|
||||
// Project every candidate into the same ray-aligned 2D coordinate system.
|
||||
// Shared edges then use identical products with opposite signs, avoiding the
|
||||
@@ -388,10 +454,12 @@ fn trace_precise_within(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, any_hit: b
|
||||
// than the near radius uses ordinary arithmetic from it: that error cannot
|
||||
// reorder such a hit, but it can move the ray across an edge, so decisions
|
||||
// within the error bound of an edge or bound fall back to the exact path.
|
||||
let rounded=o+o_low;
|
||||
var rounded=o;
|
||||
if has_origin_residual { rounded=o+o_low; }
|
||||
let scale=max(max(abs(rounded.x),max(abs(rounded.y),abs(rounded.z))),1.0);
|
||||
let origin_error=scale*(1.0/1048576.0);
|
||||
let pad=u.lighting.x+origin_error;
|
||||
var pad=u.lighting.x;
|
||||
if has_origin_residual { pad=u.lighting.x+origin_error; }
|
||||
var source: Triangle;
|
||||
if excluded!=0xffffffffu { source=triangle(excluded); }
|
||||
let near_radius=scale*(1.0/256.0);
|
||||
@@ -429,6 +497,32 @@ fn trace_precise_within(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, any_hit: b
|
||||
// IDs join only connected, exactly coplanar uploaded f32 triangles
|
||||
// of one body; curved facets and other bodies remain candidates.
|
||||
if excluded!=0xffffffffu && geometry[geometry[3]+i]==geometry[geometry[3]+excluded] { continue; }
|
||||
// The ordinary source uses exactly the former ordered candidate
|
||||
// branch. Sharing only the node walk avoids another inlined BVH.
|
||||
if !has_origin_residual {
|
||||
let t = triangle(i);
|
||||
if !triangle_ahead(t,o,d) { continue; }
|
||||
let pa=t.p0.xyz-o; let pb=t.p1.xyz-o; let pc=t.p2.xyz-o;
|
||||
let a=vec2<f32>(pa[kx]-shear.x*pa[kz],pa[ky]-shear.y*pa[kz]);
|
||||
let b=vec2<f32>(pb[kx]-shear.x*pb[kz],pb[ky]-shear.y*pb[kz]);
|
||||
let c=vec2<f32>(pc[kx]-shear.x*pc[kz],pc[ky]-shear.y*pc[kz]);
|
||||
let weights=vec3<f32>(difference_products(b.x,c.y,b.y,c.x),difference_products(c.x,a.y,c.y,a.x),difference_products(a.x,b.y,a.y,b.x));
|
||||
if any(weights<vec3<f32>(0.0)) && any(weights>vec3<f32>(0.0)) { continue; }
|
||||
let det=weights.x+weights.y+weights.z;
|
||||
if det==0.0 { continue; }
|
||||
// Retain all three directly evaluated weights: 1-u-v can erase a
|
||||
// small corner clearance when the other two weights sum to one.
|
||||
let bary=weights/det;
|
||||
let depth=surface_ray_depth(t,bary,o,kz);
|
||||
let distance=depth*shear.z;
|
||||
let key=depth*order;
|
||||
if excluded!=0xffffffffu && repeated_crossing(t,source,d,inside,distance,origin_error) { continue; }
|
||||
if distance > minimum && distance < limit && (key < best || (key == best && tie_preferred(t,d,inside))) {
|
||||
hit = Hit(distance,i,bary); best=key;
|
||||
if any_hit || (distance < blocker && (t.base.w<=0.0 || t.coat.w<=0.0)) { return hit; }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let t = triangle(i);
|
||||
let lo=min(t.p0.xyz,min(t.p1.xyz,t.p2.xyz));
|
||||
let hi=max(t.p0.xyz,max(t.p1.xyz,t.p2.xyz));
|
||||
@@ -568,6 +662,144 @@ fn trace_ordered_within(o: vec3<f32>, d: vec3<f32>, any_hit: bool, excluded: u32
|
||||
fn trace(o: vec3<f32>, d: vec3<f32>, any_hit: bool) -> Hit {
|
||||
return trace_bounded(o,d,any_hit,0xffffffffu,u.lighting.x);
|
||||
}
|
||||
// Origin classification is separate from visible/shadow traversal. A nearer
|
||||
// opaque triangle, thin sheet or analytic emitter cannot hide a closed boundary.
|
||||
// This uses the existing uploaded-f32 intersection domain and no launch epsilon:
|
||||
// exact numeric contacts, silhouette edges/vertices and tied nearest boundaries
|
||||
// are ambiguous certificates, not an invitation to guess a containing medium.
|
||||
struct OriginBoundary { hit: Hit, valid: bool };
|
||||
struct OriginEdge { start: vec3<f32>, end: vec3<f32> };
|
||||
fn origin_edge(t: Triangle, zero: u32) -> OriginEdge {
|
||||
if zero==0u { return OriginEdge(t.p1.xyz,t.p2.xyz); }
|
||||
if zero==1u { return OriginEdge(t.p2.xyz,t.p0.xyz); }
|
||||
return OriginEdge(t.p0.xyz,t.p1.xyz);
|
||||
}
|
||||
fn origin_internal_edge(a: Triangle, ai: u32, az: u32, b: Triangle, bi: u32, bz: u32, d: vec3<f32>) -> bool {
|
||||
if geometry[geometry[3]+ai]!=geometry[geometry[3]+bi] { return false; }
|
||||
// The host patch proves connected exact uploaded-f32 coplanarity in one
|
||||
// body. Still require the same actual region/parent/medium coefficients.
|
||||
if a.parameters.z!=b.parameters.z || any(a.optics!=b.optics) || any(a.interior!=b.interior) { return false; }
|
||||
let ae=origin_edge(a,az);let be=origin_edge(b,bz);
|
||||
if any(ae.start!=be.end) || any(ae.end!=be.start) { return false; }
|
||||
let at=dot(cross(a.p1.xyz-a.p0.xyz,a.p2.xyz-a.p0.xyz),d);
|
||||
let bt=dot(cross(b.p1.xyz-b.p0.xyz,b.p2.xyz-b.p0.xyz),d);
|
||||
return at!=0.0 && bt!=0.0 && (at<0.0)==(bt<0.0);
|
||||
}
|
||||
fn origin_on_boundary(t: Triangle, o: vec3<f32>) -> bool {
|
||||
let lo=min(t.p0.xyz,min(t.p1.xyz,t.p2.xyz));
|
||||
let hi=max(t.p0.xyz,max(t.p1.xyz,t.p2.xyz));
|
||||
if any(o<lo) || any(o>hi) { return false; }
|
||||
let normal=cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz);
|
||||
if all(normal==vec3<f32>(0.0)) { return true; }
|
||||
if dot(normal,o-t.p0.xyz)!=0.0 { return false; }
|
||||
var axis=0u;
|
||||
if abs(normal.y)>abs(normal.x) { axis=1u; }
|
||||
if abs(normal.z)>abs(normal[axis]) { axis=2u; }
|
||||
let x=(axis+1u)%3u;let y=(x+1u)%3u;
|
||||
let pa=t.p0.xyz-o;let pb=t.p1.xyz-o;let pc=t.p2.xyz-o;
|
||||
let a=vec2<f32>(pa[x],pa[y]);let b=vec2<f32>(pb[x],pb[y]);let c=vec2<f32>(pc[x],pc[y]);
|
||||
let weights=vec3<f32>(difference_products(b.x,c.y,b.y,c.x),difference_products(c.x,a.y,c.y,a.x),difference_products(a.x,b.y,a.y,b.x));
|
||||
return !(any(weights<vec3<f32>(0.0)) && any(weights>vec3<f32>(0.0)));
|
||||
}
|
||||
fn origin_boundary(o: vec3<f32>, d: vec3<f32>) -> OriginBoundary {
|
||||
var hit=Hit(FAR,0xffffffffu,vec3<f32>(0.0));
|
||||
var best=FAR;
|
||||
var ambiguous=FAR;
|
||||
var edge_index=3u;
|
||||
var edge_count=0u;
|
||||
var kz=0u;
|
||||
if abs(d.y)>abs(d.x) { kz=1u; }
|
||||
if abs(d.z)>abs(d[kz]) { kz=2u; }
|
||||
let kx=(kz+1u)%3u;let ky=(kx+1u)%3u;
|
||||
let inverse_depth=1.0/d[kz];
|
||||
let order=select(-1.0,1.0,d[kz]>0.0);
|
||||
// The same host-built depth<=30 BVH bounds both visible and origin queries.
|
||||
// These fixed arrays replace no image/buffer and require no storage upload.
|
||||
var stack: array<u32,32>;
|
||||
var entry: array<f32,32>;
|
||||
if nodes[0].first==0xffffffffu { return OriginBoundary(hit,true); }
|
||||
entry[0]=box_entry_padded(nodes[0],o,d,FAR,u.lighting.x);
|
||||
if entry[0]<0.0 { return OriginBoundary(hit,true); }
|
||||
stack[0]=0u;var size=1u;
|
||||
loop {
|
||||
if size==0u { break; }
|
||||
size-=1u;
|
||||
if entry[size]>hit.distance { continue; }
|
||||
let node=nodes[stack[size]];
|
||||
if node.count==0u {
|
||||
if size+2u>32u { return OriginBoundary(hit,false); }
|
||||
let left=box_entry_padded(nodes[node.first],o,d,hit.distance,u.lighting.x);
|
||||
let right=box_entry_padded(nodes[node.first+1u],o,d,hit.distance,u.lighting.x);
|
||||
var near_child=node.first;var near_entry=left;
|
||||
var far_child=node.first+1u;var far_entry=right;
|
||||
if right>=0.0 && (left<0.0 || right<left) {
|
||||
near_child=node.first+1u;near_entry=right;
|
||||
far_child=node.first;far_entry=left;
|
||||
}
|
||||
if far_entry>=0.0 { stack[size]=far_child;entry[size]=far_entry;size+=1u; }
|
||||
if near_entry>=0.0 { stack[size]=near_child;entry[size]=near_entry;size+=1u; }
|
||||
continue;
|
||||
}
|
||||
for(var i=node.first;i<node.first+node.count;i+=1u) {
|
||||
let t=triangle(i);
|
||||
if t.base.w<=0.0 || t.parameters.w>=0.0 { continue; }
|
||||
// Check the actual point before the forward-ray fast rejection. A
|
||||
// boundary origin moving away must not be assigned vacuum by it.
|
||||
if origin_on_boundary(t,o) { return OriginBoundary(hit,false); }
|
||||
if !triangle_ahead(t,o,d) { continue; }
|
||||
let pa=t.p0.xyz-o;let pb=t.p1.xyz-o;let pc=t.p2.xyz-o;
|
||||
// Keep the ray projection homogeneous: a rounded d[kx]/d[kz]
|
||||
// must not turn an exact uploaded-f32 edge tangent into a crossing.
|
||||
// All projected coordinates share d[kz], so barycentrics are invariant.
|
||||
let a=vec2<f32>(difference_products(pa[kx],d[kz],d[kx],pa[kz]),difference_products(pa[ky],d[kz],d[ky],pa[kz]));
|
||||
let b=vec2<f32>(difference_products(pb[kx],d[kz],d[kx],pb[kz]),difference_products(pb[ky],d[kz],d[ky],pb[kz]));
|
||||
let c=vec2<f32>(difference_products(pc[kx],d[kz],d[kx],pc[kz]),difference_products(pc[ky],d[kz],d[ky],pc[kz]));
|
||||
let weights=vec3<f32>(difference_products(b.x,c.y,b.y,c.x),difference_products(c.x,a.y,c.y,a.x),difference_products(a.x,b.y,a.y,b.x));
|
||||
if any(weights<vec3<f32>(0.0)) && any(weights>vec3<f32>(0.0)) { continue; }
|
||||
let det=weights.x+weights.y+weights.z;
|
||||
if det==0.0 { continue; }
|
||||
let bary=weights/det;
|
||||
let depth=surface_ray_depth(t,bary,o,kz);
|
||||
let distance=depth*inverse_depth;
|
||||
let key=depth*order;
|
||||
if distance==0.0 { return OriginBoundary(hit,false); }
|
||||
if distance<0.0 || distance>=FAR { continue; }
|
||||
let transverse=dot(cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz),d);
|
||||
let zeros=select(0u,1u,weights.x==0.0)+select(0u,1u,weights.y==0.0)+select(0u,1u,weights.z==0.0);
|
||||
// A vertex needs a complete planar fan proof, which this query does
|
||||
// not infer. Tangencies and actual boundary origins stay refusals.
|
||||
if zeros>1u || transverse==0.0 {
|
||||
ambiguous=min(ambiguous,key);
|
||||
continue;
|
||||
}
|
||||
var zero=3u;
|
||||
if zeros==1u {
|
||||
if weights.x==0.0 { zero=0u; }
|
||||
else if weights.y==0.0 { zero=1u; }
|
||||
else { zero=2u; }
|
||||
}
|
||||
if key==best {
|
||||
// Only the exact opposite directed edge of two certified
|
||||
// coplanar triangles proves an internal triangulation edge.
|
||||
// Two arbitrary same-patch hits, perimeter edges and key-near
|
||||
// interfaces are insufficient; there is no distance tolerance.
|
||||
if edge_count==1u && zero<3u && origin_internal_edge(triangle(hit.triangle),hit.triangle,edge_index,t,i,zero,d) {
|
||||
edge_count=2u;
|
||||
} else { ambiguous=min(ambiguous,key); }
|
||||
}
|
||||
if key<best {
|
||||
hit=Hit(distance,i,bary);best=key;edge_index=zero;
|
||||
edge_count=select(0u,1u,zero<3u);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The ordinary primary uses this existing lower interval bound. A real
|
||||
// interface inside it cannot be skipped and treated as constant air/glass.
|
||||
let clipped=hit.triangle!=0xffffffffu && hit.distance<=u.lighting.x;
|
||||
return OriginBoundary(hit,!(ambiguous<FAR && ambiguous<=best) && edge_count!=1u && !clipped);
|
||||
}
|
||||
|
||||
|
||||
fn dominant_vertex(bary: vec3<f32>) -> u32 {
|
||||
var anchor=0u;
|
||||
if bary.y>bary.x { anchor=1u; }
|
||||
@@ -723,19 +955,24 @@ const FIXED_LIGHTS = array<vec4<f32>,3>(vec4<f32>(0.6,0.0,0.8,3.0),vec4<f32>(-0.
|
||||
// Next-event estimation for every light source family through ONE shadow-ray call site, like
|
||||
// volume_direct. Native compilers inline each call of the BVH walk; one walk per source family
|
||||
// made `main` too large for NVIDIA's Vulkan compiler (#6705). Studio lighting visits every area
|
||||
// light, then the environment, then the sun, drawing random numbers in that order; without it
|
||||
// light, then the environment, then the sun, drawing random numbers in that order; hard
|
||||
// directions follow without consuming random numbers. Without explicit lighting
|
||||
// the three fixed lights are used.
|
||||
fn direct_light(t: Triangle, position: vec3<f32>, o: vec3<f32>, n: vec3<f32>, geometric: vec3<f32>, v: vec3<f32>, remaining: u32, rng: ptr<function,u32>) -> vec3<f32> {
|
||||
fn direct_light(t: Triangle, position: vec3<f32>, physical: PrecisePoint, o: vec3<f32>, n: vec3<f32>, geometric: vec3<f32>, v: vec3<f32>, remaining: u32, rng: ptr<function,u32>) -> vec3<f32> {
|
||||
var result = vec3<f32>(0.0);
|
||||
let opaque = 1.0-t.base.w;
|
||||
let studio = u.environment.w > 0.0;
|
||||
if studio && opaque<=0.0 { return result; }
|
||||
let area_count = u32(u.light_count.w);
|
||||
let count = select(3u, area_count+2u, studio);
|
||||
let count = select(3u, area_count+2u+u32(u.light_count.x)+u32(u.light_count.y), studio);
|
||||
for (var source=0u; source<count; source+=1u) {
|
||||
var sample = EnvironmentSample(vec3<f32>(0.0,0.0,1.0),vec3<f32>(0.0),0.0);
|
||||
var shadow_direction = vec3<f32>(0.0,0.0,1.0);
|
||||
var shadow_distance = FAR;
|
||||
var shadow_origin = o;
|
||||
var shadow_origin_low = vec3<f32>(0.0);
|
||||
var punctual = false;
|
||||
var physical_distance = FAR;
|
||||
if !studio {
|
||||
let light = FIXED_LIGHTS[source];
|
||||
if u.lighting.z <= 0.0 || dot(geometric,light.xyz)<=0.0 || dot(n,light.xyz)<=0.0 { continue; }
|
||||
@@ -754,25 +991,59 @@ fn direct_light(t: Triangle, position: vec3<f32>, o: vec3<f32>, n: vec3<f32>, ge
|
||||
if u.environment_map.w<=0.0 && !any(u.environment.xyz>vec3<f32>(0.0)) { continue; }
|
||||
sample = env_sample(rng);
|
||||
shadow_direction = sample.direction;
|
||||
} else {
|
||||
} else if source==area_count+1u {
|
||||
if u.sun_radiance.w<=0.0 { continue; }
|
||||
sample = sun_sample(rng);
|
||||
shadow_direction = sample.direction;
|
||||
} else if source<area_count+2u+u32(u.light_count.x) {
|
||||
let light = u.directional_lights[source-area_count-2u];
|
||||
if light.direction.w<=0.0 || !any(light.color.xyz>vec3<f32>(0.0)) { continue; }
|
||||
sample = EnvironmentSample(light.direction.xyz,light.color.xyz*light.direction.w,1.0);
|
||||
shadow_direction = sample.direction;
|
||||
} else {
|
||||
punctual = true;
|
||||
let light = u.punctual_lights[source-area_count-2u-u32(u.light_count.x)];
|
||||
let value = punctual_sample(light,physical);
|
||||
if value.omitted!=0u { continue; }
|
||||
sample = EnvironmentSample(value.direction,value.radiance,1.0);
|
||||
physical_distance = value.distance;
|
||||
var contribution=vec3<f32>(0.0);
|
||||
if dot(n,sample.direction)>0.0 && dot(geometric,sample.direction)>0.0 {
|
||||
contribution=brdf(t,n,v,sample.direction)*(opaque*dot(n,sample.direction)*sample.radiance);
|
||||
}
|
||||
if !punctual_radiance_valid(contribution) { punctual_count(1u); continue; }
|
||||
// Irradiance uses the physical point; the robust connection is
|
||||
// separately re-aimed from an offset of that same precise point.
|
||||
let shifted=punctual_offset(physical,geometric*(u.lighting.x*2.0));
|
||||
shadow_origin=shifted.hi;
|
||||
shadow_origin_low=shifted.lo;
|
||||
let connection=precise_point_delta(light.position.xyz,light.position_low.xyz,shadow_origin,shadow_origin_low);
|
||||
shadow_distance=length(connection);
|
||||
if !(shadow_distance>0.0) || dot(connection,sample.direction)<=0.0 {
|
||||
punctual_count(2u); continue;
|
||||
}
|
||||
shadow_direction=connection/shadow_distance;
|
||||
shadow_distance=punctual_endpoint(shadow_distance);
|
||||
}
|
||||
if sample.pdf<=0.0 || dot(n,sample.direction)<=0.0 || dot(geometric,sample.direction)<=0.0 { continue; }
|
||||
}
|
||||
// Studio emitters block a connection that passes them; studio volumes attenuate it.
|
||||
var visibility = blocked_shadow();
|
||||
if !studio || emitter_hit(o,shadow_direction).distance>=shadow_distance-u.lighting.x*2.0 {
|
||||
visibility = shadow_transmittance(o,shadow_direction,shadow_distance);
|
||||
let shadow_minimum=select(u.lighting.x,0.0,punctual);
|
||||
let emitter_limit=shadow_distance-select(u.lighting.x*2.0,0.0,punctual);
|
||||
if !studio || emitter_hit_precise(shadow_origin,shadow_origin_low,shadow_direction,shadow_minimum).distance>=emitter_limit {
|
||||
visibility = shadow_transmittance(shadow_origin,shadow_origin_low,shadow_direction,shadow_distance,shadow_minimum);
|
||||
if studio && u.volume_min.w>0.0 {
|
||||
let attenuation=volume_transmittance(o,shadow_direction,shadow_distance);
|
||||
var attenuation=volume_transmittance_precise(shadow_origin,shadow_origin_low,shadow_direction,shadow_distance);
|
||||
if punctual { attenuation=volume_transmittance_precise(physical.hi,physical.lo,sample.direction,physical_distance); }
|
||||
visibility.transmittance*=attenuation;
|
||||
visibility.branch_pdf*=attenuation;
|
||||
}
|
||||
}
|
||||
let nl = dot(n,sample.direction);
|
||||
if !studio {
|
||||
// A hard source is a delta distribution. A continuous BSDF continuation
|
||||
// has no competing probability mass at that direction, so its MIS weight is one.
|
||||
if !studio || source>=area_count+2u {
|
||||
result += visibility.transmittance * brdf(t,n,v,sample.direction) * (opaque*nl*sample.radiance);
|
||||
continue;
|
||||
}
|
||||
@@ -836,11 +1107,30 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
var media: array<Medium,16>;
|
||||
var medium_count = 0u;
|
||||
for (var bounce = 0u; bounce < u.sampling.y; bounce += 1u) {
|
||||
// Classify the actual sampled primary/pupil origin once, before an opaque
|
||||
// hit, emitter endpoint, subsurface flight or escape can consume its segment.
|
||||
// No random draw or extra traversal is added to nested_media=false paths.
|
||||
if bounce==0u && u.lighting.w>0.0 {
|
||||
let boundary=origin_boundary(o,d);
|
||||
if !boundary.valid { return medium_failure(radiance,coverage,pixel,0u,4u); }
|
||||
if boundary.hit.triangle!=0xffffffffu {
|
||||
let first=triangle(boundary.hit.triangle);
|
||||
// Match initialize_media's exact uploaded-f32 entry predicate. A
|
||||
// certified first entry with no parent already proves vacuum; its
|
||||
// initializer would only write the existing zero count again.
|
||||
let vacuum=first.interior.w==0.0 && dot(unit(cross(first.p1.xyz-first.p0.xyz,first.p2.xyz-first.p0.xyz)),d)<0.0;
|
||||
if !vacuum {
|
||||
if !initialize_media(boundary.hit.triangle,d,&media,&medium_count) { return medium_failure(radiance,coverage,pixel,0u,4u); }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Closed-glass continuations set an excluded producing triangle; every
|
||||
// other launch (camera, opaque or thin-sheet surface) does not.
|
||||
let continued = ray_excluded != 0xffffffffu;
|
||||
let hit = trace_precise(o,o_low,d,false,ray_excluded,ray_minimum,medium_count>0u);
|
||||
if u.lighting.w>0.0 && medium_count==0u && hit.triangle!=0xffffffffu {
|
||||
// The complete origin query exclusively owns primary classification.
|
||||
// Retain the established fallback for later opaque/thin surface launches.
|
||||
if u.lighting.w>0.0 && bounce>0u && medium_count==0u && hit.triangle!=0xffffffffu {
|
||||
if !initialize_media(hit.triangle,d,&media,&medium_count) {return medium_failure(radiance,coverage,pixel,bounce,4u);}
|
||||
}
|
||||
if medium_count>0u && media[medium_count-1u].scatter.x>0.0 {
|
||||
@@ -866,7 +1156,15 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
}
|
||||
if u.volume_min.w>0.0 {
|
||||
let emitter_distance=emitter_hit_precise(o,o_low,d,ray_minimum).distance;
|
||||
let segment=volume_segment(o+o_low,d,min(hit.distance,emitter_distance));
|
||||
var segment=vec2<f32>(0.0);
|
||||
if bounce==0u && u.eye.w<=0.5 {
|
||||
// The orthographic camera has no physical eye plane. Its fog
|
||||
// line starts at the finite box entry, even before the artificial
|
||||
// geometry origin; the existing forward endpoint stays fixed.
|
||||
segment=volume_orthographic_segment(o+o_low,d,min(hit.distance,emitter_distance));
|
||||
} else {
|
||||
segment=volume_segment(o+o_low,d,min(hit.distance,emitter_distance));
|
||||
}
|
||||
let length=segment.y-segment.x;
|
||||
if length>0.0 {
|
||||
let travel=-log(1.0-random(&rng))/u.volume_min.w;
|
||||
@@ -876,7 +1174,9 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
throughput*=u.volume_albedo.xyz;
|
||||
if !any(throughput>vec3<f32>(0.0)) { break; }
|
||||
let remaining=u.sampling.y-bounce-1u;
|
||||
radiance+=throughput*volume_direct(position,d,remaining,&rng);
|
||||
var physical=PrecisePoint(o,o_low+d*(segment.x+travel));
|
||||
if u.light_count.y>0.0 {physical=punctual_offset(physical,vec3<f32>(0.0));}
|
||||
radiance+=throughput*volume_direct(position,physical,d,remaining,&rng);
|
||||
if remaining==0u { break; }
|
||||
let next=volume_sample(d,&rng);
|
||||
previous_pdf=volume_phase(dot(d,next));
|
||||
@@ -965,7 +1265,9 @@ fn sample_path_lane(pixel: u32, lane: u32) -> vec4<f32> {
|
||||
}
|
||||
previous_position = position; previous_position_low=position_low;
|
||||
o = position + geometric*(u.lighting.x*2.0); o_low=vec3<f32>(0.0);
|
||||
radiance += throughput*direct_light(t,position,o,n,geometric,v,u.sampling.y-bounce-1u,&rng);
|
||||
var physical=PrecisePoint(position,position_low);
|
||||
if u.light_count.y>0.0 { physical=punctual_offset(surface_point_precise(t,hit.bary),vec3<f32>(0.0)); }
|
||||
radiance += throughput*direct_light(t,position,physical,o,n,geometric,v,u.sampling.y-bounce-1u,&rng);
|
||||
if t.base.w > 0.0 && random(&rng) < t.base.w {
|
||||
var nt = t.parameters.z;
|
||||
if t.parameters.w < 0.0 && entering && medium_count > 0u && u.lighting.w==0.0 {
|
||||
@@ -1106,7 +1408,7 @@ fn adaptive_target(mean: vec4<f32>, m2: vec4<f32>) -> u32 {
|
||||
return clamp(u32(max(max(need.x,need.y),max(need.z,need.w))),u.adaptive_budget.y,maximum);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
@compute @workgroup_size(32)
|
||||
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let pixel = u.image.w + gid.x;
|
||||
let pixels = u.image.x*u.image.y;
|
||||
@@ -1383,3 +1685,16 @@ fn integer_relative_reference(@builtin(global_invocation_id) invocation: vec3<u3
|
||||
let candidate=anchored_delta_bits(a,al,b,bl);
|
||||
accumulation[i]=vec4<f32>(f32(reference&65535u),f32(reference>>16u),f32(candidate&65535u),f32(candidate>>16u));
|
||||
}
|
||||
|
||||
@compute @workgroup_size(32)
|
||||
fn split_predicate_reference(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
if gid.x>=geometry[0] { return; }
|
||||
let word=1u+gid.x*4u;
|
||||
let a=bitcast<f32>(geometry[word]);let b=bitcast<f32>(geometry[word+1u]);
|
||||
let c=bitcast<f32>(geometry[word+2u]);let d=bitcast<f32>(geometry[word+3u]);
|
||||
let product=split_product(split_scaled(a).value,split_scaled(b).value);
|
||||
let p=bitcast<u32>(product.x);let e=bitcast<u32>(product.y);
|
||||
let direct=bitcast<u32>(split_difference_products(a,b,c,d));let wrapped=bitcast<u32>(difference_products(a,b,c,d));
|
||||
accumulation[2u*gid.x]=vec4<f32>(f32(p&65535u),f32(p>>16u),f32(e&65535u),f32(e>>16u));
|
||||
accumulation[2u*gid.x+1u]=vec4<f32>(f32(direct&65535u),f32(direct>>16u),f32(wrapped&65535u),f32(wrapped>>16u));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Original relative point/spot transport. No sampling RNG, photometry or clamp.
|
||||
// Diagnostics count actual sampled surface/volume paths (adaptive pilots included).
|
||||
fn punctual_count(kind:u32) {
|
||||
let word=8u+kind*2u;
|
||||
if atomicAdd(&failures[word],1u)==0xffffffffu { atomicAdd(&failures[word+1u],1u); }
|
||||
}
|
||||
fn punctual_radiance_valid(value:vec3<f32>)->bool {
|
||||
return all(value>=vec3<f32>(0.0)) && all(value<=vec3<f32>(1e12));
|
||||
}
|
||||
struct PunctualSample { direction:vec3<f32>, radiance:vec3<f32>, distance:f32, omitted:u32 };
|
||||
fn punctual_sample(light:PunctualLight,position:PrecisePoint)->PunctualSample {
|
||||
// A barycentric anchor may have a large local displacement. Canonicalize
|
||||
// both parts before subtracting a nearby canonically packed source.
|
||||
let physical=punctual_offset(position,vec3<f32>(0.0));
|
||||
let delta=precise_point_delta(light.position.xyz,light.position_low.xyz,physical.hi,physical.lo);
|
||||
let distance=length(delta);
|
||||
// Count near-source before testing intensity/colour/cone, like the CPU.
|
||||
if !(distance>=light.position_low.w) {
|
||||
punctual_count(0u);
|
||||
return PunctualSample(vec3<f32>(0.0,0.0,1.0),vec3<f32>(0.0),distance,1u);
|
||||
}
|
||||
let direction=delta/distance;
|
||||
var cone=1.0;
|
||||
if light.cone.y>0.0 {
|
||||
let cosine=dot(light.axis.xyz,-direction);
|
||||
let t=clamp((cosine-light.cone.x)/(light.axis.w-light.cone.x),0.0,1.0);
|
||||
cone=t*t*(3.0-2.0*t);
|
||||
}
|
||||
let ratio=light.position.w/distance;
|
||||
let incident=light.color.xyz*(light.color.w*ratio*ratio*cone);
|
||||
if !punctual_radiance_valid(incident) {
|
||||
punctual_count(1u);
|
||||
return PunctualSample(direction,vec3<f32>(0.0),distance,2u);
|
||||
}
|
||||
return PunctualSample(direction,incident,distance,0u);
|
||||
}
|
||||
|
||||
// Finite delta sources exclude their endpoint, as the CPU next_down interval does.
|
||||
fn punctual_endpoint(distance:f32)->f32 { return bitcast<f32>(bitcast<u32>(distance)-1u); }
|
||||
|
||||
// Canonicalize an anchored point before adding a tiny geometric offset. Adding
|
||||
// directly to an arbitrarily large local barycentric displacement can erase it.
|
||||
fn punctual_offset(point:PrecisePoint,offset:vec3<f32>)->PrecisePoint {
|
||||
var hi=vec3<f32>(0.0);var lo=vec3<f32>(0.0);
|
||||
for(var axis=0u;axis<3u;axis+=1u) {
|
||||
hi[axis]=bitcast<f32>(fp_add_bits(bitcast<u32>(point.hi[axis]),bitcast<u32>(point.lo[axis])));
|
||||
lo[axis]=anchored_delta_component(point.hi[axis],point.lo[axis],hi[axis],0.0)+offset[axis];
|
||||
}
|
||||
return PrecisePoint(hi,lo);
|
||||
}
|
||||
@@ -4,20 +4,20 @@ struct ShadowResult { transmittance: vec3<f32>, branch_pdf: f32, crossings: u32,
|
||||
fn blocked_shadow() -> ShadowResult {
|
||||
return ShadowResult(vec3<f32>(0.0),0.0,0u,false);
|
||||
}
|
||||
fn shadow_transmittance(origin: vec3<f32>, direction: vec3<f32>, limit: f32) -> ShadowResult {
|
||||
fn shadow_transmittance(origin: vec3<f32>, origin_low: vec3<f32>, direction: vec3<f32>, limit: f32, minimum: f32) -> ShadowResult {
|
||||
var result = ShadowResult(vec3<f32>(1.0),1.0,0u,false);
|
||||
var o = origin;
|
||||
var o_low = vec3<f32>(0.0);
|
||||
var o_low = origin_low;
|
||||
var ray_excluded = 0xffffffffu;
|
||||
var ray_minimum = u.lighting.x;
|
||||
var ray_minimum = minimum;
|
||||
var medium_id = 0.0;
|
||||
var medium_ior = 1.0;
|
||||
var sigma = vec3<f32>(0.0);
|
||||
var entry = origin;
|
||||
var entry_low = vec3<f32>(0.0);
|
||||
var entry_low = origin_low;
|
||||
// Up to 64 interfaces. If traversal cannot establish visibility, block it.
|
||||
for (var step=0u; step<=64u; step+=1u) {
|
||||
let traversed = dot(precise_point_delta(o,o_low,origin,vec3<f32>(0.0)),direction);
|
||||
let traversed = dot(precise_point_delta(o,o_low,origin,origin_low),direction);
|
||||
let remaining = limit-traversed;
|
||||
// Outside media nothing beyond the endpoint matters. Inside, the
|
||||
// endpoint branch below must still see the farther boundary.
|
||||
@@ -75,7 +75,7 @@ fn shadow_transmittance(origin: vec3<f32>, direction: vec3<f32>, limit: f32) ->
|
||||
// The first crossing from the shading point exits a closed body:
|
||||
// the point touches or lies within it (see the path loop).
|
||||
if medium_id==0.0 && step==0u {
|
||||
medium_id=t.optics.w; medium_ior=t.parameters.z; sigma=t.optics.xyz; entry=origin; entry_low=vec3<f32>(0.0);
|
||||
medium_id=t.optics.w; medium_ior=t.parameters.z; sigma=t.optics.xyz; entry=origin; entry_low=origin_low;
|
||||
}
|
||||
if medium_id==0.0 || medium_id!=t.optics.w { return blocked_shadow(); }
|
||||
result.transmittance*=exp(-sigma*length(precise_point_delta(position,position_low,entry,entry_low)));
|
||||
@@ -99,7 +99,7 @@ fn shadow_transmittance(origin: vec3<f32>, direction: vec3<f32>, limit: f32) ->
|
||||
|
||||
@compute @workgroup_size(1)
|
||||
fn shadow_reference() {
|
||||
let shadow=shadow_transmittance(u.eye.xyz,u.back.xyz,u.back.w);
|
||||
let shadow=shadow_transmittance(u.eye.xyz,vec3<f32>(0.0),u.back.xyz,u.back.w,u.lighting.x);
|
||||
accumulation[0]=vec4<f32>(shadow.transmittance,1.0);
|
||||
accumulation[1]=vec4<f32>(shadow.branch_pdf,f32(shadow.crossings),select(0.0,1.0,shadow.solid),1.0);
|
||||
}
|
||||
|
||||
+1468
-11
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,19 @@ fn volume_segment(o: vec3<f32>, d: vec3<f32>, limit: f32) -> vec2<f32> {
|
||||
}
|
||||
return vec2<f32>(enter,leave);
|
||||
}
|
||||
fn volume_transmittance_precise(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, distance: f32) -> f32 {
|
||||
if all(o_low==vec3<f32>(0.0)) { return volume_transmittance(o,d,distance); }
|
||||
if u.volume_min.w<=0.0 { return 1.0; }
|
||||
let lo=precise_point_delta(u.volume_min.xyz,vec3<f32>(0.0),o,o_low);
|
||||
let hi=precise_point_delta(u.volume_max.xyz,vec3<f32>(0.0),o,o_low);
|
||||
var enter=0.0; var leave=distance;
|
||||
for(var axis=0u;axis<3u;axis+=1u) {
|
||||
if d[axis]==0.0 { if lo[axis]>0.0 || hi[axis]<0.0 { return 1.0; } }
|
||||
else { let a=lo[axis]/d[axis]; let b=hi[axis]/d[axis]; enter=max(enter,min(a,b));leave=min(leave,max(a,b)); }
|
||||
if enter>=leave { return 1.0; }
|
||||
}
|
||||
return exp(-u.volume_min.w*(leave-enter));
|
||||
}
|
||||
fn volume_transmittance(o: vec3<f32>, d: vec3<f32>, distance: f32) -> f32 {
|
||||
let segment = volume_segment(o,d,distance);
|
||||
return exp(-u.volume_min.w*(segment.y-segment.x));
|
||||
@@ -48,23 +61,26 @@ fn sample_hg(direction: vec3<f32>,g:f32,rng: ptr<function,u32>) -> vec3<f32> {
|
||||
fn volume_sample(direction: vec3<f32>,rng:ptr<function,u32>) -> vec3<f32> {
|
||||
return sample_hg(direction,u.volume_max.w,rng);
|
||||
}
|
||||
fn volume_visible(o: vec3<f32>, d: vec3<f32>, distance: f32) -> ShadowResult {
|
||||
fn volume_visible(o: vec3<f32>, o_low: vec3<f32>, d: vec3<f32>, distance: f32, punctual: bool) -> ShadowResult {
|
||||
// Volume launches are interior points, not offsets from a surface. Glass is
|
||||
// refused by admission, so the ordinary opaque traversal needs no interface walk.
|
||||
if emitter_hit_bounded(o,d,0.0).distance<distance-u.lighting.x*2.0 { return blocked_shadow(); }
|
||||
let hit=trace_ordered_within(o,d,true,0xffffffffu,0.0,false,distance,-1.0);
|
||||
if emitter_hit_precise(o,o_low,d,0.0).distance<distance-select(u.lighting.x*2.0,0.0,punctual) { return blocked_shadow(); }
|
||||
let hit=trace_precise_within(o,o_low,d,true,0xffffffffu,0.0,false,distance,-1.0);
|
||||
if hit.triangle!=0xffffffffu { return blocked_shadow(); }
|
||||
let attenuation=volume_transmittance(o,d,distance);
|
||||
let attenuation=volume_transmittance_precise(o,o_low,d,distance);
|
||||
return ShadowResult(vec3<f32>(attenuation),attenuation,0u,false);
|
||||
}
|
||||
fn volume_direct(position: vec3<f32>, incoming: vec3<f32>, remaining: u32, rng: ptr<function,u32>) -> vec3<f32> {
|
||||
fn volume_direct(position: vec3<f32>, physical: PrecisePoint, incoming: vec3<f32>, remaining: u32, rng: ptr<function,u32>) -> vec3<f32> {
|
||||
var radiance=vec3<f32>(0.0);
|
||||
let count=u32(u.light_count.w);
|
||||
// One shared visibility evaluation avoids cloning the complete BVH walk
|
||||
// separately for area, environment and sun source families in this branch.
|
||||
for (var source=0u; source<count+2u; source+=1u) {
|
||||
for (var source=0u; source<count+2u+u32(u.light_count.x)+u32(u.light_count.y); source+=1u) {
|
||||
var sample=EnvironmentSample(vec3<f32>(0.0,0.0,1.0),vec3<f32>(0.0),0.0);
|
||||
var distance=FAR;
|
||||
var origin=position;
|
||||
var origin_low=vec3<f32>(0.0);
|
||||
var punctual=false;
|
||||
if source<count {
|
||||
let light=u.area_lights[source];
|
||||
let point=light.center.xyz+light.u.xyz*(2.0*random(rng)-1.0)+light.v.xyz*(2.0*random(rng)-1.0);
|
||||
@@ -75,15 +91,48 @@ fn volume_direct(position: vec3<f32>, incoming: vec3<f32>, remaining: u32, rng:
|
||||
} else if source==count {
|
||||
if u.environment_map.w<=0.0 && !any(u.environment.xyz>vec3<f32>(0.0)) { continue; }
|
||||
sample=env_sample(rng);
|
||||
} else {
|
||||
} else if source==count+1u {
|
||||
if u.sun_radiance.w<=0.0 { continue; }
|
||||
sample=sun_sample(rng);
|
||||
} else if source<count+2u+u32(u.light_count.x) {
|
||||
let light=u.directional_lights[source-count-2u];
|
||||
if light.direction.w<=0.0 || !any(light.color.xyz>vec3<f32>(0.0)) { continue; }
|
||||
sample=EnvironmentSample(light.direction.xyz,light.color.xyz*light.direction.w,1.0);
|
||||
} else {
|
||||
punctual=true;
|
||||
let light=u.punctual_lights[source-count-2u-u32(u.light_count.x)];
|
||||
let value=punctual_sample(light,physical);
|
||||
if value.omitted!=0u { continue; }
|
||||
sample=EnvironmentSample(value.direction,value.radiance,1.0);
|
||||
origin=physical.hi; origin_low=physical.lo;
|
||||
distance=punctual_endpoint(value.distance);
|
||||
}
|
||||
if sample.pdf<=0.0 { continue; }
|
||||
let visibility=volume_visible(position,sample.direction,distance);
|
||||
let phase=volume_phase(dot(incoming,sample.direction));
|
||||
let weight=select(power_weight(sample.pdf,phase*visibility.branch_pdf),1.0,remaining==0u);
|
||||
if punctual && !punctual_radiance_valid(sample.radiance*phase) { punctual_count(1u); continue; }
|
||||
let visibility=volume_visible(origin,origin_low,sample.direction,distance,punctual);
|
||||
let weight=select(power_weight(sample.pdf,phase*visibility.branch_pdf),1.0,remaining==0u || source>=count+2u);
|
||||
radiance+=visibility.transmittance*sample.radiance*(phase*weight/sample.pdf);
|
||||
}
|
||||
return radiance;
|
||||
}
|
||||
|
||||
// Only the primary orthographic optical line uses signed entry. Secondary,
|
||||
// shadow and perspective rays retain volume_segment's physical forward origin.
|
||||
// The caller supplies the original forward geometry/emitter endpoint unchanged.
|
||||
fn volume_orthographic_segment(o: vec3<f32>, d: vec3<f32>, limit: f32) -> vec2<f32> {
|
||||
var enter = -FAR;
|
||||
var leave = limit;
|
||||
for (var axis = 0u; axis < 3u; axis += 1u) {
|
||||
if d[axis] == 0.0 {
|
||||
if o[axis] < u.volume_min[axis] || o[axis] > u.volume_max[axis] { return vec2<f32>(0.0); }
|
||||
} else {
|
||||
let a = (u.volume_min[axis]-o[axis])/d[axis];
|
||||
let b = (u.volume_max[axis]-o[axis])/d[axis];
|
||||
enter=max(enter,min(a,b));
|
||||
leave=min(leave,max(a,b));
|
||||
}
|
||||
if enter >= leave { return vec2<f32>(0.0); }
|
||||
}
|
||||
return vec2<f32>(enter,leave);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,13 @@ fn slab() -> HomogeneousVolume {
|
||||
fn settings(volume: HomogeneousVolume) -> JobOptions {
|
||||
JobOptions {
|
||||
volume: Some(volume),
|
||||
lighting: Some(StudioLighting { environment: [1.0; 3], sun: None, area_lights: Default::default() }),
|
||||
lighting: Some(StudioLighting {
|
||||
punctual_lights: Default::default(),
|
||||
directional_lights: Default::default(),
|
||||
environment: [1.0; 3],
|
||||
sun: None,
|
||||
area_lights: Default::default(),
|
||||
}),
|
||||
samples: 1024,
|
||||
max_depth: 1,
|
||||
seed: 3951,
|
||||
@@ -119,7 +125,7 @@ fn phase_reference(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
for g in [-0.8, -0.00005, 0.0, 0.00005, 0.8] {
|
||||
let medium = HomogeneousVolume { anisotropy: g, ..slab() };
|
||||
let mut task = Task::new(context.clone(), empty(), request(64), settings(medium), CancelToken::default()).unwrap();
|
||||
context.queue.write_buffer(&task.uniform, 0, &task.parameters(4096));
|
||||
context.queue.write_buffer(&task.uniform, 0, &task.parameters(4096).unwrap());
|
||||
let mut encoder = context.device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&Default::default());
|
||||
@@ -139,3 +145,657 @@ fn phase_reference(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_hard_directional_single_scatter_matches_independent_beer_slab_integral() {
|
||||
use solidcraft_render::job::{DirectionalLightOptions, DirectionalLights};
|
||||
let Some(executor) = executor() else { return };
|
||||
let medium = slab();
|
||||
let source = DirectionalLightOptions { direction: [0.0, 0.0, 1.0], color: [1.0, 0.5, 0.25], intensity: 2.0 };
|
||||
let options = JobOptions {
|
||||
lighting: Some(StudioLighting { directional_lights: DirectionalLights::new(&[source]).unwrap(), ..Default::default() }),
|
||||
..settings(medium)
|
||||
};
|
||||
let mut task = Task::new(executor.context.clone(), empty(), camera(), options, CancelToken::default()).unwrap();
|
||||
while task.sample < options.samples {
|
||||
task.complete_sample().unwrap();
|
||||
}
|
||||
let pixels = task.read_linear().unwrap();
|
||||
// Integrate sigma*exp(-sigma*t) camera attenuation times exp(-sigma*t)
|
||||
// light attenuation and isotropic phase 1/(4*pi), t in [0,2] mm.
|
||||
let expected = 0.8 * 2.0 * (1.0 - (-2.0_f64).exp()) / (8.0 * std::f64::consts::PI);
|
||||
for (channel, scale) in [(0, 1.0), (1, 0.5), (2, 0.25)] {
|
||||
let mean = pixels.iter().map(|p| f64::from(p[channel])).sum::<f64>() / pixels.len() as f64;
|
||||
assert!((mean - expected * scale).abs() < 0.025 * expected * scale, "channel{channel}: {mean} vs {}", expected * scale);
|
||||
}
|
||||
}
|
||||
|
||||
// These fixtures distinguish the orthographic optical line from the unchanged
|
||||
// forward geometry ray. No camera, mesh or render bounds are extended for fog.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum OpticalLineEndpoint {
|
||||
None,
|
||||
Emitter(f64),
|
||||
Surface(f64),
|
||||
}
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct OpticalLineCase {
|
||||
name: &'static str,
|
||||
fog: [f64; 2],
|
||||
target_z: f64,
|
||||
distance: f64,
|
||||
endpoint: OpticalLineEndpoint,
|
||||
length: f64,
|
||||
}
|
||||
fn optical_line_fixture(case: OpticalLineCase) -> (ProductScene, ImageRequest, JobOptions) {
|
||||
use solidcraft_render::job::{AreaLight, AreaLights, DirectionalLightOptions, DirectionalLights};
|
||||
let mut image = request(4);
|
||||
image.camera.target = Vec3::new(0.0, 0.0, case.target_z);
|
||||
image.camera.distance = case.distance;
|
||||
image.camera.fov = 0.0;
|
||||
image.background = [0; 3];
|
||||
image.transparent_background = true;
|
||||
let mut source = empty();
|
||||
let mut lighting = StudioLighting { environment: [0.0; 3], sun: None, ..Default::default() };
|
||||
match case.endpoint {
|
||||
OpticalLineEndpoint::None => {}
|
||||
OpticalLineEndpoint::Emitter(z) => {
|
||||
lighting.area_lights =
|
||||
AreaLights::new(&[AreaLight { center: [0.0, 0.0, z], u: [1000.0, 0.0, 0.0], v: [0.0, 1000.0, 0.0], radiance: [2.0, 3.0, 5.0] }])
|
||||
.unwrap();
|
||||
}
|
||||
OpticalLineEndpoint::Surface(z) => {
|
||||
let mesh = solidcraft_geom::Mesh {
|
||||
positions: vec![
|
||||
Vec3::new(-1000.0, -1000.0, z),
|
||||
Vec3::new(1000.0, -1000.0, z),
|
||||
Vec3::new(1000.0, 1000.0, z),
|
||||
Vec3::new(-1000.0, 1000.0, z),
|
||||
],
|
||||
normals: vec![Vec3::Z; 4],
|
||||
triangles: vec![[0, 1, 2], [0, 2, 3]],
|
||||
tri_face: vec![0, 0],
|
||||
..Default::default()
|
||||
};
|
||||
source.bounds = mesh.bounds();
|
||||
source.meshes.push(solidcraft_render::ProductMesh {
|
||||
name: "Existing forward-only surface".into(),
|
||||
mesh: Arc::new(mesh),
|
||||
color: [128, 64, 32],
|
||||
opacity: 1.0,
|
||||
surface: Some(OpaqueSurface::new(Vec3::new(0.5, 0.25, 0.125), 0.0, 0.5, 1.0).unwrap()),
|
||||
transmission: None,
|
||||
faces: Vec::new(),
|
||||
});
|
||||
lighting.directional_lights =
|
||||
DirectionalLights::new(&[DirectionalLightOptions { direction: [0.0, 0.0, 1.0], color: [1.0; 3], intensity: std::f64::consts::PI }])
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let volume = HomogeneousVolume {
|
||||
min_mm: [-10000.0, -10000.0, case.fog[0]],
|
||||
max_mm: [10000.0, 10000.0, case.fog[1]],
|
||||
extinction_per_mm: 1.0 / 128.0,
|
||||
scattering_albedo: [0.0; 3],
|
||||
anisotropy: 0.0,
|
||||
};
|
||||
let options =
|
||||
JobOptions { samples: 16, max_depth: 1, seed: 71, linear_exr: true, volume: Some(volume), lighting: Some(lighting), ..Default::default() };
|
||||
(source, image, options)
|
||||
}
|
||||
fn optical_line_collision(pixel: u32, ordinal: u32, seed: u32, optical_depth: f64) -> bool {
|
||||
// Independent scalar PCG stream: two camera jitter draws precede the first
|
||||
// exponential flight. No production camera/volume/sampler helper is used.
|
||||
let mut state = pixel ^ ordinal.wrapping_mul(0x9e3779b9) ^ seed;
|
||||
let mut random = || {
|
||||
state = state.wrapping_mul(747796405).wrapping_add(2891336453);
|
||||
let word = ((state >> ((state >> 28) + 4)) ^ state).wrapping_mul(277803737);
|
||||
f64::from((((word >> 22) ^ word) >> 8) as f32 * (1.0 / 16777216.0))
|
||||
};
|
||||
let _ = random();
|
||||
let _ = random();
|
||||
let hazard = -(1.0 - random()).ln();
|
||||
// Thresholds are independently separated from a GPU log/rounding boundary;
|
||||
// coverage remains exact, with no numerical acceptance band around it.
|
||||
assert!((hazard - optical_depth).abs() > 1e-5 || optical_depth == 0.0);
|
||||
optical_depth > 0.0 && hazard < optical_depth
|
||||
}
|
||||
fn optical_line_expected(case: OpticalLineCase, pixel: u32, samples: u32, seed: u32) -> [f64; 4] {
|
||||
let hits = (0..samples).filter(|ordinal| optical_line_collision(pixel, *ordinal, seed, case.length / 128.0)).count() as u32;
|
||||
let survival = f64::from(samples - hits) / f64::from(samples);
|
||||
let visible = match case.endpoint {
|
||||
OpticalLineEndpoint::None => false,
|
||||
OpticalLineEndpoint::Emitter(z) | OpticalLineEndpoint::Surface(z) => z < case.target_z + case.distance * 4.0,
|
||||
};
|
||||
let radiance = match case.endpoint {
|
||||
OpticalLineEndpoint::Emitter(_) if visible => [2.0, 3.0, 5.0],
|
||||
OpticalLineEndpoint::Surface(z) if visible => {
|
||||
// Exact normal-incidence Lambert substrate (IOR1 removes specular),
|
||||
// pi-radiance delta source, and its independent forward Beer segment.
|
||||
let shadow = (case.fog[1] - case.fog[0].max(z)).max(0.0);
|
||||
let attenuation = (-shadow / 128.0).exp();
|
||||
[0.5 * attenuation, 0.25 * attenuation, 0.125 * attenuation]
|
||||
}
|
||||
_ => [0.0; 3],
|
||||
};
|
||||
[radiance[0] * survival, radiance[1] * survival, radiance[2] * survival, if visible { 1.0 } else { f64::from(hits) / f64::from(samples) }]
|
||||
}
|
||||
fn optical_line_check(case: OpticalLineCase, samples: u32, seed: u32, pixels: &[[f32; 4]], errors: &mut Vec<String>) {
|
||||
assert_eq!(pixels.len(), 16);
|
||||
let mut failures = 0;
|
||||
for (pixel, actual) in pixels.iter().enumerate() {
|
||||
let expected = optical_line_expected(case, pixel as u32, samples, seed);
|
||||
let alpha = expected[3] as f32;
|
||||
if actual[3] != alpha || (0..3).any(|c| (f64::from(actual[c]) - expected[c]).abs() > 2e-6) {
|
||||
failures += 1;
|
||||
if errors.len() < 32 {
|
||||
errors.push(format!("{} prefix{samples} pixel{pixel}: {actual:?} vs {expected:?}", case.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"orthographic signed optical line case={} prefix={samples} length={} failures={failures} first={:?}",
|
||||
case.name, case.length, pixels[0]
|
||||
);
|
||||
}
|
||||
|
||||
fn optical_line_raw(bytes: &[u8], width: usize, height: usize) -> Vec<[f32; 4]> {
|
||||
// Independent uncompressed FLOAT OpenEXR wire reader. The numerical oracle
|
||||
// above never calls the production volume, sampler or image codec.
|
||||
let word = |at: usize| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap());
|
||||
assert_eq!((word(0), word(4)), (20_000_630, 2));
|
||||
let string = |at: &mut usize| {
|
||||
let start = *at;
|
||||
*at += bytes[start..].iter().position(|b| *b == 0).unwrap() + 1;
|
||||
&bytes[start..*at - 1]
|
||||
};
|
||||
let mut at = 8;
|
||||
let (mut channels, mut window, mut compression) = (false, false, false);
|
||||
while bytes[at] != 0 {
|
||||
let name = string(&mut at);
|
||||
let kind = string(&mut at);
|
||||
let len = word(at) as usize;
|
||||
at += 4;
|
||||
let data = &bytes[at..at + len];
|
||||
at += len;
|
||||
match name {
|
||||
b"channels" => {
|
||||
assert_eq!(kind, b"chlist");
|
||||
let mut c = 0;
|
||||
for name in [b"A", b"B", b"G", b"R"] {
|
||||
assert_eq!(&data[c..c + 2], &[name[0], 0]);
|
||||
c += 2;
|
||||
assert_eq!(u32::from_le_bytes(data[c..c + 4].try_into().unwrap()), 2);
|
||||
assert_eq!(&data[c + 4..c + 8], &[0; 4]);
|
||||
assert_eq!(u32::from_le_bytes(data[c + 8..c + 12].try_into().unwrap()), 1);
|
||||
assert_eq!(u32::from_le_bytes(data[c + 12..c + 16].try_into().unwrap()), 1);
|
||||
c += 16;
|
||||
}
|
||||
assert_eq!(&data[c..], &[0]);
|
||||
channels = true;
|
||||
}
|
||||
b"compression" => {
|
||||
assert_eq!(kind, b"compression");
|
||||
assert_eq!(data, &[0]);
|
||||
compression = true;
|
||||
}
|
||||
b"dataWindow" => {
|
||||
assert_eq!(kind, b"box2i");
|
||||
let bounds: Vec<_> = data.as_chunks::<4>().0.iter().map(|x| i32::from_le_bytes(*x)).collect();
|
||||
assert_eq!(bounds, [0, 0, width as i32 - 1, height as i32 - 1]);
|
||||
window = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(channels && window && compression);
|
||||
at += 1;
|
||||
let mut pixels = vec![[0.0; 4]; width * height];
|
||||
for y in 0..height {
|
||||
let offset = u64::from_le_bytes(bytes[at + y * 8..at + y * 8 + 8].try_into().unwrap()) as usize;
|
||||
assert_eq!((word(offset) as usize, word(offset + 4) as usize), (y, width * 16));
|
||||
for x in 0..width {
|
||||
for (channel, plane) in [(0, 3), (1, 2), (2, 1), (3, 0)] {
|
||||
pixels[y * width + x][channel] = f32::from_bits(word(offset + 8 + (plane * width + x) * 4));
|
||||
}
|
||||
}
|
||||
}
|
||||
pixels
|
||||
}
|
||||
fn optical_line_finish(task: &mut Task, units: usize) -> (Vec<u8>, Vec<u8>) {
|
||||
for _ in 0..20000 {
|
||||
if let TaskPoll::Complete(png) = task.step(units).unwrap() {
|
||||
let raw = task.take_linear_exr().unwrap();
|
||||
assert!(task.take_linear_exr().is_none());
|
||||
return (png, raw);
|
||||
}
|
||||
}
|
||||
panic!("bounded optical-line finishing did not complete")
|
||||
}
|
||||
fn optical_line_png_alpha(case: OpticalLineCase, samples: u32, seed: u32, png: &[u8], errors: &mut Vec<String>) {
|
||||
let image = image::load_from_memory(png).unwrap().to_rgba8();
|
||||
assert_eq!(image.dimensions(), (4, 4));
|
||||
for (pixel, actual) in image.pixels().enumerate() {
|
||||
let alpha = optical_line_expected(case, pixel as u32, samples, seed)[3];
|
||||
let expected = (alpha * 255.0).round() as u8;
|
||||
if actual[3] != expected && errors.len() < 32 {
|
||||
errors.push(format!("{} PNG prefix{samples} alpha pixel{pixel}: {} vs {expected}", case.name, actual[3]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_orthographic_volume_signed_line_absorption_matches_every_complete_prefix() {
|
||||
let Some(executor) = executor() else { return };
|
||||
let cases = [
|
||||
OpticalLineCase {
|
||||
name: "empty_origin_inside_slab",
|
||||
fog: [300.0, 500.0],
|
||||
target_z: 0.0,
|
||||
distance: 100.0,
|
||||
endpoint: OpticalLineEndpoint::None,
|
||||
length: 200.0,
|
||||
},
|
||||
OpticalLineCase {
|
||||
name: "empty_box_entirely_before_origin",
|
||||
fog: [450.0, 500.0],
|
||||
target_z: 0.0,
|
||||
distance: 100.0,
|
||||
endpoint: OpticalLineEndpoint::None,
|
||||
length: 50.0,
|
||||
},
|
||||
OpticalLineCase {
|
||||
name: "empty_box_after_origin",
|
||||
fog: [-500.0, -300.0],
|
||||
target_z: 0.0,
|
||||
distance: 100.0,
|
||||
endpoint: OpticalLineEndpoint::None,
|
||||
length: 200.0,
|
||||
},
|
||||
OpticalLineCase {
|
||||
name: "emitter_after_foreground_fog",
|
||||
fog: [300.0, 500.0],
|
||||
target_z: 0.0,
|
||||
distance: 100.0,
|
||||
endpoint: OpticalLineEndpoint::Emitter(0.0),
|
||||
length: 200.0,
|
||||
},
|
||||
OpticalLineCase {
|
||||
name: "emitter_terminates_inside_fog",
|
||||
fog: [300.0, 500.0],
|
||||
target_z: 0.0,
|
||||
distance: 100.0,
|
||||
endpoint: OpticalLineEndpoint::Emitter(350.0),
|
||||
length: 150.0,
|
||||
},
|
||||
OpticalLineCase {
|
||||
name: "opaque_terminates_inside_fog",
|
||||
fog: [300.0, 500.0],
|
||||
target_z: 0.0,
|
||||
distance: 100.0,
|
||||
endpoint: OpticalLineEndpoint::Surface(350.0),
|
||||
length: 150.0,
|
||||
},
|
||||
OpticalLineCase {
|
||||
name: "opaque_before_background_fog",
|
||||
fog: [-500.0, -300.0],
|
||||
target_z: 0.0,
|
||||
distance: 100.0,
|
||||
endpoint: OpticalLineEndpoint::Surface(0.0),
|
||||
length: 0.0,
|
||||
},
|
||||
OpticalLineCase {
|
||||
name: "negative_geometry_hit_stays_invisible",
|
||||
fog: [300.0, 500.0],
|
||||
target_z: 0.0,
|
||||
distance: 100.0,
|
||||
endpoint: OpticalLineEndpoint::Surface(550.0),
|
||||
length: 200.0,
|
||||
},
|
||||
];
|
||||
let mut errors = Vec::new();
|
||||
for case in cases {
|
||||
let (source, image, options) = optical_line_fixture(case);
|
||||
let original_camera = image.camera;
|
||||
let mut task = Task::new(executor.context.clone(), source, image, options, CancelToken::default()).unwrap();
|
||||
let packed = task.parameters(16).unwrap();
|
||||
assert_eq!(f32::from_le_bytes(packed[44..48].try_into().unwrap()), 0.0);
|
||||
assert_eq!(f64::from(f32::from_le_bytes(packed[40..44].try_into().unwrap())) + case.target_z, case.target_z + case.distance * 4.0);
|
||||
for samples in 1..=options.samples {
|
||||
task.complete_sample().unwrap();
|
||||
optical_line_check(case, samples, options.seed, &task.read_linear().unwrap(), &mut errors);
|
||||
}
|
||||
let linear = task.read_linear().unwrap();
|
||||
let (png, raw) = optical_line_finish(&mut task, 7);
|
||||
let raw = optical_line_raw(&raw, 4, 4);
|
||||
assert_eq!(raw, linear, "{} raw master preserves actual final film bits", case.name);
|
||||
optical_line_check(case, options.samples, options.seed, &raw, &mut errors);
|
||||
optical_line_png_alpha(case, options.samples, options.seed, &png, &mut errors);
|
||||
assert_eq!(task.request.camera, original_camera, "fog must not mutate the authored camera");
|
||||
}
|
||||
assert!(errors.is_empty(), "strict signed optical-line absorption failures: {errors:#?}");
|
||||
}
|
||||
|
||||
// Preserve the existing GPU/shared Display contract: preview PNGs encode the
|
||||
// exact stored f32 film coverage, then round8bit. Every film prefix is separately
|
||||
// checked against the independent rational count with exact f32 equality.
|
||||
fn optical_line_preview_png_alpha(case: OpticalLineCase, samples: u32, seed: u32, png: &[u8], errors: &mut Vec<String>) {
|
||||
let image = image::load_from_memory(png).unwrap().to_rgba8();
|
||||
assert_eq!(image.dimensions(), (4, 4));
|
||||
for (pixel, actual) in image.pixels().enumerate() {
|
||||
let alpha = optical_line_expected(case, pixel as u32, samples, seed)[3] as f32;
|
||||
let expected = (f64::from(alpha) * 255.0).round() as u8;
|
||||
if actual[3] != expected && errors.len() < 32 {
|
||||
errors.push(format!("{} PNG prefix{samples} alpha pixel{pixel}: {} vs {expected}", case.name, actual[3]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_orthographic_volume_axial_origin_changes_preserve_lines_framing_and_raw_prefixes() {
|
||||
let Some(executor) = executor() else { return };
|
||||
let mut baseline = None;
|
||||
let mut baseline_frame: Option<Vec<u8>> = None;
|
||||
let mut errors = Vec::new();
|
||||
for (target_z, distance) in [(-200.0, 100.0), (0.0, 100.0), (200.0, 100.0), (0.0, 50.0), (0.0, 150.0)] {
|
||||
let case = OpticalLineCase {
|
||||
name: "translated_or_rescaled_infinite_line",
|
||||
fog: [300.0, 500.0],
|
||||
target_z,
|
||||
distance,
|
||||
endpoint: OpticalLineEndpoint::Emitter(0.0),
|
||||
length: 200.0,
|
||||
};
|
||||
let (source, image, options) = optical_line_fixture(case);
|
||||
let camera = image.camera;
|
||||
let mut task = Task::new(executor.context.clone(), source, image, options, CancelToken::default()).unwrap();
|
||||
let packet = task.parameters(16).unwrap();
|
||||
assert_eq!(f32::from_le_bytes(packet[48..52].try_into().unwrap()), (distance * 0.4) as f32);
|
||||
if let Some(frame) = &baseline_frame {
|
||||
if distance == 100.0 {
|
||||
assert_eq!(&packet[48..96], frame.as_slice(), "axial target translation keeps exact right/up/back and orthographic framing");
|
||||
} else {
|
||||
assert_ne!(
|
||||
&packet[48..80],
|
||||
&frame[..32],
|
||||
"real distance change rescales the orthographic span; only uniform-coverage radiance is invariant"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
baseline_frame = Some(packet[48..96].to_vec());
|
||||
}
|
||||
let mut prefixes = Vec::new();
|
||||
for count in 1..=options.samples {
|
||||
task.complete_sample().unwrap();
|
||||
let pixels = task.read_linear().unwrap();
|
||||
optical_line_check(case, count, options.seed, &pixels, &mut errors);
|
||||
prefixes.push(pixels);
|
||||
}
|
||||
let (png, raw) = optical_line_finish(&mut task, 7);
|
||||
optical_line_check(case, options.samples, options.seed, &optical_line_raw(&raw, 4, 4), &mut errors);
|
||||
assert_eq!(task.request.camera, camera);
|
||||
let output = (prefixes, png, raw);
|
||||
if let Some(baseline) = &baseline {
|
||||
assert_eq!(&output, baseline, "only the arbitrary launch-plane location or homogeneous coverage scale changed");
|
||||
} else {
|
||||
baseline = Some(output);
|
||||
}
|
||||
}
|
||||
assert!(errors.is_empty(), "translated signed optical-line errors: {errors:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_orthographic_volume_every_preview_raw_and_cancel_are_quantum_safe() {
|
||||
let Some(executor) = executor() else { return };
|
||||
let case = OpticalLineCase {
|
||||
name: "quantum_complete_prefixes",
|
||||
fog: [300.0, 500.0],
|
||||
target_z: 0.0,
|
||||
distance: 100.0,
|
||||
endpoint: OpticalLineEndpoint::None,
|
||||
length: 200.0,
|
||||
};
|
||||
let run = |units| {
|
||||
let (source, image, options) = optical_line_fixture(case);
|
||||
let mut task = Task::new(executor.context.clone(), source, image, options, CancelToken::default()).unwrap();
|
||||
task.preview_enabled = true;
|
||||
let (mut prefixes, mut previews, mut uniforms, mut errors) = (Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
||||
for _ in 0..20000 {
|
||||
if task.sample < options.samples && uniforms.len() == task.sample as usize {
|
||||
uniforms.push(task.parameters(16).unwrap());
|
||||
}
|
||||
let before = task.sample;
|
||||
let poll = task.step(units).unwrap();
|
||||
if task.sample > before {
|
||||
assert_eq!(task.sample, before + 1);
|
||||
let pixels = task.read_linear().unwrap();
|
||||
optical_line_check(case, task.sample, options.seed, &pixels, &mut errors);
|
||||
prefixes.push(pixels);
|
||||
}
|
||||
if let Some(preview) = task.take_preview() {
|
||||
assert_eq!(preview.samples_per_pixel, previews.len() as u32 + 1);
|
||||
optical_line_preview_png_alpha(case, preview.samples_per_pixel, options.seed, &preview.png, &mut errors);
|
||||
previews.push(preview.png);
|
||||
}
|
||||
if let TaskPoll::Complete(png) = poll {
|
||||
let raw = task.take_linear_exr().unwrap();
|
||||
assert!(task.take_linear_exr().is_none());
|
||||
assert_eq!((prefixes.len(), previews.len(), uniforms.len()), (16, 15, 16));
|
||||
assert_eq!(optical_line_raw(&raw, 4, 4), *prefixes.last().unwrap());
|
||||
optical_line_png_alpha(case, 16, options.seed, &png, &mut errors);
|
||||
assert!(errors.is_empty(), "quantum{units}: {errors:#?}");
|
||||
return (prefixes, previews, uniforms, png, raw);
|
||||
}
|
||||
}
|
||||
panic!("bounded progressive volume output did not complete")
|
||||
};
|
||||
assert_eq!(run(1), run(16384), "all completed previews, full input packets, PNG/raw and film prefixes survive output quantum changes exactly");
|
||||
let (source, mut image, options) = optical_line_fixture(case);
|
||||
image.width = 16;
|
||||
image.height = 16;
|
||||
let token = CancelToken::default();
|
||||
let mut task = Task::new(executor.context, source, image, options, token.clone()).unwrap();
|
||||
task.preview_enabled = true;
|
||||
task.dispatch().unwrap();
|
||||
assert_eq!(task.sample, 0);
|
||||
assert!(task.offset > 0 && task.offset < 256, "cancel a real partial pass");
|
||||
token.cancel();
|
||||
assert_eq!(task.step(1).err().unwrap().code, ErrorCode::Cancelled);
|
||||
assert!(task.take_preview().is_none() && task.take_linear_exr().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_orthographic_volume_cancel_releases_work_before_exact_retained_metadata() {
|
||||
use solidcraft_render::job::{JobId, Phase};
|
||||
let Some(executor) = executor() else { return };
|
||||
let host = ImageHost::with_executor(Arc::new(executor)).unwrap();
|
||||
let case = OpticalLineCase {
|
||||
name: "real_host_cancel",
|
||||
fog: [300.0, 500.0],
|
||||
target_z: 0.0,
|
||||
distance: 100.0,
|
||||
endpoint: OpticalLineEndpoint::None,
|
||||
length: 200.0,
|
||||
};
|
||||
let (source, mut image, mut options) = optical_line_fixture(case);
|
||||
image.width = 128;
|
||||
image.height = 128;
|
||||
options.samples = 4096;
|
||||
let retained = options.retained_bytes().unwrap() + image.retained_resource_metadata_bytes();
|
||||
let admission = host.admit(&source, &image, options).unwrap();
|
||||
assert!(admission.estimated_total_bytes > retained);
|
||||
assert_eq!(host.admitted_bytes(), 0, "preflight leaves no reserved work");
|
||||
let handle = host.submit_with_options(JobId { session: 918, serial: 1, generation: 1 }, 1, source, image, options).unwrap();
|
||||
handle.cancel().unwrap();
|
||||
let status = handle.wait_timeout(std::time::Duration::from_secs(30)).unwrap();
|
||||
assert_eq!(status.progress.phase, Phase::Cancelled);
|
||||
let deadline = Instant::now() + std::time::Duration::from_secs(5);
|
||||
while !handle.work_released() {
|
||||
assert!(Instant::now() < deadline, "cancelled GPU volume work did not drain");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert!(handle.frame().unwrap().is_none() && handle.preview().unwrap().is_none());
|
||||
assert_eq!(host.admitted_bytes(), retained, "only actual options and fixed request headers remain owned by Record");
|
||||
drop(handle);
|
||||
while host.admitted_bytes() != 0 {
|
||||
assert!(Instant::now() < deadline, "last Record did not release retained metadata");
|
||||
std::thread::yield_now();
|
||||
}
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_volume_signed_primary_preserves_old_perspective_lens_none_and_zero_bytes() {
|
||||
use solidcraft_engine::doc::render_resources::HdrId;
|
||||
let Some(executor) = executor() else { return };
|
||||
let context = &executor.context;
|
||||
let old_hook = r#" let segment=volume_segment(o+o_low,d,min(hit.distance,emitter_distance));"#;
|
||||
let new_hook = r#" var segment=vec2<f32>(0.0);
|
||||
if bounce==0u && u.eye.w<=0.5 {
|
||||
// The orthographic camera has no physical eye plane. Its fog
|
||||
// line starts at the finite box entry, even before the artificial
|
||||
// geometry origin; the existing forward endpoint stays fixed.
|
||||
segment=volume_orthographic_segment(o+o_low,d,min(hit.distance,emitter_distance));
|
||||
} else {
|
||||
segment=volume_segment(o+o_low,d,min(hit.distance,emitter_distance));
|
||||
}"#;
|
||||
let helper = r#"// Only the primary orthographic optical line uses signed entry. Secondary,
|
||||
// shadow and perspective rays retain volume_segment's physical forward origin.
|
||||
// The caller supplies the original forward geometry/emitter endpoint unchanged.
|
||||
fn volume_orthographic_segment(o: vec3<f32>, d: vec3<f32>, limit: f32) -> vec2<f32> {
|
||||
var enter = -FAR;
|
||||
var leave = limit;
|
||||
for (var axis = 0u; axis < 3u; axis += 1u) {
|
||||
if d[axis] == 0.0 {
|
||||
if o[axis] < u.volume_min[axis] || o[axis] > u.volume_max[axis] { return vec2<f32>(0.0); }
|
||||
} else {
|
||||
let a = (u.volume_min[axis]-o[axis])/d[axis];
|
||||
let b = (u.volume_max[axis]-o[axis])/d[axis];
|
||||
enter=max(enter,min(a,b));
|
||||
leave=min(leave,max(a,b));
|
||||
}
|
||||
if enter >= leave { return vec2<f32>(0.0); }
|
||||
}
|
||||
return vec2<f32>(enter,leave);
|
||||
}
|
||||
"#;
|
||||
assert_eq!(SHADER.matches(new_hook).count(), 1);
|
||||
assert_eq!(SHADER.matches(helper).count(), 1);
|
||||
let helper_block = format!("\n{helper}");
|
||||
let helper_offset = SHADER.find(&helper_block).unwrap();
|
||||
let original = SHADER.replacen(new_hook, old_hook, 1).replacen(&helper_block, "", 1);
|
||||
assert_eq!(original.matches(old_hook).count(), 1);
|
||||
let mut restored = original.replacen(old_hook, new_hook, 1);
|
||||
restored.insert_str(helper_offset, &helper_block);
|
||||
assert_eq!(restored, SHADER, "the whole-source inverse restores only the signed primary hook/helper");
|
||||
// Historical a4f69bd8 is the archived pre-GPU12 source. This current control
|
||||
// is independently reconstructed from 96190d, the 35af rename and the early wrapper.
|
||||
assert_eq!(
|
||||
HdrId::of(original.as_bytes()).to_string(),
|
||||
"f373400816e40e803691ceb847d8c9d7983fd4744c3874a322363810c14b6d6c",
|
||||
"exact composed pre-fog shader independently reconstructed; no unrelated producer math edited"
|
||||
);
|
||||
let creating = scopes(&context.device);
|
||||
let module = context.device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("exact old signed-line control"),
|
||||
source: wgpu::ShaderSource::Wgsl(original.into()),
|
||||
});
|
||||
let layout = context.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: None,
|
||||
bind_group_layouts: &[Some(&context.layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = context.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: Some(&layout),
|
||||
module: &module,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
checked(creating).unwrap();
|
||||
warm_up(&context.device, &context.queue, &pipeline, &placeholder_bind(&context.device, &context.layout, 7).unwrap(), &context.healthy).unwrap();
|
||||
let mut inactive_baseline = None;
|
||||
for mode in 0..8 {
|
||||
let case = OpticalLineCase {
|
||||
name: "unchanged_routes",
|
||||
fog: [300.0, 500.0],
|
||||
target_z: 0.0,
|
||||
distance: 100.0,
|
||||
endpoint: OpticalLineEndpoint::Emitter(0.0),
|
||||
length: 0.0,
|
||||
};
|
||||
let (source, mut image, mut options) = optical_line_fixture(case);
|
||||
match mode {
|
||||
0 => options.volume = None,
|
||||
1 => options.volume.as_mut().unwrap().extinction_per_mm = 0.0,
|
||||
2 => image.camera.fov = 0.4,
|
||||
3 => {
|
||||
image.camera.fov = 0.4;
|
||||
options.lens = Some(solidcraft_render::job::ThinLens { aperture_radius_mm: 2.0, focus_distance_mm: 100.0 });
|
||||
}
|
||||
4 => {
|
||||
let volume = options.volume.as_mut().unwrap();
|
||||
volume.min_mm[2] = 100.0;
|
||||
volume.max_mm[2] = 300.0;
|
||||
}
|
||||
5 => {
|
||||
let volume = options.volume.as_mut().unwrap();
|
||||
volume.min_mm[0] = 1000.0;
|
||||
volume.max_mm[0] = 2000.0;
|
||||
}
|
||||
6 => {
|
||||
image.camera.fov = 0.4;
|
||||
let volume = options.volume.as_mut().unwrap();
|
||||
volume.min_mm[2] = 0.0;
|
||||
volume.max_mm[2] = 200.0;
|
||||
}
|
||||
7 => {
|
||||
image.camera.fov = 0.4;
|
||||
options.lens = Some(solidcraft_render::job::ThinLens { aperture_radius_mm: 2.0, focus_distance_mm: 100.0 });
|
||||
let volume = options.volume.as_mut().unwrap();
|
||||
volume.min_mm[2] = 0.0;
|
||||
volume.max_mm[2] = 200.0;
|
||||
}
|
||||
_ => panic!("unexpected unchanged-volume fixture mode"),
|
||||
}
|
||||
let run = |old: bool| {
|
||||
let mut task = Task::new(context.clone(), source.clone(), image.clone(), options, CancelToken::default()).unwrap();
|
||||
let (mut uniforms, mut prefixes) = (Vec::new(), Vec::new());
|
||||
for sample in 0..options.samples {
|
||||
assert_eq!((task.sample, task.offset, task.lane), (sample, 0, 0));
|
||||
let params = task.parameters(16).unwrap();
|
||||
uniforms.push(params.clone());
|
||||
if old {
|
||||
let checking = scopes(&context.device);
|
||||
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(16u32.div_ceil(PATH_WORKGROUP_SIZE), 1, 1);
|
||||
}
|
||||
let submission = context.queue.submit([encoder.finish()]);
|
||||
checked(checking).unwrap();
|
||||
context.wait(submission, TIMEOUT).unwrap();
|
||||
task.sample += 1;
|
||||
} else {
|
||||
task.complete_sample().unwrap();
|
||||
}
|
||||
prefixes.push(task.read_linear().unwrap());
|
||||
}
|
||||
let (png, raw) = optical_line_finish(&mut task, 7);
|
||||
assert_eq!(optical_line_raw(&raw, 4, 4), *prefixes.last().unwrap());
|
||||
(uniforms, prefixes, png, raw)
|
||||
};
|
||||
let old = run(true);
|
||||
let new = run(false);
|
||||
assert_eq!(old, new, "mode{mode}: complete input packets, each film prefix and PNG/raw must remain byte-identical");
|
||||
if mode == 0 {
|
||||
inactive_baseline = Some(new);
|
||||
} else if mode == 1 {
|
||||
assert_eq!(inactive_baseline.as_ref().unwrap(), &new, "Some(0) preserves full None uniforms, stream and outputs exactly");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+133
-26
@@ -24,6 +24,7 @@ pub use clouds::{CloudFilter, CloudStyle, ColorMode, DOT_SIZE, Dot, FILTERS, Gpu
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod msaa_check;
|
||||
mod oit;
|
||||
mod product_highlights;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use msaa_check::MsaaCheck;
|
||||
|
||||
@@ -2100,6 +2101,10 @@ struct Cut {
|
||||
|
||||
/// The viewport's pipelines and the scenes uploaded to one device, for one kind of target.
|
||||
pub struct Renderer {
|
||||
/// Lazily created depth-only pipelines for feedback over a live product image.
|
||||
product_depth: Option<product_highlights::DepthPipelines>,
|
||||
/// The offscreen cache contains transparent product highlights rather than shaded colour.
|
||||
product_highlights: bool,
|
||||
/// What the last frame drew ([`Renderer::stats`]).
|
||||
stats: DrawStatsHandle,
|
||||
/// What the frame `prepare` drew offscreen (supersampled, or for see-through surfaces),
|
||||
@@ -3055,6 +3060,8 @@ impl Renderer {
|
||||
let (depth_reset, section_stencil) = resets.join();
|
||||
let oit = oit.join();
|
||||
Renderer {
|
||||
product_depth: None,
|
||||
product_highlights: false,
|
||||
ao: ao.join(),
|
||||
ground_ao: ground_ao.join(),
|
||||
ground_ao_key: None,
|
||||
@@ -3480,6 +3487,11 @@ impl Renderer {
|
||||
pub fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, frame: &Frame) {
|
||||
let res = self;
|
||||
let cb = frame;
|
||||
if res.product_highlights {
|
||||
res.product_highlights = false;
|
||||
res.ss_key = None;
|
||||
res.cached = None;
|
||||
}
|
||||
let key = frame_key(cb);
|
||||
if can_reuse(res.ss_ready && res.ss.is_some(), res.ss_key, key) {
|
||||
// A still frame shows the picture as it is; the first one filters it down once.
|
||||
@@ -3493,28 +3505,8 @@ impl Renderer {
|
||||
}
|
||||
res.cached = None;
|
||||
if cb.product {
|
||||
// A large product scene can exceed what the device will allocate. Its upload runs under
|
||||
// its own error scopes, so the failure reaches the preview instead of the device's
|
||||
// uncaptured-error handler, and nothing half-uploaded is drawn.
|
||||
let scopes =
|
||||
[wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f));
|
||||
res.product.take(device, queue, &res.uniform, &res.uniform_bgl, cb.key, &cb.slot, None, false);
|
||||
let mut failed = None;
|
||||
for scope in scopes.into_iter().rev() {
|
||||
if let Some(e) = crate::popped(scope.pop()) {
|
||||
failed.get_or_insert(e.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(error) = failed {
|
||||
log::error!("product preview upload failed: {error}");
|
||||
res.product = Batches { key: Some(cb.key), ..Batches::default() };
|
||||
if let Some(Ok(mut sink)) = cb.upload_error.as_ref().map(|e| e.lock()) {
|
||||
*sink = Some(error);
|
||||
}
|
||||
}
|
||||
res.prepare_product_geometry(device, queue, cb);
|
||||
res.product.sort_glass(queue, cb.back);
|
||||
res.highlight.take(device, queue, &res.uniform, &res.uniform_bgl, cb.hl_key, &cb.hl_slot, None, false);
|
||||
queue.write_buffer(&res.uniform, 0, &uniform_bytes(cb, cb.size_px[0], cb.size_px[1], res.linear_out, res.target.reversed_depth()));
|
||||
// The product picture goes through the window's pass too: offscreen when the targets
|
||||
// differ or it is drawn smaller, else straight into the window's pass.
|
||||
res.ss_ready = (res.window_differs() || cb.ssaa != 1.0) && res.supersample(device, encoder, cb, false);
|
||||
@@ -4093,7 +4085,7 @@ impl Renderer {
|
||||
};
|
||||
// See-through surfaces (order-independent transparency) and a window pass of another kind
|
||||
// (samples, depth) need a frame of their own, even at the viewport's own size.
|
||||
let needs_own = see_through || self.window_differs();
|
||||
let needs_own = see_through || self.window_differs() || self.product_highlights;
|
||||
let samples = self.target.samples;
|
||||
let mut drawn = None;
|
||||
for _ in 0..FRAME_TRIES {
|
||||
@@ -4190,7 +4182,11 @@ impl Renderer {
|
||||
let (Some(p), Some(t), true) = (&self.oit, &self.oit_targets, see_through) else {
|
||||
let mut first = pass(encoder, true);
|
||||
first.set_viewport(0.0, 0.0, size[0] as f32, size[1] as f32, 0.0, 1.0);
|
||||
self.draw(cb, &mut first, Phase::All, &tally);
|
||||
if self.product_highlights {
|
||||
self.draw_product_highlights(&mut first, &tally);
|
||||
} else {
|
||||
self.draw(cb, &mut first, Phase::All, &tally);
|
||||
}
|
||||
drop(first);
|
||||
self.ss_stats = tally.stats();
|
||||
return true;
|
||||
@@ -5140,6 +5136,18 @@ mod glass_tests {
|
||||
|
||||
/// Render `frame` through `r` into a fresh 64 × 64 target and read the pixels back.
|
||||
fn render_frame(r: &mut Renderer, device: &wgpu::Device, queue: &wgpu::Queue, frame: &Frame, with_depth: bool) -> Vec<u8> {
|
||||
render_frame_kind(r, device, queue, frame, with_depth, false, wgpu::Color::WHITE)
|
||||
}
|
||||
|
||||
fn render_frame_kind(
|
||||
r: &mut Renderer,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
frame: &Frame,
|
||||
with_depth: bool,
|
||||
highlights: bool,
|
||||
background: wgpu::Color,
|
||||
) -> Vec<u8> {
|
||||
const SIZE: u32 = 64;
|
||||
let depth = crate::depth_format(device, true);
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
@@ -5160,7 +5168,11 @@ mod glass_tests {
|
||||
let depth_tex = with_depth.then(|| texture(depth, wgpu::TextureUsages::RENDER_ATTACHMENT));
|
||||
let depth_view = depth_tex.as_ref().map(|t| t.create_view(&Default::default()));
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
r.prepare(device, queue, &mut encoder, frame);
|
||||
if highlights {
|
||||
r.prepare_product_highlights(device, queue, &mut encoder, frame);
|
||||
} else {
|
||||
r.prepare(device, queue, &mut encoder, frame);
|
||||
}
|
||||
{
|
||||
let mut pass = encoder
|
||||
.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
@@ -5169,7 +5181,7 @@ mod glass_tests {
|
||||
view: &color_view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::WHITE), store: wgpu::StoreOp::Store },
|
||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(background), store: wgpu::StoreOp::Store },
|
||||
})],
|
||||
depth_stencil_attachment: depth_view.as_ref().map(|view| wgpu::RenderPassDepthStencilAttachment {
|
||||
view,
|
||||
@@ -5186,7 +5198,11 @@ mod glass_tests {
|
||||
multiview_mask: None,
|
||||
})
|
||||
.forget_lifetime();
|
||||
r.paint(frame, &mut pass, [0.0, 0.0, SIZE as f32, SIZE as f32]);
|
||||
if highlights {
|
||||
r.paint_product_highlights(frame, &mut pass, [0.0, 0.0, SIZE as f32, SIZE as f32]);
|
||||
} else {
|
||||
r.paint(frame, &mut pass, [0.0, 0.0, SIZE as f32, SIZE as f32]);
|
||||
}
|
||||
}
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
@@ -5258,6 +5274,97 @@ mod glass_tests {
|
||||
scene
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn product_highlights_keep_live_colour_and_occlude_faces_with_both_product_vertex_layouts_and_msaa() {
|
||||
let (_, device, queue) = match crate::open_headless() {
|
||||
Ok(opened) => opened,
|
||||
Err(error) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
|
||||
eprintln!("skipping: {error}");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("required GPU unavailable: {error}"),
|
||||
};
|
||||
let format = wgpu::TextureFormat::Rgba8Unorm;
|
||||
let background = wgpu::Color { r: 17.0 / 255.0, g: 33.0 / 255.0, b: 65.0 / 255.0, a: 1.0 };
|
||||
let surface = solidcraft_render::surface::OpaqueSurface::new(Vec3::new(0.8, 0.1, 0.1), 0.0, 0.6, 1.5).unwrap();
|
||||
for depth in [wgpu::TextureFormat::Depth24PlusStencil8, crate::depth_format(&device, true)] {
|
||||
for samples in [1, 4] {
|
||||
let target = GpuTarget { format, depth: Some(depth), samples, tess: false, oit: false };
|
||||
let window = GpuTarget { format, depth: None, samples: 1, tess: false, oit: false };
|
||||
let mut renderer = Renderer::with_window_target(&device, target, window, None);
|
||||
// Preserve the original built/PBR cases, and exercise the new
|
||||
// upstream open-body batch with the same independent pixels.
|
||||
for layout in 0_u64..3 {
|
||||
let pbr = layout == 1;
|
||||
let open = layout == 2;
|
||||
let mut scene = GpuScene::default();
|
||||
let red = scene.color([255, 0, 0, 255]);
|
||||
for p in [[-10.0, -10.0, 0.0], [10.0, -10.0, 0.0], [10.0, 10.0, 0.0], [-10.0, -10.0, 0.0], [10.0, 10.0, 0.0], [-10.0, 10.0, 0.0]]
|
||||
{
|
||||
if pbr {
|
||||
scene.pbr_tri(p, [0.0, 0.0, 1.0], surface);
|
||||
} else if open {
|
||||
scene.open_tri(p, [0.0; 3], red);
|
||||
} else {
|
||||
scene.tri(p, [0.0; 3], red);
|
||||
}
|
||||
}
|
||||
let mut highlight = GpuScene::default();
|
||||
let green = highlight.color([0, 255, 0, 128]);
|
||||
let red = highlight.color([255, 0, 0, 255]);
|
||||
for p in [[-8.0, -4.0, 1.0], [-1.0, -4.0, 1.0], [-4.5, 4.0, 1.0]] {
|
||||
if open {
|
||||
highlight.open_tri(p, [0.0; 3], green);
|
||||
} else {
|
||||
highlight.tri(p, [0.0; 3], green);
|
||||
}
|
||||
}
|
||||
for p in [[1.0, -4.0, -1.0], [8.0, -4.0, -1.0], [4.5, 4.0, -1.0]] {
|
||||
if open {
|
||||
highlight.open_tri(p, [0.0; 3], red);
|
||||
} else {
|
||||
highlight.tri(p, [0.0; 3], red);
|
||||
}
|
||||
}
|
||||
let mut frame = test_frame(scene, 1.0);
|
||||
frame.product = true;
|
||||
frame.key = 1 + layout;
|
||||
frame.hl_key = 1 + layout;
|
||||
frame.hl_slot = Arc::new(Mutex::new(Some(highlight)));
|
||||
let normal = render_frame_kind(&mut renderer, &device, &queue, &frame, false, false, background);
|
||||
let overlay = render_frame_kind(&mut renderer, &device, &queue, &frame, false, true, background);
|
||||
let pixel =
|
||||
|bytes: &[u8], x: usize, y: usize| -> [u8; 4] { bytes[((y * 64 + x) * 4)..((y * 64 + x) * 4 + 4)].try_into().unwrap() };
|
||||
let unchanged = [17, 33, 65, 255];
|
||||
assert_eq!(
|
||||
pixel(&overlay, 32, 32),
|
||||
unchanged,
|
||||
"depth-only product colour must not replace live pixels: {depth:?}, {samples}x, layout={layout}"
|
||||
);
|
||||
assert_eq!(
|
||||
pixel(&overlay, 46, 32),
|
||||
unchanged,
|
||||
"the rear target is hidden by actual product depth: {depth:?}, {samples}x, layout={layout}"
|
||||
);
|
||||
assert_eq!(pixel(&overlay, 3, 3), unchanged, "unselected product pixels remain exactly the live image colour");
|
||||
let selected = pixel(&overlay, 18, 32);
|
||||
assert!(
|
||||
selected[1] > 120 && selected[0] < 17 && selected[2] < 65,
|
||||
"the visible target is transparently composited: {selected:?}"
|
||||
);
|
||||
let again = render_frame_kind(&mut renderer, &device, &queue, &frame, false, true, background);
|
||||
assert_eq!(overlay, again, "a cached overlay preserves its transparency");
|
||||
assert_eq!(
|
||||
normal,
|
||||
render_frame_kind(&mut renderer, &device, &queue, &frame, false, false, background),
|
||||
"returning to ordinary product rendering cannot reuse the transparent overlay"
|
||||
);
|
||||
assert_ne!(normal, overlay);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cached_cloud_picture_follows_its_style_without_uploading_again() {
|
||||
let Some((_, device, queue)) = crate::open_headless().ok() else { return };
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Depth-correct selection feedback over independently rendered product pixels.
|
||||
//! The offscreen target contains only highlights; product colour is never drawn into it.
|
||||
use super::*;
|
||||
|
||||
const DEPTH_SHADER: &str = r#"
|
||||
struct U { vp: mat4x4<f32> };
|
||||
@group(0) @binding(0) var<uniform> u: U;
|
||||
@vertex
|
||||
fn vs(@location(0) p: vec3<f32>) -> @builtin(position) vec4<f32> {
|
||||
return u.vp * vec4<f32>(p, 1.0);
|
||||
}
|
||||
@vertex
|
||||
fn vs_inst(@location(0) p: vec3<f32>, @location(3) r0: vec4<f32>, @location(4) r1: vec4<f32>, @location(5) r2: vec4<f32>) -> @builtin(position) vec4<f32> {
|
||||
let q = vec4<f32>(p, 1.0);
|
||||
return u.vp * vec4<f32>(dot(r0,q), dot(r1,q), dot(r2,q), 1.0);
|
||||
}
|
||||
@fragment
|
||||
fn fs() -> @location(0) vec4<f32> { return vec4<f32>(0.0); }
|
||||
"#;
|
||||
|
||||
pub(super) struct DepthPipelines {
|
||||
built: wgpu::RenderPipeline,
|
||||
pbr: wgpu::RenderPipeline,
|
||||
planned: wgpu::RenderPipeline,
|
||||
instanced: wgpu::RenderPipeline,
|
||||
}
|
||||
impl DepthPipelines {
|
||||
fn new(device: &wgpu::Device, uniform: &wgpu::BindGroupLayout, target: GpuTarget) -> Self {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("sc_product_highlight_depth"),
|
||||
source: wgpu::ShaderSource::Wgsl(DEPTH_SHADER.into()),
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("sc_product_highlight_depth"),
|
||||
bind_group_layouts: &[Some(uniform)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = |buffers: &[Option<wgpu::VertexBufferLayout<'_>>], instanced: bool| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("sc_product_highlight_depth"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(if instanced { "vs_inst" } else { "vs" }),
|
||||
buffers,
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: target.depth.map(|format| wgpu::DepthStencilState {
|
||||
format,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(depth_compare(target.reversed_depth(), wgpu::CompareFunction::Less)),
|
||||
stencil: Default::default(),
|
||||
bias: depth_bias(target.reversed_depth(), FACE_BIAS),
|
||||
}),
|
||||
multisample: wgpu::MultisampleState { count: target.samples.max(1), mask: !0, alpha_to_coverage_enabled: false },
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs"),
|
||||
targets: &[Some(wgpu::ColorTargetState { format: target.format, blend: None, write_mask: wgpu::ColorWrites::empty() })],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
Self {
|
||||
built: pipeline(&[Some(tri_layout())], false),
|
||||
pbr: pipeline(&[Some(pbr_layout())], false),
|
||||
planned: pipeline(&[Some(planned_layout()), Some(color_layout())], false),
|
||||
instanced: pipeline(&[Some(shared_layout()), Some(instance_layout())], true),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Shared product upload and camera setup for ordinary colour and transparent feedback.
|
||||
pub(super) fn prepare_product_geometry(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, frame: &Frame) -> bool {
|
||||
let scopes = [wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation]
|
||||
.map(|filter| device.push_error_scope(filter));
|
||||
self.product.take(device, queue, &self.uniform, &self.uniform_bgl, frame.key, &frame.slot, None, false);
|
||||
let mut failed = None;
|
||||
for scope in scopes.into_iter().rev() {
|
||||
if let Some(error) = crate::popped(scope.pop()) {
|
||||
failed.get_or_insert(error.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(error) = failed {
|
||||
log::error!("product preview upload failed: {error}");
|
||||
self.product = Batches { key: Some(frame.key), ..Batches::default() };
|
||||
if let Some(Ok(mut sink)) = frame.upload_error.as_ref().map(|sink| sink.lock()) {
|
||||
*sink = Some(error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
self.highlight.take(device, queue, &self.uniform, &self.uniform_bgl, frame.hl_key, &frame.hl_slot, None, false);
|
||||
queue.write_buffer(
|
||||
&self.uniform,
|
||||
0,
|
||||
&uniform_bytes(frame, frame.size_px[0], frame.size_px[1], self.linear_out, self.target.reversed_depth()),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Prepare a transparent product selection/hover image with its own depth and MSAA.
|
||||
/// Emit this instead of the ordinary product callback when live pixels are displayed.
|
||||
pub fn prepare_product_highlights(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, frame: &Frame) {
|
||||
let key = frame_key(frame);
|
||||
if self.product_highlights && can_reuse(self.ss_ready && self.ss.is_some(), self.ss_key, key) {
|
||||
let filter = self.ss_scaled && self.cached.is_none();
|
||||
if filter {
|
||||
self.cache_picture(device, encoder, frame.size_px);
|
||||
}
|
||||
self.ss_stats = DrawStats { draw_calls: u32::from(filter), ..DrawStats::default() };
|
||||
return;
|
||||
}
|
||||
self.product_highlights = true;
|
||||
self.cached = None;
|
||||
self.ss_ready = false;
|
||||
self.ss_key = None;
|
||||
// Feedback without product depth would light occluded faces as if visible.
|
||||
if !frame.product || self.target.depth.is_none() {
|
||||
return;
|
||||
}
|
||||
if self.product_depth.is_none() {
|
||||
self.product_depth = Some(DepthPipelines::new(device, &self.uniform_bgl, self.target));
|
||||
}
|
||||
if !self.prepare_product_geometry(device, queue, frame) {
|
||||
return;
|
||||
}
|
||||
self.ss_ready = self.supersample(device, encoder, frame, false);
|
||||
self.ss_key = self.ss_ready.then_some(key);
|
||||
}
|
||||
|
||||
/// Alpha-blit only the prepared feedback. There is no shaded-model fallback.
|
||||
pub fn paint_product_highlights(&self, frame: &Frame, pass: &mut wgpu::RenderPass<'static>, viewport: [f32; 4]) {
|
||||
if self.product_highlights && self.ss_ready {
|
||||
self.paint(frame, pass, viewport);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn draw_product_highlights(&self, pass: &mut wgpu::RenderPass<'static>, tally: &Tally) {
|
||||
let Some(depth) = &self.product_depth else { return };
|
||||
let triangles = |pass: &mut wgpu::RenderPass<'static>, pipeline: &wgpu::RenderPipeline, batch: &Option<Batch>| {
|
||||
if let Some(batch) = batch {
|
||||
pass.set_pipeline(pipeline);
|
||||
for (buffer, count) in &batch.parts {
|
||||
pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
pass.draw(0..*count, 0..1);
|
||||
tally.triangles(*count, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
let lines = |pass: &mut wgpu::RenderPass<'static>, pipeline: &wgpu::RenderPipeline, batch: &Option<Batch>| {
|
||||
if let Some(batch) = batch {
|
||||
pass.set_pipeline(pipeline);
|
||||
for (buffer, count) in &batch.parts {
|
||||
pass.set_vertex_buffer(0, buffer.slice(..));
|
||||
pass.draw(0..6, 0..*count);
|
||||
tally.segments(*count);
|
||||
}
|
||||
}
|
||||
};
|
||||
let model = &self.product;
|
||||
pass.set_bind_group(0, scene_bind(model, &self.bind), &[]);
|
||||
triangles(pass, &depth.built, &model.tris);
|
||||
// Open bodies still occlude feedback; their new upstream batch only
|
||||
// changes section crossing counts, which product feedback does not use.
|
||||
triangles(pass, &depth.built, &model.open_tris);
|
||||
triangles(pass, &depth.pbr, &model.pbr);
|
||||
for key in &model.block_order {
|
||||
if let Some(block) = model.blocks.get(key).filter(|block| block.class(TriClass::Opaque).is_some()) {
|
||||
pass.set_pipeline(&depth.built);
|
||||
block.draw(pass, TriClass::Opaque, tally);
|
||||
}
|
||||
}
|
||||
for draw in model.planned.iter().filter(|draw| draw.class == TriClass::Opaque) {
|
||||
pass.set_pipeline(&depth.planned);
|
||||
pass.set_vertex_buffer(0, draw.mesh.vertices.slice(..));
|
||||
pass.set_vertex_buffer(1, draw.colors.slice(..));
|
||||
pass.set_index_buffer(draw.mesh.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..draw.mesh.index_count, 0, 0..1);
|
||||
tally.triangles(draw.mesh.index_count, 1);
|
||||
}
|
||||
for draw in model.instanced.iter().filter(|draw| draw.look == InstanceLook::Opaque) {
|
||||
draw.draw(pass, &depth.instanced, &depth.instanced, tally);
|
||||
}
|
||||
// Render's build_highlight emits self-contained mesh triangles/edges, including
|
||||
// whole bodies; it intentionally does not reference the Design model's GPU plans.
|
||||
let highlight = &self.highlight;
|
||||
pass.set_bind_group(0, scene_bind(highlight, &self.bind), &[]);
|
||||
for key in &highlight.block_order {
|
||||
if let Some(block) = highlight.blocks.get(key).filter(|block| block.class(TriClass::Opaque).is_some()) {
|
||||
pass.set_pipeline(&self.tri_hl);
|
||||
block.draw(pass, TriClass::Opaque, tally);
|
||||
}
|
||||
}
|
||||
triangles(pass, &self.tri_hl, &highlight.tris);
|
||||
triangles(pass, &self.tri_hl, &highlight.open_tris);
|
||||
lines(pass, &self.line_hidden, &highlight.hidden);
|
||||
lines(pass, &self.line, &highlight.lines);
|
||||
lines(pass, &self.guide_line, &highlight.guide_lines);
|
||||
triangles(pass, &self.trans, &highlight.trans);
|
||||
triangles(pass, &self.guide_trans, &highlight.guide_trans);
|
||||
triangles(pass, &self.xray, &highlight.xray);
|
||||
lines(pass, &self.overlay, &highlight.overlays);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn product_depth_shader_uses_only_the_shared_camera_and_validates() {
|
||||
let module = naga::front::wgsl::parse_str(super::DEPTH_SHADER).unwrap();
|
||||
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap();
|
||||
assert_eq!(module.entry_points.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -217,6 +217,18 @@ pub fn design_folder_refusal_in(dir: &std::path::Path, home: Option<&std::path::
|
||||
}
|
||||
|
||||
impl PathScope {
|
||||
/// Actual owned path buffer capacities only. The enclosing owner accounts
|
||||
/// its PathScope/Vec headers once with size_of, without revealing paths.
|
||||
pub fn retained_heap_bytes(&self) -> Result<u64> {
|
||||
let fail = || Error::new(ErrorKind::InvalidData, "path scope byte overflow");
|
||||
let slots = self.roots.capacity().checked_add(self.design_roots.capacity()).ok_or_else(fail)?;
|
||||
let mut bytes = slots.checked_mul(std::mem::size_of::<std::path::PathBuf>()).ok_or_else(fail)?;
|
||||
for path in self.roots.iter().chain(&self.design_roots) {
|
||||
bytes = bytes.checked_add(path.capacity()).ok_or_else(fail)?;
|
||||
}
|
||||
u64::try_from(bytes).map_err(|_| fail())
|
||||
}
|
||||
|
||||
/// Confine file access to `roots` (resolved now; folders that don't exist are left out, so an
|
||||
/// empty scope refuses every path).
|
||||
pub fn new(roots: impl IntoIterator<Item = std::path::PathBuf>) -> PathScope {
|
||||
@@ -1183,4 +1195,25 @@ mod tests {
|
||||
let dir = std::env::temp_dir().to_string_lossy().to_string();
|
||||
assert!(super::read(&dir, 1024).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn retained_scope_heap_counts_actual_path_and_vector_capacities_without_exposing_or_changing_roots() {
|
||||
let mut root = std::path::PathBuf::new();
|
||||
root.reserve(512);
|
||||
root.push("/owned/root");
|
||||
let mut design = std::path::PathBuf::new();
|
||||
design.reserve(256);
|
||||
design.push("/owned/design");
|
||||
let mut roots = Vec::with_capacity(4);
|
||||
roots.push(root);
|
||||
let mut design_roots = Vec::with_capacity(3);
|
||||
design_roots.push(design);
|
||||
let expected = ((roots.capacity() + design_roots.capacity()) * std::mem::size_of::<std::path::PathBuf>()
|
||||
+ roots[0].capacity()
|
||||
+ design_roots[0].capacity()) as u64;
|
||||
let scope = super::PathScope { roots, design_roots };
|
||||
let before = scope.clone();
|
||||
assert_eq!(scope.retained_heap_bytes().unwrap(), expected);
|
||||
assert_eq!(scope, before);
|
||||
assert_eq!(super::PathScope::default().retained_heap_bytes().unwrap(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,21 +10,23 @@ workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
raytrace-cpu = []
|
||||
raytrace-cpu = ["dep:robust"]
|
||||
|
||||
[dependencies]
|
||||
solidcore-record = { workspace = true }
|
||||
solidcraft-geom = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
image = { workspace = true, features = ["jpeg"] }
|
||||
moxcms = { workspace = true }
|
||||
png = { workspace = true }
|
||||
robust = { workspace = true, optional = true }
|
||||
sha2 = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
weezl = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
flate2 = "1"
|
||||
|
||||
[[example]]
|
||||
name = "cpu_resource_smoke"
|
||||
|
||||
@@ -111,7 +111,8 @@ impl VisualFrame {
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded opaque response with two perceptual roughness axes in [0.045, 1].
|
||||
/// An opaque response whose public constructor admits two roughness axes in [0.045, 1].
|
||||
/// The crate-private isotropic optical adapter also retains validated aging finishes down to 0.02.
|
||||
/// Alpha is roughness squared, matching [`OpaqueSurface`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AnisotropicSurface {
|
||||
@@ -143,6 +144,14 @@ impl AnisotropicSurface {
|
||||
Ok(Self { base, roughness_y })
|
||||
}
|
||||
|
||||
/// Reuse one validated opaque optical finish for an isotropic VNDF. The
|
||||
/// authored runtime finish may have roughness 0.02; independently authored
|
||||
/// anisotropic axes continue through new() with their unchanged 0.045 bound.
|
||||
#[cfg(any(feature = "raytrace-cpu", test))]
|
||||
pub(crate) fn from_isotropic_optical(base: OpaqueSurface) -> Self {
|
||||
Self { base, roughness_y: base.roughness() }
|
||||
}
|
||||
|
||||
/// Admitted perceptual roughness along visual X and Y.
|
||||
pub fn roughness(self) -> [f64; 2] {
|
||||
[self.base.roughness(), self.roughness_y]
|
||||
|
||||
@@ -275,3 +275,23 @@ fn broad_lobe_importance_estimate_agrees_with_integrated_energy() {
|
||||
assert!(sampled.x > 0.0 && sampled.x <= 1.001);
|
||||
close_rgb(integrated, sampled, 0.003);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_isotropic_optical_finish_keeps_point_zero_two_without_broadening_public_axes() {
|
||||
let old = OpaqueSurface::new(Vec3::new(0.8, 0.5, 0.2), 1.0, MIN_ROUGHNESS, 1.5).unwrap();
|
||||
let finish = old.with_authored_finish(old.base_color(), 1.0, 0.02).unwrap();
|
||||
assert_eq!(finish.roughness(), 0.02);
|
||||
assert!(OpaqueSurface::new(old.base_color(), 1.0, 0.02, 1.5).is_err());
|
||||
assert!(AnisotropicSurface::new(old.base_color(), 1.0, 0.02, 0.02, 1.5).is_err());
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, 0.02f64.next_down(), 1.0f64.next_up()] {
|
||||
assert!(old.with_authored_finish(old.base_color(), 1.0, bad).is_err());
|
||||
}
|
||||
assert!(old.with_authored_finish(Vec3::new(f64::NAN, 0.0, 0.0), 1.0, 0.02).is_err());
|
||||
assert!(old.with_authored_finish(Vec3::X, -0.01, 0.02).is_err());
|
||||
let finish = AnisotropicSurface::from_isotropic_optical(finish);
|
||||
assert_eq!(finish.roughness(), [0.02; 2]);
|
||||
let d = 1.0 / (PI * 0.02_f64.powi(4));
|
||||
close(finish.distribution(frame(), Vec3::Z).unwrap(), d, 1e-14);
|
||||
close(finish.visible_reflection_pdf(frame(), Vec3::Z, Vec3::Z, Vec3::Z).unwrap(), d / 4.0, 1e-14);
|
||||
close_rgb(finish.specular_brdf(frame(), Vec3::Z, Vec3::Z, Vec3::Z).unwrap(), old.base_color() * (d / 4.0), 1e-14);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@
|
||||
|
||||
use crate::job::{CancelToken, ErrorCode, JobError, Result};
|
||||
|
||||
// Standalone adaptive camera-film foundation. No CPU request/producer is installed.
|
||||
#[path = "film_adaptive.rs"]
|
||||
pub mod adaptive;
|
||||
|
||||
#[path = "film_png.rs"]
|
||||
pub mod png;
|
||||
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
//! CPU camera film for the explicit independent-pilot image continuation.
|
||||
//! Independent pilots allocate fixed production counts. Pilot radiance is discarded,
|
||||
//! and complete passes borrow exact integer counts without retaining a new snapshot.
|
||||
use super::{invalid, limit};
|
||||
use crate::job::{
|
||||
CancelToken, CpuBudget, CpuProgressiveOptions, ErrorCode, JobError, Result,
|
||||
adaptive::{AdaptivePreview, AdaptiveSampling, PreviewStage, SamplingReport},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Moments {
|
||||
// Pilot: Welford RGBA mean/M2. Production: sum.rgb, with M2 unused.
|
||||
value: [f64; 4],
|
||||
m2: [f64; 4],
|
||||
}
|
||||
/// Exact counts have a separate, borrowed plane so image finishing need not
|
||||
/// 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,
|
||||
}
|
||||
impl PixelCounts {
|
||||
pub fn covered(self) -> u16 {
|
||||
self.covered
|
||||
}
|
||||
pub fn samples(self) -> u16 {
|
||||
self.samples
|
||||
}
|
||||
pub fn alpha8(self) -> Result<u8> {
|
||||
let samples = u32::from(self.samples);
|
||||
let covered = u32::from(self.covered);
|
||||
if samples == 0 || covered > samples {
|
||||
return Err(invalid("adaptive coverage has no valid sample denominator"));
|
||||
}
|
||||
u8::try_from((covered * 510 + samples) / (samples * 2)).map_err(|_| invalid("adaptive coverage exceeds one byte"))
|
||||
}
|
||||
}
|
||||
pub struct CountsPlane {
|
||||
values: Vec<PixelCounts>,
|
||||
}
|
||||
impl CountsPlane {
|
||||
pub fn len(&self) -> usize {
|
||||
self.values.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.values.is_empty()
|
||||
}
|
||||
pub fn pixel(&self, index: usize) -> Result<PixelCounts> {
|
||||
self.values.get(index).copied().ok_or_else(|| invalid("adaptive count index is outside the complete plane"))
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Stage {
|
||||
Pilot,
|
||||
Reset,
|
||||
Production,
|
||||
Done,
|
||||
}
|
||||
/// A stream identity for an existing counter sampler. Production keeps the
|
||||
/// original seed and zero-based ordinals. Pilots use a distinct fixed seed tag.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SamplingIdentity {
|
||||
pub seed: u32,
|
||||
pub ordinal: u32,
|
||||
pub stage: PreviewStage,
|
||||
}
|
||||
/// Two prepaid arrays, one ordered update/skip per pixel per complete sweep.
|
||||
/// ImageTask includes required_bytes in the SAME existing job lease. The counts
|
||||
/// remain borrowed by finishing, never copied into an unbilled snapshot plane.
|
||||
pub struct AdaptiveCameraFilm {
|
||||
width: u32,
|
||||
height: u32,
|
||||
maximum: u32,
|
||||
seed: u32,
|
||||
options: AdaptiveSampling,
|
||||
moments: Vec<Moments>,
|
||||
counts: CountsPlane,
|
||||
count: usize,
|
||||
completed: u32,
|
||||
next_pixel: usize,
|
||||
stage: Stage,
|
||||
report: SamplingReport,
|
||||
production_evaluations: u64,
|
||||
}
|
||||
/// Borrowing this view prevents reset/add/skip until finishing releases it.
|
||||
/// It is neither an encoded image nor a transferable retained snapshot/lease.
|
||||
pub struct AdaptivePass<'a> {
|
||||
film: &'a AdaptiveCameraFilm,
|
||||
}
|
||||
impl AdaptivePass<'_> {
|
||||
pub fn metadata(&self) -> AdaptivePreview {
|
||||
let pilot = self.film.stage == Stage::Pilot;
|
||||
AdaptivePreview {
|
||||
stage: if pilot { PreviewStage::Pilot } else { PreviewStage::Production },
|
||||
completed: self.film.completed,
|
||||
target: if pilot { self.film.options.pilot_samples } else { self.film.maximum },
|
||||
pilot_samples: self.film.options.pilot_samples,
|
||||
minimum_production_samples: self.film.options.min_samples,
|
||||
maximum_production_samples: self.film.maximum,
|
||||
}
|
||||
}
|
||||
pub fn counts(&self) -> &CountsPlane {
|
||||
&self.film.counts
|
||||
}
|
||||
/// Premultiplied linear radiance and coverage, before any float32 conversion,
|
||||
/// denoising, exposure or display transform. The denominator is pixel-specific.
|
||||
pub fn pixel(&self, index: usize) -> Result<([f64; 3], f64)> {
|
||||
let counts = self.counts().pixel(index)?;
|
||||
if counts.samples == 0 || counts.covered > counts.samples {
|
||||
return Err(invalid("adaptive pass has invalid exact coverage"));
|
||||
}
|
||||
let value = self.film.moments.get(index).ok_or_else(|| invalid("adaptive resolve index exceeds admission"))?.value;
|
||||
let divisor = if self.film.stage == Stage::Pilot { 1.0 } else { f64::from(counts.samples) };
|
||||
Ok(([value[0] / divisor, value[1] / divisor, value[2] / divisor], f64::from(counts.covered) / f64::from(counts.samples)))
|
||||
}
|
||||
}
|
||||
impl AdaptiveCameraFilm {
|
||||
/// The existing CPU profile includes all pilots plus the production cap.
|
||||
/// GPU AdaptiveSampling chooses the statistical policy; no CPU cap is raised.
|
||||
pub fn required_bytes(width: u32, height: u32, production: CpuProgressiveOptions, options: AdaptiveSampling, budget: CpuBudget) -> Result<u64> {
|
||||
options.validate(production.samples_per_pixel)?;
|
||||
let total = options.pilot_samples.checked_add(production.samples_per_pixel).ok_or_else(|| limit("adaptive camera target overflow"))?;
|
||||
budget.validate_image(width, height, Some(CpuProgressiveOptions { samples_per_pixel: total, ..production }))?;
|
||||
(u64::from(width) * u64::from(height))
|
||||
.checked_mul((std::mem::size_of::<Moments>() + std::mem::size_of::<PixelCounts>()) as u64)
|
||||
.and_then(|bytes| bytes.checked_add(std::mem::size_of::<Self>() as u64))
|
||||
.filter(|bytes| *bytes <= budget.limits().max_job_bytes)
|
||||
.ok_or_else(|| limit("adaptive film storage exceeds the unchanged CPU profile"))
|
||||
}
|
||||
pub fn new(
|
||||
width: u32,
|
||||
height: u32,
|
||||
production: CpuProgressiveOptions,
|
||||
options: AdaptiveSampling,
|
||||
budget: CpuBudget,
|
||||
admitted_bytes: u64,
|
||||
cancel: &CancelToken,
|
||||
) -> Result<Self> {
|
||||
cancel.check()?;
|
||||
let required = Self::required_bytes(width, height, production, options, budget)?;
|
||||
if admitted_bytes < required {
|
||||
return Err(limit("adaptive film is not prepaid by the owning image job"));
|
||||
}
|
||||
let count = usize::try_from(u64::from(width) * u64::from(height)).map_err(|_| limit("adaptive film exceeds the address space"))?;
|
||||
let mut moments = Vec::new();
|
||||
let mut counts = Vec::new();
|
||||
moments.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "could not reserve adaptive moments"))?;
|
||||
cancel.check()?;
|
||||
counts.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "could not reserve exact adaptive counts"))?;
|
||||
if moments.capacity() != count || counts.capacity() != count {
|
||||
return Err(limit("actual adaptive film capacity exceeds admission"));
|
||||
}
|
||||
cancel.check()?;
|
||||
Ok(Self {
|
||||
width,
|
||||
height,
|
||||
maximum: production.samples_per_pixel,
|
||||
seed: production.seed,
|
||||
options,
|
||||
moments,
|
||||
counts: CountsPlane { values: counts },
|
||||
count,
|
||||
completed: 0,
|
||||
next_pixel: 0,
|
||||
stage: Stage::Pilot,
|
||||
report: SamplingReport {
|
||||
pilot_samples_per_pixel: options.pilot_samples,
|
||||
minimum_production_samples: production.samples_per_pixel,
|
||||
maximum_production_samples: 0,
|
||||
production_samples: 0,
|
||||
primary_path_evaluations: 0,
|
||||
pixels_at_cap: 0,
|
||||
transmission_strata: 1,
|
||||
},
|
||||
production_evaluations: 0,
|
||||
})
|
||||
}
|
||||
pub fn dimensions(&self) -> [u32; 2] {
|
||||
[self.width, self.height]
|
||||
}
|
||||
pub fn initialized(&self) -> usize {
|
||||
self.counts.values.len()
|
||||
}
|
||||
pub fn ready(&self) -> bool {
|
||||
self.initialized() == self.count && self.moments.len() == self.count
|
||||
}
|
||||
pub fn initialize_one(&mut self, cancel: &CancelToken, charge: &mut impl FnMut() -> Result<()>) -> Result<()> {
|
||||
cancel.check()?;
|
||||
if self.ready() || self.moments.len() != self.counts.values.len() {
|
||||
return Err(invalid("adaptive film initialization is complete or inconsistent"));
|
||||
}
|
||||
charge()?;
|
||||
cancel.check()?;
|
||||
self.moments.push(Moments::default());
|
||||
self.counts.values.push(PixelCounts::default());
|
||||
Ok(())
|
||||
}
|
||||
pub fn next_pixel(&self) -> usize {
|
||||
self.next_pixel
|
||||
}
|
||||
pub fn completed_passes(&self) -> u32 {
|
||||
if self.stage == Stage::Pilot { self.completed } else { self.options.pilot_samples + self.completed }
|
||||
}
|
||||
pub fn final_pass_target(&self) -> u32 {
|
||||
self.options.pilot_samples + self.maximum
|
||||
}
|
||||
pub fn is_final(&self) -> bool {
|
||||
self.stage == Stage::Done
|
||||
}
|
||||
fn ordered(&self, index: usize) -> Result<PixelCounts> {
|
||||
if !self.ready() || self.stage == Stage::Reset || self.stage == Stage::Done || index != self.next_pixel {
|
||||
return Err(invalid("adaptive film needs the next pixel in a sampling sweep"));
|
||||
}
|
||||
self.counts.pixel(index)
|
||||
}
|
||||
/// 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)?;
|
||||
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) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(SamplingIdentity { seed: self.seed, ordinal: self.completed, stage: PreviewStage::Production }))
|
||||
}
|
||||
fn advance(&mut self) {
|
||||
self.next_pixel += 1;
|
||||
if self.next_pixel == self.count {
|
||||
self.next_pixel = 0;
|
||||
self.completed += 1;
|
||||
if self.stage == Stage::Production && self.completed == self.maximum {
|
||||
self.stage = Stage::Done;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Pilot moments/production sums and exact counts form ONE transaction. Every
|
||||
/// fallible check, work debit and cancellation check precedes both writes.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
index: usize,
|
||||
identity: SamplingIdentity,
|
||||
rgb: [f64; 3],
|
||||
covered: bool,
|
||||
cancel: &CancelToken,
|
||||
charge: &mut impl FnMut() -> Result<()>,
|
||||
) -> Result<()> {
|
||||
cancel.check()?;
|
||||
let expected = self.sampling_identity(index)?.ok_or_else(|| invalid("stopped adaptive pixel cannot accept another sample"))?;
|
||||
if identity != expected {
|
||||
return Err(invalid("adaptive stream/ordinal does not match the captured sweep"));
|
||||
}
|
||||
if rgb.iter().any(|value| !value.is_finite() || !(0.0..=1e12).contains(value)) || (!covered && rgb != [0.0; 3]) {
|
||||
return Err(invalid("invalid adaptive premultiplied camera radiance or coverage"));
|
||||
}
|
||||
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 {
|
||||
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"))?;
|
||||
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) {
|
||||
let delta = value - *mean;
|
||||
*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"))?;
|
||||
}
|
||||
} else {
|
||||
for (sum, value) in moments.value.iter_mut().take(3).zip(rgb) {
|
||||
*sum += value;
|
||||
if !sum.is_finite() || *sum < 0.0 || *sum > f64::from(self.maximum) * 1e12 {
|
||||
return Err(invalid("adaptive production sum exceeds its finite bound"));
|
||||
}
|
||||
}
|
||||
}
|
||||
counts.samples = samples;
|
||||
counts.covered = hits;
|
||||
// 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);
|
||||
report.minimum_production_samples = report.minimum_production_samples.min(target);
|
||||
report.maximum_production_samples = report.maximum_production_samples.max(target);
|
||||
report.production_samples =
|
||||
report.production_samples.checked_add(u64::from(target)).ok_or_else(|| limit("adaptive report allocation overflow"))?;
|
||||
report.pixels_at_cap += u64::from(target == self.maximum);
|
||||
}
|
||||
let evaluations = self
|
||||
.production_evaluations
|
||||
.checked_add(u64::from(self.stage == Stage::Production))
|
||||
.ok_or_else(|| limit("adaptive evaluation report overflow"))?;
|
||||
let target_moments = self.moments.get_mut(index).ok_or_else(|| invalid("adaptive destination moments disappeared"))?;
|
||||
let target_counts = self.counts.values.get_mut(index).ok_or_else(|| invalid("adaptive destination counts disappeared"))?;
|
||||
charge()?;
|
||||
cancel.check()?;
|
||||
*target_moments = moments;
|
||||
*target_counts = counts;
|
||||
self.report = report;
|
||||
self.production_evaluations = evaluations;
|
||||
self.advance();
|
||||
Ok(())
|
||||
}
|
||||
pub fn skip_one(&mut self, index: usize, cancel: &CancelToken, charge: &mut impl FnMut() -> Result<()>) -> Result<()> {
|
||||
cancel.check()?;
|
||||
if self.stage != Stage::Production || self.sampling_identity(index)?.is_some() {
|
||||
return Err(invalid("only a fully sampled production pixel may be skipped"));
|
||||
}
|
||||
charge()?;
|
||||
cancel.check()?;
|
||||
self.advance();
|
||||
Ok(())
|
||||
}
|
||||
/// Borrow exact counts only at a complete, frozen pass. The caller may pass
|
||||
/// this borrow into one finishing step without keeping a self-reference.
|
||||
pub fn counts_plane(&self) -> Result<&CountsPlane> {
|
||||
self.complete_pass()?;
|
||||
Ok(&self.counts)
|
||||
}
|
||||
|
||||
pub fn complete_pass(&self) -> Result<AdaptivePass<'_>> {
|
||||
if !self.ready() || self.next_pixel != 0 || self.completed == 0 || self.stage == Stage::Reset {
|
||||
return Err(invalid("adaptive film has no complete sampling pass"));
|
||||
}
|
||||
Ok(AdaptivePass { film: self })
|
||||
}
|
||||
/// Called only after last pilot finishing/publication is no longer borrowing
|
||||
/// this film. Reset is cooperative and never exposes partially discarded pilots.
|
||||
pub fn begin_production(&mut self, cancel: &CancelToken, charge: &mut impl FnMut() -> Result<()>) -> Result<()> {
|
||||
cancel.check()?;
|
||||
if self.stage != Stage::Pilot || self.next_pixel != 0 || self.completed != self.options.pilot_samples {
|
||||
return Err(invalid("production requires all complete independent pilots"));
|
||||
}
|
||||
charge()?;
|
||||
cancel.check()?;
|
||||
self.stage = Stage::Reset;
|
||||
self.completed = 0;
|
||||
Ok(())
|
||||
}
|
||||
pub fn resetting(&self) -> bool {
|
||||
self.stage == Stage::Reset
|
||||
}
|
||||
pub fn reset_one(&mut self, cancel: &CancelToken, charge: &mut impl FnMut() -> Result<()>) -> Result<()> {
|
||||
cancel.check()?;
|
||||
if self.stage != Stage::Reset {
|
||||
return Err(invalid("adaptive film is not resetting pilots"));
|
||||
}
|
||||
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)) {
|
||||
return Err(invalid("adaptive reset has no admitted production allocation"));
|
||||
}
|
||||
charge()?;
|
||||
cancel.check()?;
|
||||
*moments = Moments::default();
|
||||
counts.covered = 0;
|
||||
counts.samples = 0;
|
||||
self.next_pixel += 1;
|
||||
if self.next_pixel == self.count {
|
||||
self.next_pixel = 0;
|
||||
self.stage = Stage::Production;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn production_target(&self, index: usize) -> Result<u32> {
|
||||
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))
|
||||
}
|
||||
pub fn report(&self) -> Result<SamplingReport> {
|
||||
if !self.is_final() {
|
||||
return Err(invalid("adaptive sampling report requires complete production"));
|
||||
}
|
||||
if self.production_evaluations != self.report.production_samples {
|
||||
return Err(invalid("adaptive final evaluation count disagrees with its pilot allocation"));
|
||||
}
|
||||
let mut report = self.report.clone();
|
||||
report.primary_path_evaluations = u64::from(self.options.pilot_samples) * self.count as u64 + self.production_evaluations;
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
@@ -275,3 +275,302 @@ fn primary_film_large_profile_keeps_every_sample_and_cancels_before_mutation() {
|
||||
assert!(film.add(0, 512, [0.0; 3], true, &cancel, &mut || Ok(())).is_err());
|
||||
assert_eq!(film.samples(), 512);
|
||||
}
|
||||
|
||||
mod cpu_adaptive_foundation {
|
||||
use super::adaptive::{AdaptiveCameraFilm, SamplingIdentity};
|
||||
use super::*;
|
||||
use crate::job::{
|
||||
CpuBudget, CpuProgressiveOptions,
|
||||
adaptive::{AdaptiveSampling, PreviewStage},
|
||||
};
|
||||
|
||||
fn options() -> AdaptiveSampling {
|
||||
AdaptiveSampling { pilot_samples: 16, min_samples: 16, relative_error: 0.0, absolute_error: 0.1, coverage_error: 0.25 }
|
||||
}
|
||||
fn film(maximum: u32) -> AdaptiveCameraFilm {
|
||||
let production = CpuProgressiveOptions { samples_per_pixel: maximum, seed: 81 };
|
||||
let bytes = AdaptiveCameraFilm::required_bytes(2, 1, production, options(), CpuBudget::Compact).unwrap();
|
||||
AdaptiveCameraFilm::new(2, 1, production, options(), CpuBudget::Compact, bytes, &CancelToken::default()).unwrap()
|
||||
}
|
||||
fn source(index: usize, key: SamplingIdentity) -> ([f64; 3], bool) {
|
||||
match (key.stage, index) {
|
||||
(PreviewStage::Pilot, 0) => ([0.25, 0.5, 0.75], true),
|
||||
(PreviewStage::Pilot, _) => {
|
||||
if key.ordinal % 2 == 1 {
|
||||
([1.0, 0.0, 0.0], true)
|
||||
} else {
|
||||
([0.0; 3], false)
|
||||
}
|
||||
}
|
||||
(PreviewStage::Production, 0) => ([3.0, 1.0, 2.0], true),
|
||||
(PreviewStage::Production, _) => {
|
||||
if key.ordinal % 6 != 5 {
|
||||
([6.0, 0.0, 0.0], true)
|
||||
} else {
|
||||
([0.0; 3], false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn step(film: &mut AdaptiveCameraFilm, work: &mut u64) {
|
||||
let cancel = CancelToken::default();
|
||||
let mut charge = || {
|
||||
*work += 1;
|
||||
Ok(())
|
||||
};
|
||||
if !film.ready() {
|
||||
film.initialize_one(&cancel, &mut charge).unwrap();
|
||||
} else if film.resetting() {
|
||||
film.reset_one(&cancel, &mut charge).unwrap();
|
||||
} else if film.complete_pass().is_ok_and(|pass| pass.metadata().stage == PreviewStage::Pilot && pass.metadata().completed == 16) {
|
||||
film.begin_production(&cancel, &mut charge).unwrap();
|
||||
} else {
|
||||
let index = film.next_pixel();
|
||||
if let Some(identity) = film.sampling_identity(index).unwrap() {
|
||||
let (rgb, covered) = source(index, identity);
|
||||
film.add(index, identity, rgb, covered, &cancel, &mut charge).unwrap();
|
||||
} else {
|
||||
film.skip_one(index, &cancel, &mut charge).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
type Snapshot = (PreviewStage, u32, Vec<([f64; 3], f64, u16, u16)>);
|
||||
fn snapshot(film: &AdaptiveCameraFilm) -> Option<Snapshot> {
|
||||
let pass = film.complete_pass().ok()?;
|
||||
let metadata = pass.metadata();
|
||||
let pixels = (0..pass.counts().len())
|
||||
.map(|index| {
|
||||
let (mean, alpha) = pass.pixel(index).unwrap();
|
||||
let count = pass.counts().pixel(index).unwrap();
|
||||
(mean, alpha, count.covered(), count.samples())
|
||||
})
|
||||
.collect();
|
||||
Some((metadata.stage, metadata.completed, pixels))
|
||||
}
|
||||
fn complete(maximum: u32, quantum: usize) -> (Vec<Snapshot>, crate::job::adaptive::SamplingReport, u64) {
|
||||
let mut film = film(maximum);
|
||||
let mut work = 0;
|
||||
let mut snapshots = Vec::new();
|
||||
for _ in 0..10_000 {
|
||||
for _ in 0..quantum {
|
||||
if film.is_final() {
|
||||
break;
|
||||
}
|
||||
step(&mut film, &mut work);
|
||||
if let Some(next) = snapshot(&film)
|
||||
&& snapshots.last() != Some(&next)
|
||||
{
|
||||
snapshots.push(next);
|
||||
}
|
||||
}
|
||||
if film.is_final() {
|
||||
return (snapshots, film.report().unwrap(), work);
|
||||
}
|
||||
}
|
||||
panic!("bounded adaptive foundation did not complete");
|
||||
}
|
||||
#[test]
|
||||
fn cpu_adaptive_film_independent_variance_allocation_discards_pilots_and_normalizes_heterogeneous_production() {
|
||||
let mut film = film(48);
|
||||
let mut work = 0;
|
||||
while film.completed_passes() < 16 {
|
||||
step(&mut film, &mut work);
|
||||
}
|
||||
let pilot = snapshot(&film).unwrap();
|
||||
assert_eq!(pilot.2[0], ([0.25, 0.5, 0.75], 1.0, 16, 16));
|
||||
assert_eq!(pilot.2[1], ([0.5, 0.0, 0.0], 0.5, 8, 16));
|
||||
assert_eq!(film.production_target(0).unwrap(), 16);
|
||||
// Eight0/eight1 pilots have M2=4, sample variance4/15. With
|
||||
// absolute RGB error0.1, ceil((4/15)/0.01)=27; alpha uses0.25.
|
||||
assert_eq!(film.production_target(1).unwrap(), 27);
|
||||
while !film.is_final() {
|
||||
step(&mut film, &mut work);
|
||||
}
|
||||
let final_pass = film.complete_pass().unwrap();
|
||||
assert_eq!(final_pass.pixel(0).unwrap(), ([3.0, 1.0, 2.0], 1.0), "pilot radiance is discarded rather than stopped into production");
|
||||
assert_eq!((final_pass.counts().pixel(0).unwrap().samples(), final_pass.counts().pixel(1).unwrap().samples()), (16, 27));
|
||||
let (mean, alpha) = final_pass.pixel(1).unwrap();
|
||||
assert_eq!(mean, [138.0 / 27.0, 0.0, 0.0]);
|
||||
assert_eq!(alpha, 23.0 / 27.0);
|
||||
let report = film.report().unwrap();
|
||||
assert_eq!((report.pilot_samples_per_pixel, report.minimum_production_samples, report.maximum_production_samples), (16, 16, 27));
|
||||
assert_eq!((report.production_samples, report.primary_path_evaluations, report.pixels_at_cap, report.transmission_strata), (43, 75, 0, 1));
|
||||
assert_eq!(work, 133, "init/reset and each sampled/skipped pixel own one exact work unit");
|
||||
}
|
||||
#[test]
|
||||
fn cpu_adaptive_counts_keep_five_of_six_alpha_exact_without_quantizing_raw_radiance() {
|
||||
let mut film = film(48);
|
||||
let mut work = 0;
|
||||
loop {
|
||||
if let Some((PreviewStage::Production, 6, _)) = snapshot(&film) {
|
||||
break;
|
||||
}
|
||||
step(&mut film, &mut work);
|
||||
}
|
||||
let pass = film.complete_pass().unwrap();
|
||||
let count = pass.counts().pixel(1).unwrap();
|
||||
assert_eq!((count.covered(), count.samples(), count.alpha8().unwrap()), (5, 6, 213));
|
||||
let (raw, alpha) = pass.pixel(1).unwrap();
|
||||
assert_eq!(raw, [5.0, 0.0, 0.0]);
|
||||
assert_eq!(alpha, 5.0 / 6.0);
|
||||
assert_eq!(alpha as f32, 5.0f32 / 6.0);
|
||||
assert_ne!(alpha as f32, 213.0f32 / 255.0, "PNG byte rounding cannot alter the raw FLOAT32 alpha");
|
||||
}
|
||||
#[test]
|
||||
fn cpu_adaptive_all_complete_passes_and_production_prefixes_are_quantum_and_cap_independent() {
|
||||
let (snapshots, report, work) = complete(48, 1);
|
||||
assert_eq!((snapshots.clone(), report.clone(), work), complete(48, 16384));
|
||||
assert_eq!(snapshots.len(), 64);
|
||||
assert_eq!(snapshots.iter().filter(|s| s.0 == PreviewStage::Pilot).count(), 16);
|
||||
assert_eq!(snapshots.last().unwrap().1, 48);
|
||||
for maximum in 16..=48 {
|
||||
let (prefix, _, _) = complete(maximum, 7);
|
||||
assert_eq!(&prefix[..16], &snapshots[..16], "the production cap never enters the pilot stream");
|
||||
assert_eq!(prefix.last().unwrap(), snapshots.iter().find(|s| s.0 == PreviewStage::Production && s.1 == maximum).unwrap());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn cpu_adaptive_sample_identities_separate_pilots_and_preserve_original_production_ordinals() {
|
||||
let mut film = film(48);
|
||||
let mut work = 0;
|
||||
while !film.ready() {
|
||||
step(&mut film, &mut work);
|
||||
}
|
||||
let pilot = film.sampling_identity(0).unwrap().unwrap();
|
||||
assert_eq!((pilot.seed, pilot.ordinal, pilot.stage), (81 ^ 0xa511_e9b3, 0, PreviewStage::Pilot));
|
||||
while film.complete_pass().is_err() || film.complete_pass().unwrap().metadata().stage == PreviewStage::Pilot {
|
||||
step(&mut film, &mut work);
|
||||
}
|
||||
// A complete first production pass has consumed ordinal0, so the next
|
||||
// active pixel receives original seed81 and absolute production ordinal1.
|
||||
let production = film.sampling_identity(0).unwrap().unwrap();
|
||||
assert_eq!((production.seed, production.ordinal, production.stage), (81, 1, PreviewStage::Production));
|
||||
assert_ne!(pilot.seed, production.seed);
|
||||
while film.completed_passes() < 32 {
|
||||
step(&mut film, &mut work);
|
||||
}
|
||||
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_pilots_plus_production_obey_every_unchanged_profile_and_camera_work_cap() {
|
||||
for budget in CpuBudget::ALL {
|
||||
let maximum = budget.limits().max_samples_per_pixel;
|
||||
let make = |production| CpuProgressiveOptions { samples_per_pixel: production, seed: 0 };
|
||||
assert!(AdaptiveCameraFilm::required_bytes(1, 1, make(maximum - 16), options(), budget).is_ok());
|
||||
assert!(AdaptiveCameraFilm::required_bytes(1, 1, make(maximum - 15), options(), budget).is_err());
|
||||
assert!(AdaptiveCameraFilm::required_bytes(1, 1, make(maximum), options(), budget).is_err());
|
||||
}
|
||||
let production = CpuProgressiveOptions { samples_per_pixel: 16, seed: 0 };
|
||||
assert!(AdaptiveCameraFilm::required_bytes(1024, 512, production, options(), CpuBudget::Compact).is_ok());
|
||||
assert!(
|
||||
AdaptiveCameraFilm::required_bytes(1024, 513, production, options(), CpuBudget::Compact).is_err(),
|
||||
"pilot camera paths are included in the same total sample budget"
|
||||
);
|
||||
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!(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!(
|
||||
AdaptiveCameraFilm::required_bytes(1, 1, production, AdaptiveSampling { pilot_samples: 15, ..options() }, CpuBudget::Compact).is_err()
|
||||
);
|
||||
assert!(
|
||||
AdaptiveCameraFilm::required_bytes(1, 1, production, AdaptiveSampling { relative_error: f64::NAN, ..options() }, CpuBudget::Compact)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn cpu_adaptive_add_reset_skip_and_initialization_are_atomic_under_cancel_or_work_failure() {
|
||||
for operation in ["initialize", "pilot", "reset", "production", "skip"] {
|
||||
for cancelled in [false, true] {
|
||||
let mut film = film(48);
|
||||
let mut work = 0;
|
||||
if operation != "initialize" {
|
||||
while !film.ready() {
|
||||
step(&mut film, &mut work);
|
||||
}
|
||||
}
|
||||
if matches!(operation, "reset" | "production" | "skip") {
|
||||
while film.completed_passes() < 16 {
|
||||
step(&mut film, &mut work);
|
||||
}
|
||||
film.begin_production(&CancelToken::default(), &mut || Ok(())).unwrap();
|
||||
}
|
||||
if matches!(operation, "production" | "skip") {
|
||||
while film.resetting() {
|
||||
step(&mut film, &mut work);
|
||||
}
|
||||
}
|
||||
if operation == "skip" {
|
||||
while film.completed_passes() < 32 {
|
||||
step(&mut film, &mut work);
|
||||
}
|
||||
}
|
||||
let before = (
|
||||
film.initialized(),
|
||||
film.ready(),
|
||||
film.next_pixel(),
|
||||
film.completed_passes(),
|
||||
snapshot(&film),
|
||||
film.sampling_identity(film.next_pixel()).ok(),
|
||||
);
|
||||
let cancel = CancelToken::default();
|
||||
let token = cancel.clone();
|
||||
let mut charge = || {
|
||||
if cancelled {
|
||||
token.cancel();
|
||||
Ok(())
|
||||
} else {
|
||||
Err(JobError::new(ErrorCode::LimitExceeded, "test cumulative work exhausted"))
|
||||
}
|
||||
};
|
||||
let result = match operation {
|
||||
"initialize" => film.initialize_one(&cancel, &mut charge),
|
||||
"reset" => film.reset_one(&cancel, &mut charge),
|
||||
"skip" => film.skip_one(0, &cancel, &mut charge),
|
||||
_ => {
|
||||
let key = film.sampling_identity(0).unwrap().unwrap();
|
||||
let (rgb, covered) = source(0, key);
|
||||
film.add(0, key, rgb, covered, &cancel, &mut charge)
|
||||
}
|
||||
};
|
||||
assert_eq!(result.unwrap_err().code, if cancelled { ErrorCode::Cancelled } else { ErrorCode::LimitExceeded }, "{operation}");
|
||||
assert_eq!(
|
||||
(
|
||||
film.initialized(),
|
||||
film.ready(),
|
||||
film.next_pixel(),
|
||||
film.completed_passes(),
|
||||
snapshot(&film),
|
||||
film.sampling_identity(film.next_pixel()).ok()
|
||||
),
|
||||
before,
|
||||
"{operation} may not partially consume its cursor/count/stream"
|
||||
);
|
||||
assert!(film.report().is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn cpu_adaptive_integration_requires_explicit_progressive_intent_and_preserves_strict_input() {
|
||||
assert!(serde_json::to_value(crate::job::RaytraceOptions::default()).unwrap().get("adaptive").is_none());
|
||||
let invalid: crate::job::RaytraceOptions = serde_json::from_value(serde_json::json!({"adaptive":options()})).unwrap();
|
||||
assert!(invalid.validate().is_err(), "adaptive cannot silently introduce a camera film into a fixed saved setup");
|
||||
assert!(serde_json::from_value::<crate::job::RaytraceOptions>(serde_json::json!({"adaptive":{"noise_guarantee":true}})).is_err());
|
||||
let mut film = film(48);
|
||||
let mut work = 0;
|
||||
while !film.ready() {
|
||||
step(&mut film, &mut work);
|
||||
}
|
||||
assert!(film.complete_pass().is_err());
|
||||
assert!(film.begin_production(&CancelToken::default(), &mut || Ok(())).is_err());
|
||||
let key = film.sampling_identity(0).unwrap().unwrap();
|
||||
for (rgb, covered) in [([f64::NAN, 0.0, 0.0], true), ([f64::INFINITY, 0.0, 0.0], true), ([1.0, 0.0, 0.0], false)] {
|
||||
assert!(film.add(0, key, rgb, covered, &CancelToken::default(), &mut || panic!("invalid input must not debit or mutate work")).is_err());
|
||||
assert_eq!(film.sampling_identity(0).unwrap().unwrap(), key);
|
||||
}
|
||||
assert!(film.add(1, key, [0.0; 3], false, &CancelToken::default(), &mut || Ok(())).is_err());
|
||||
assert!(film.skip_one(0, &CancelToken::default(), &mut || Ok(())).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user