diff --git a/Docs/pure-base-shader-contract.md b/Docs/pure-base-shader-contract.md index 857880b..df14699 100644 --- a/Docs/pure-base-shader-contract.md +++ b/Docs/pure-base-shader-contract.md @@ -111,7 +111,27 @@ The model-specific properties are: | `PureBase/PBR` | `_NormalMap`, `_NormalScale`, `_Metallic`, `_Roughness`, `_UseUnityStandardDiffuseBrightness` | | `PureBase/Hybrid` | `_NormalMap`, `_NormalScale`, `_Metallic`, `_Roughness`, `_UseUnityStandardDiffuseBrightness` | -PBR and Hybrid use byte-identical property declarations. `_Roughness` clamps from `0.002` to `1`. +PBR and Hybrid use byte-identical property declarations. `_Roughness` is a ShaderLab `Float` backed by `SC_float`, with default `0.5`, public perceptual range `[0.089, 1]`, and exact `[SCRange(0.089,1)]` metadata. It remains ordered between `_Metallic` and `_UseUnityStandardDiffuseBrightness`, and the complete PBR and Hybrid property declarations, including this metadata, remain byte-identical. + +### PBR and Hybrid roughness contract + +`_Roughness` stores perceptual roughness `p`, not academic roughness `p^2`. PBR and Hybrid use one shared runtime clamp, `clamp(p, 0.089, 1)`, before creating their shared BRDF data. The clamped value feeds every roughness-sensitive path: + +- Direct GGX evaluates `roughnessSquared = p^2` and then reaches `roughnessFourth = p^4` in the direct evaluator for both `ForwardBase` and `ForwardAdd`. +- Unity Standard GI and reflection-probe setup derive `Smoothness = 1 - p` from the same clamped value before `LightingStandard_GI`; the resulting indirect contribution uses the same shared BRDF data. +- PBR and Hybrid `Meta` fragments create the same shared BRDF data, so Meta/lightmapping uses the same floor through its squared-roughness rule. + +The floor is `0.089` because the direct evaluator's fourth-power term must remain above the IEEE-754 binary16 minimum positive normal. Specifically, $0.089^4 = 0.000062742241 > 2^{-14} = 0.00006103515625$. Unity URP uses related FP16 protections in its [`BRDF.hlsl`](https://github.com/Unity-Technologies/Graphics/blob/e6595ee2d83c8b02dab6e58abba0ff285c0c80ed/Packages/com.unity.render-pipelines.universal/ShaderLibrary/BRDF.hlsl): its BRDF initialization protects squared roughness with `HALF_MIN_SQRT` and the square of that value with `HALF_MIN`. Unity Core's [`CommonMaterial.hlsl`](https://github.com/Unity-Technologies/Graphics/blob/cdc941e1378729b1ca1fafb175151ac3d781ebb0/Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl) also documents that zero or excessively small analytical-light roughness is invalid or can alias. These are related numerical protections, not a claim that URP is supported or that Pure Base implements the URP BRDF. + +Built-in Standard's internal `0.002` clamp is a different parameterization. It clamps academic roughness after perceptual roughness has been squared, whereas Pure Base's public `_Roughness` is perceptual `p`. The installed Unity `2022.3.22f1` Built-in Standard source therefore does not define Pure Base's public floor; the same numeral represents a different quantity. + +### Existing-material compatibility + +- No material migration command or bulk serialized rewrite is added. A stored value below `0.089` remains stored as-is until a user explicitly edits or otherwise rewrites the material. +- At runtime, every stored `_Roughness` below `0.089` evaluates as `0.089`, so direct lighting, Unity Standard GI/reflection, and Meta/lightmapping produce the same roughness result as an input of `0.089`. +- Stored values at or above `0.089` retain their public ordering and roughness meaning. The public default remains `0.5`; shader names, property names, types, and pass ownership remain unchanged. + +This roughness-floor change does not implement the Issue #13 visibility approximation, Issue #14 multiple-scattering compensation, specular anti-aliasing, or full Unity Standard BRDF parity. It also does not change the ownership, placement, or behavior of `_UseUnityStandardDiffuseBrightness`. ### Direct diffuse brightness ABI and semantics diff --git a/Docs/technical-information.ja.md b/Docs/technical-information.ja.md index ac51da0..06e3a9c 100644 --- a/Docs/technical-information.ja.md +++ b/Docs/technical-information.ja.md @@ -73,7 +73,23 @@ Pure Base は Shader-Core を動かすための最小構成の土台です。多 `PureBase/Toon` は、追加で `_NormalMap` と `_NormalScale` を公開します。 -`PureBase/PBR` と `PureBase/Hybrid` は、法線マップ用の項目に加えて `_Metallic`、`_Roughness`、`_UseUnityStandardDiffuseBrightness` を公開します。両者の公開項目定義は完全に同一です。粗さは `0.002` から `1` の範囲に制限されます。 +`PureBase/PBR` と `PureBase/Hybrid` は、法線マップ用の項目に加えて `_Metallic`、`_Roughness`、`_UseUnityStandardDiffuseBrightness` を公開します。このメタデータを含む両者の公開項目定義は完全に同一です。`_Roughness` は `SC_float` を基にした ShaderLab の `Float` で、初期値は `0.5`、公開する知覚粗さの範囲は `[0.089, 1]`、メタデータは正確に `[SCRange(0.089,1)]` です。公開順序は `_Metallic` と `_UseUnityStandardDiffuseBrightness` の間のままです。 + +### PBR と Hybrid の知覚粗さ下限 + +`_Roughness` が保持するのは学術的な粗さ `p^2` ではなく、知覚粗さ `p` です。PBR と Hybrid は共通の BRDF データを作る前に、1つの共有ランタイムクランプ `clamp(p, 0.089, 1)` を使います。このクランプ後の値は、粗さに依存するすべての経路へ渡されます。 + +- 直接 GGX は `roughnessSquared = p^2` を計算し、`ForwardBase` と `ForwardAdd` の直接評価で `roughnessFourth = p^4` まで計算します。 +- Unity Standard の GI と反射プローブの準備では、同じクランプ後の値から `Smoothness = 1 - p` を作って `LightingStandard_GI` に渡します。その後の間接光評価も同じ BRDF データを使います。 +- PBR と Hybrid の `Meta` フラグメントは同じ BRDF データを作るため、Meta/ライトマップも二乗粗さの規則を通じて同じ下限を使います。 + +下限を `0.089` とするのは、直接評価の4乗項を IEEE-754 binary16 の最小正規化正数より大きく保つためです。具体的には、$0.089^4 = 0.000062742241 > 2^{-14} = 0.00006103515625$ です。Unity URP も [`BRDF.hlsl`](https://github.com/Unity-Technologies/Graphics/blob/e6595ee2d83c8b02dab6e58abba0ff285c0c80ed/Packages/com.unity.render-pipelines.universal/ShaderLibrary/BRDF.hlsl) で関連する FP16 保護を使っており、BRDF 初期化時に二乗粗さを `HALF_MIN_SQRT`、その値の二乗を `HALF_MIN` で保護します。Unity Core の [`CommonMaterial.hlsl`](https://github.com/Unity-Technologies/Graphics/blob/cdc941e1378729b1ca1fafb175151ac3d781ebb0/Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl) も、粗さがゼロまたは過度に小さい分析ライト用粗さは無効またはエイリアシングの原因になると説明しています。これは関連する数値保護を示すものであり、URP を対応対象にすることや、Pure Base が URP の BRDF を実装することを意味しません。 + +Built-in Standard 内部の `0.002` クランプは、別のパラメーター化です。これは知覚粗さを二乗した後の学術的な粗さに対して適用されます。一方、Pure Base の公開 `_Roughness` は知覚粗さ `p` です。そのため、インストール済み Unity `2022.3.22f1` の Built-in Standard ソースにある値は Pure Base の公開下限を定義せず、同じ数値でも表している量が異なります。 + +マテリアル移行コマンドや、保存値を一括書き換えする処理は追加しません。`0.089` 未満の保存値は、ユーザーが明示的に編集または別の方法でマテリアルを書き換えるまで、そのまま保存されます。ただし実行時には、直接光、Unity Standard の GI/反射、Meta/ライトマップのすべてで `0.089` として評価されます。`0.089` 以上の保存値は公開順序と粗さの意味を維持し、公開初期値も `0.5` のままです。 + +この変更には Issue #13 の可視性近似、Issue #14 の多重散乱補償、スペキュラアンチエイリアシング、Unity Standard との完全な BRDF 一致は含まれません。`_UseUnityStandardDiffuseBrightness` の所有範囲、配置、動作も変更しません。 ### PBR と Hybrid の直接拡散反射の輝度 diff --git a/Docs/technical-information.md b/Docs/technical-information.md index 9001fcc..93195b1 100644 --- a/Docs/technical-information.md +++ b/Docs/technical-information.md @@ -73,7 +73,23 @@ All four shaders expose these common properties: `PureBase/Toon` additionally exposes `_NormalMap` and `_NormalScale`. -`PureBase/PBR` and `PureBase/Hybrid` expose the same normal-map properties plus `_Metallic`, `_Roughness`, and `_UseUnityStandardDiffuseBrightness`. Their public property declarations are byte-identical. Roughness is clamped from `0.002` to `1`. +`PureBase/PBR` and `PureBase/Hybrid` expose the same normal-map properties plus `_Metallic`, `_Roughness`, and `_UseUnityStandardDiffuseBrightness`. Their public property declarations, including this metadata, are byte-identical. `_Roughness` is a ShaderLab `Float` backed by `SC_float`, with default `0.5`, public perceptual range `[0.089, 1]`, and exact `[SCRange(0.089,1)]` metadata. It remains ordered between `_Metallic` and `_UseUnityStandardDiffuseBrightness`. + +### PBR and Hybrid perceptual roughness floor + +`_Roughness` stores perceptual roughness `p`, not academic roughness `p^2`. PBR and Hybrid use one shared runtime clamp, `clamp(p, 0.089, 1)`, before creating their shared BRDF data. The clamped value feeds every roughness-sensitive path: + +- Direct GGX evaluates `roughnessSquared = p^2` and then reaches `roughnessFourth = p^4` in the direct evaluator for both `ForwardBase` and `ForwardAdd`. +- Unity Standard GI and reflection-probe setup derive `Smoothness = 1 - p` from the same clamped value before `LightingStandard_GI`; the resulting indirect contribution uses the same shared BRDF data. +- PBR and Hybrid `Meta` fragments create the same shared BRDF data, so Meta/lightmapping uses the same floor through its squared-roughness rule. + +The floor is `0.089` because the direct evaluator's fourth-power term must remain above the IEEE-754 binary16 minimum positive normal. Specifically, $0.089^4 = 0.000062742241 > 2^{-14} = 0.00006103515625$. Unity URP uses related FP16 protections in its [`BRDF.hlsl`](https://github.com/Unity-Technologies/Graphics/blob/e6595ee2d83c8b02dab6e58abba0ff285c0c80ed/Packages/com.unity.render-pipelines.universal/ShaderLibrary/BRDF.hlsl): its BRDF initialization protects squared roughness with `HALF_MIN_SQRT` and the square of that value with `HALF_MIN`. Unity Core's [`CommonMaterial.hlsl`](https://github.com/Unity-Technologies/Graphics/blob/cdc941e1378729b1ca1fafb175151ac3d781ebb0/Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl) also documents that zero or excessively small analytical-light roughness is invalid or can alias. These are related numerical protections, not a claim that URP is supported or that Pure Base implements the URP BRDF. + +Built-in Standard's internal `0.002` clamp is a different parameterization. It clamps academic roughness after perceptual roughness has been squared, whereas Pure Base's public `_Roughness` is perceptual `p`. The installed Unity `2022.3.22f1` Built-in Standard source therefore does not define Pure Base's public floor; the same numeral represents a different quantity. + +No material migration command or bulk serialized rewrite is added. A stored value below `0.089` remains stored as-is until a user explicitly edits or otherwise rewrites the material, but it evaluates as `0.089` at runtime in direct lighting, Unity Standard GI/reflection, and Meta/lightmapping. Stored values at or above `0.089` retain their public ordering and roughness meaning, and the public default remains `0.5`. + +This change does not implement the Issue #13 visibility approximation, Issue #14 multiple-scattering compensation, specular anti-aliasing, or full Unity Standard BRDF parity. It does not change the ownership, placement, or behavior of `_UseUnityStandardDiffuseBrightness`. ### PBR and Hybrid direct-diffuse brightness diff --git a/Shaders/Common/pbr_brdf.hlsl b/Shaders/Common/pbr_brdf.hlsl index 84871b9..8cde105 100644 --- a/Shaders/Common/pbr_brdf.hlsl +++ b/Shaders/Common/pbr_brdf.hlsl @@ -32,6 +32,15 @@ struct PureBasePbrBrdfData half roughnessSquared; }; +/// Defines the shared rounded-up perceptual-roughness floor as a compile-time half value. +static const half PureBasePbrPerceptualRoughnessFloor = 0.0890; + +/// Clamps perceptual roughness to 0.089; the rounded-up floor protects p^4 above the IEEE binary16 minimum normal and aligns with Unity URP HALF_MIN_SQRT/HALF_MIN initialization. +half PureBasePbrClampPerceptualRoughness(half perceptualRoughness) +{ + return clamp(perceptualRoughness, PureBasePbrPerceptualRoughnessFloor, 1.0); +} + /// Returns a finite unit direction and maps zero-length directions to zero. /// The direction to normalize. /// A finite unit direction or zero. @@ -49,7 +58,7 @@ PureBasePbrBrdfData PureBasePbrCreateBrdf(half3 albedo, half metallic, half roug { PureBasePbrBrdfData brdf; half clampedMetallic = saturate(metallic); - brdf.roughness = clamp(roughness, 0.002, 1.0); + brdf.roughness = PureBasePbrClampPerceptualRoughness(roughness); brdf.roughnessSquared = brdf.roughness * brdf.roughness; brdf.diffuseColor = saturate(albedo) * (1.0 - clampedMetallic); brdf.specularColor = lerp(half3(0.04, 0.04, 0.04), saturate(albedo), clampedMetallic); diff --git a/Shaders/Models/pbr.hlsl b/Shaders/Models/pbr.hlsl index 0a2a633..b89d1c0 100644 --- a/Shaders/Models/pbr.hlsl +++ b/Shaders/Models/pbr.hlsl @@ -137,7 +137,8 @@ SurfaceOutputStandard SCModelCreateStandardSurface(SCShadingData shadingData) surface.Normal = PureBasePbrSafeNormalize(shadingData.N); surface.Emission = 0; surface.Metallic = saturate(_Metallic); - surface.Smoothness = 1.0 - clamp(_Roughness, 0.002, 1.0); + half clampedPerceptualRoughness = PureBasePbrClampPerceptualRoughness(_Roughness); + surface.Smoothness = 1.0 - clampedPerceptualRoughness; surface.Occlusion = 1; surface.Alpha = 1; return surface; diff --git a/Shaders/PureBaseHybrid_properties.hlsl b/Shaders/PureBaseHybrid_properties.hlsl index 9b12577..c1c4921 100644 --- a/Shaders/PureBaseHybrid_properties.hlsl +++ b/Shaders/PureBaseHybrid_properties.hlsl @@ -18,5 +18,5 @@ SC_Texture2D(_NormalMap, "bump", [], "Normal Map", "") SC_SamplerState(sampler_NormalMap) SC_float(_NormalScale, 1, [SCRange(0,2)], "Normal Scale", "") SC_float(_Metallic, 0, [SCRange(0,1)], "Metallic", "") -SC_float(_Roughness, 0.5, [SCRange(0.002,1)], "Roughness", "") +SC_float(_Roughness, 0.5, [SCRange(0.089,1)], "Roughness", "") SC_uint(_UseUnityStandardDiffuseBrightness, 0, [SCToggle], "Unity Standard Diffuse Brightness", "") diff --git a/Shaders/PureBasePBR_properties.hlsl b/Shaders/PureBasePBR_properties.hlsl index 9b12577..c1c4921 100644 --- a/Shaders/PureBasePBR_properties.hlsl +++ b/Shaders/PureBasePBR_properties.hlsl @@ -18,5 +18,5 @@ SC_Texture2D(_NormalMap, "bump", [], "Normal Map", "") SC_SamplerState(sampler_NormalMap) SC_float(_NormalScale, 1, [SCRange(0,2)], "Normal Scale", "") SC_float(_Metallic, 0, [SCRange(0,1)], "Metallic", "") -SC_float(_Roughness, 0.5, [SCRange(0.002,1)], "Roughness", "") +SC_float(_Roughness, 0.5, [SCRange(0.089,1)], "Roughness", "") SC_uint(_UseUnityStandardDiffuseBrightness, 0, [SCToggle], "Unity Standard Diffuse Brightness", "") diff --git a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.ProductContracts.cs b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.ProductContracts.cs index c46aff2..ba10ce0 100644 --- a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.ProductContracts.cs +++ b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.ProductContracts.cs @@ -61,6 +61,10 @@ public sealed partial class PureBaseRenderingModeContractTests private const string UnityStandardDiffuseBrightnessPropertySourcePattern = @"SC_uint\s*\(\s*_UseUnityStandardDiffuseBrightness\s*,\s*0(?:\.0+)?\s*,\s*\[\s*SCToggle\s*\]\s*,\s*""Unity Standard Diffuse Brightness""\s*,\s*""""\s*\)"; + /// Identifies the exact public PBR and Hybrid perceptual-roughness declaration. + private const string PbrRoughnessPropertySource = + "SC_float(_Roughness, 0.5, [SCRange(0.089,1)], \"Roughness\", \"\")"; + /// Lists the public product shaders and their complete visible property ABI. private static readonly ProductContract[] Products = { @@ -377,6 +381,33 @@ private static void AssertProductShaderAbi(ProductContract product, Shader shade ); AssertStencilPropertyAbi(product, shader); AssertUnityStandardDiffuseBrightnessAbi(product, shader); + AssertPbrRoughnessAbi(product, shader); + } + + /// Requires the stable PBR and Hybrid roughness ABI and its byte-identical source mirror. + /// The product ABI under test. + /// The imported product shader. + private static void AssertPbrRoughnessAbi(ProductContract product, Shader shader) + { + bool supportsPbrRoughness = product.shaderName == "PureBase/PBR" + || product.shaderName == "PureBase/Hybrid"; + int roughnessIndex = shader.FindPropertyIndex("_Roughness"); + if (!supportsPbrRoughness) + { + Assert.That(roughnessIndex, Is.EqualTo(-1), product.shaderName + " must not expose _Roughness."); + return; + } + + Assert.That(roughnessIndex, Is.GreaterThan(shader.FindPropertyIndex("_Metallic"))); + Assert.That(roughnessIndex, Is.LessThan(shader.FindPropertyIndex("_UseUnityStandardDiffuseBrightness"))); + Assert.That(shader.GetPropertyType(roughnessIndex), Is.EqualTo(ShaderPropertyType.Float)); + Assert.That(shader.GetPropertyDefaultFloatValue(roughnessIndex), Is.EqualTo(0.5f)); + Assert.That(shader.GetPropertyDescription(roughnessIndex), Is.EqualTo("Roughness")); + CollectionAssert.AreEqual(new[] { "SCRange(0.089,1)" }, shader.GetPropertyAttributes(roughnessIndex)); + StringAssert.Contains(PbrRoughnessPropertySource, File.ReadAllText(product.propertySourcePath)); + string pbr = File.ReadAllText("Packages/jp.penguin.purebase/Shaders/PureBasePBR_properties.hlsl"); + string hybrid = File.ReadAllText("Packages/jp.penguin.purebase/Shaders/PureBaseHybrid_properties.hlsl"); + Assert.That(hybrid, Is.EqualTo(pbr), "PBR and Hybrid property declarations must remain byte-identical."); } /// Requires the PBR and Hybrid Integer toggle ABI while preserving its absence from Unlit and Toon. diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.OpenLitSourceContracts.cs b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.OpenLitSourceContracts.cs index cccdce0..4aa825a 100644 --- a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.OpenLitSourceContracts.cs +++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.OpenLitSourceContracts.cs @@ -110,6 +110,7 @@ public void ToonLightingOwnershipKeepsBinaryDirectTwoBandShaderCoreLightmapsAndF AssertToonHelperAndModelContracts(toon, helper); AssertBirpHostForwardAddAndLightmapContracts(host, shaderCoreLighting); AssertPbrAndHybridLightingOwnership(pbr, pbrBrdf, hybrid); + AssertPbrRoughnessFloorOwnership(pbr, pbrBrdf); AssertLightingPhaseOrder(host); OpenLitSourceContractAssertions.AssertOpenLitFallbackPrecedesNormalization(helper); } diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs index 258f31f..05c5101 100644 --- a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs +++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs @@ -506,6 +506,25 @@ private static void AssertPbrAndHybridLightingOwnership(string pbr, string pbrBr AssertPbrBrdfExclusionContracts(pbrBrdf); } + /// Requires one documented runtime roughness floor to feed direct, GI, and Meta BRDF construction. + /// The shared PBR model source. + /// The shared PBR BRDF source. + private static void AssertPbrRoughnessFloorOwnership(string pbr, string pbrBrdf) + { + Assert.That( + Regex.IsMatch(pbrBrdf, @"\bstatic\s+const\s+half\s+PureBasePbrPerceptualRoughnessFloor\s*=\s*0\.0890\s*;"), + Is.True, + "The shared PBR BRDF must define runtime constant PureBasePbrPerceptualRoughnessFloor with value 0.0890." + ); + Assert.That(Regex.IsMatch(pbrBrdf, @"///\s*[^\r\n]*0\.089[^\r\n]*\s*half\s+PureBasePbrClampPerceptualRoughness\s*\(\s*half\s+perceptualRoughness\s*\)", RegexOptions.Singleline), Is.True, "The 0.089 floor must be documented by the shared perceptual-roughness helper."); + Assert.That(Regex.IsMatch(pbrBrdf, @"PureBasePbrCreateBrdf\s*\([^)]*\)\s*\{[^}]*PureBasePbrClampPerceptualRoughness\s*\(\s*roughness\s*\)", RegexOptions.Singleline), Is.True, "PureBasePbrCreateBrdf must use the shared roughness clamp helper."); + Assert.That(Regex.IsMatch(pbr, @"SCModelCreateStandardSurface\s*\([^)]*\)\s*\{[^}]*PureBasePbrClampPerceptualRoughness\s*\(\s*_Roughness\s*\)", RegexOptions.Singleline), Is.True, "Unity Standard GI setup must use the shared roughness clamp helper."); + StringAssert.Contains("PureBasePbrCreateBrdf", File.ReadAllText("Packages/jp.penguin.purebase/Shaders/PureBasePBR.scshader")); + StringAssert.Contains("PureBasePbrCreateBrdf", File.ReadAllText("Packages/jp.penguin.purebase/Shaders/PureBaseHybrid.scshader")); + Assert.That(Regex.IsMatch(pbrBrdf, @"PureBasePbrCreateBrdf\s*\([^)]*\)\s*\{[^}]*0\.002", RegexOptions.Singleline), Is.False, "BRDF construction must not retain a local old roughness floor."); + Assert.That(Regex.IsMatch(pbr, @"SCModelCreateStandardSurface\s*\([^)]*\)\s*\{[^}]*0\.002", RegexOptions.Singleline), Is.False, "Unity Standard GI setup must not retain a local old roughness floor."); + } + /// Asserts that PBR and Hybrid retain their own lighting sources instead of consuming Toon lighting. /// The PBR model source. /// The Hybrid model source. diff --git a/Tests/Daily/Editor/PureBaseToonLightingContractTests.PbrRoughness.cs b/Tests/Daily/Editor/PureBaseToonLightingContractTests.PbrRoughness.cs new file mode 100644 index 0000000..2f7e0b3 --- /dev/null +++ b/Tests/Daily/Editor/PureBaseToonLightingContractTests.PbrRoughness.cs @@ -0,0 +1,113 @@ +/* + * Copyright 2026 Penguin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Defines direct and reflection GPU contracts for the PBR perceptual-roughness floor. + +using NUnit.Framework; +using UnityEngine; +using UnityEngine.Rendering; + +namespace PureBase.Tests.Daily +{ + /// Defines GPU contracts for the PBR and Hybrid perceptual-roughness floor. + public sealed partial class PureBaseToonLightingContractTests + { + /// Identifies the public lower bound shared by PBR and Hybrid perceptual roughness. + private const float PbrRoughnessFloor = 0.089f; + + /// Lists the PBR-family products that must share roughness behavior. + private static readonly string[] PbrRoughnessShaderNames = { "PureBase/PBR", "PureBase/Hybrid" }; + + /// Requires both direct forward paths to map below-floor metallic roughness to the public floor. + [Test] + public void PbrAndHybridDirectRoughnessFloorIsFiniteEquivalentAndDiscriminating() + { + using (var capture = new ToonLightingCaptureScope()) + { + foreach (string shaderName in PbrRoughnessShaderNames) + { + AssertDirectRoughnessCase(capture, shaderName, "ForwardBase", Vector3.back, "normal incidence", true); + AssertDirectRoughnessCase(capture, shaderName, "ForwardBase", new Vector3(0.98f, 0.0f, -0.2f), "grazing incidence", false); + AssertDirectRoughnessCase(capture, shaderName, "ForwardAdd", Vector3.back, "normal incidence", true); + AssertDirectRoughnessCase(capture, shaderName, "ForwardAdd", new Vector3(0.98f, 0.0f, -0.2f), "grazing incidence", false); + } + } + } + + /// Requires direct-light-free reflection to select the shared floor and a distinct higher roughness response. + [Test] + public void PbrAndHybridReflectionRoughnessFloorIsFiniteEquivalentAndDiscriminating() + { + using (var capture = new ToonLightingCaptureScope()) + { + foreach (string shaderName in PbrRoughnessShaderNames) + { + Color below = capture.RenderPbrRoughnessReflection(shaderName, 0.0f); + Color floor = capture.RenderPbrRoughnessReflection(shaderName, PbrRoughnessFloor); + Color above = capture.RenderPbrRoughnessReflection(shaderName, 0.25f); + AssertPbrRoughnessObservation(below, shaderName + " reflection below floor", true); + AssertPbrRoughnessObservation(floor, shaderName + " reflection floor", true); + AssertPbrRoughnessObservation(above, shaderName + " reflection above floor", true); + AssertColorWithin(floor, below, 0.01f, shaderName + " reflection below-floor equivalence"); + Assert.That(MaximumPbrRoughnessDifference(floor, above), Is.GreaterThan(0.01f), shaderName + " reflection must distinguish 0.25 roughness."); + } + } + } + + /// Ensures reflection capture restores caller-owned reflection globals after disposal. + [Test] + public void PbrRoughnessReflectionCaptureRestoresCallerState() + { + DefaultReflectionMode mode = RenderSettings.defaultReflectionMode; + Texture texture = RenderSettings.customReflectionTexture; + float intensity = RenderSettings.reflectionIntensity; + using (var capture = new ToonLightingCaptureScope()) + AssertPbrRoughnessObservation(capture.RenderPbrRoughnessReflection("PureBase/PBR", PbrRoughnessFloor), "reflection restoration control", true); + Assert.That(RenderSettings.defaultReflectionMode, Is.EqualTo(mode)); + Assert.That(RenderSettings.customReflectionTexture, Is.EqualTo(texture)); + Assert.That(RenderSettings.reflectionIntensity, Is.EqualTo(intensity)); + } + + /// Asserts one selected direct incidence and pass case with an optional nonblack control. + private static void AssertDirectRoughnessCase(ToonLightingCaptureScope capture, string shaderName, string passName, Vector3 normal, string incidence, bool requireNonBlack) + { + Color below = capture.RenderPbrRoughnessDirect(shaderName, passName, 0.0f, normal); + Color floor = capture.RenderPbrRoughnessDirect(shaderName, passName, PbrRoughnessFloor, normal); + Color above = capture.RenderPbrRoughnessDirect(shaderName, passName, 0.25f, normal); + string label = shaderName + " " + passName + " " + incidence; + AssertPbrRoughnessObservation(below, label + " below floor", false); + AssertPbrRoughnessObservation(floor, label + " floor", requireNonBlack); + AssertPbrRoughnessObservation(above, label + " above floor", requireNonBlack); + AssertColorWithin(floor, below, 0.01f, label + " below-floor equivalence"); + Assert.That(MaximumPbrRoughnessDifference(floor, above), Is.GreaterThan(0.005f), label + " must distinguish 0.25 roughness."); + } + + /// Requires a finite, nonnegative HDR observation and an optional nonblack control. + private static void AssertPbrRoughnessObservation(Color color, string label, bool requireNonBlack) + { + Assert.That(float.IsFinite(color.r) && float.IsFinite(color.g) && float.IsFinite(color.b), Is.True, label + " is non-finite."); + Assert.That(color.r >= 0.0f && color.g >= 0.0f && color.b >= 0.0f, Is.True, label + " is negative."); + if (requireNonBlack) + Assert.That(color.maxColorComponent, Is.GreaterThan(0.001f), label + " is black or nondiscriminating."); + } + + /// Calculates the largest absolute RGB difference between two roughness observations. + private static float MaximumPbrRoughnessDifference(Color first, Color second) + { + return Mathf.Max(Mathf.Abs(first.r - second.r), Mathf.Abs(first.g - second.g), Mathf.Abs(first.b - second.b)); + } + } +} diff --git a/Tests/Daily/Editor/PureBaseToonLightingContractTests.PbrRoughness.cs.meta b/Tests/Daily/Editor/PureBaseToonLightingContractTests.PbrRoughness.cs.meta new file mode 100644 index 0000000..4449ef0 --- /dev/null +++ b/Tests/Daily/Editor/PureBaseToonLightingContractTests.PbrRoughness.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a1181a91b5a74c1fa3f4034510a5cd31 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Daily/Editor/PureBaseToonLightingContractTests.PbrRoughnessRuntime.cs b/Tests/Daily/Editor/PureBaseToonLightingContractTests.PbrRoughnessRuntime.cs new file mode 100644 index 0000000..67fd5fe --- /dev/null +++ b/Tests/Daily/Editor/PureBaseToonLightingContractTests.PbrRoughnessRuntime.cs @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Penguin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Owns direct and reflection fixture setup for PBR perceptual-roughness GPU observations. + +using UnityEngine; +using UnityEngine.Rendering; + +namespace PureBase.Tests.Daily +{ + /// Provides roughness-specific extensions to the isolated BIRP capture scope. + public sealed partial class PureBaseToonLightingContractTests + { + /// Owns direct and reflection capture additions for the PBR roughness floor contracts. + private partial class ToonLightingCaptureRuntimeScope + { + /// Renders a low-radiance metallic direct observation through an explicit forward pass. + public Color RenderPbrRoughnessDirect(string shaderName, string passName, float roughness, Vector3 normal) + { + renderer.lightProbeUsage = LightProbeUsage.Off; + renderer.reflectionProbeUsage = ReflectionProbeUsage.Off; + Material material = CreatePbrRoughnessMaterial(shaderName, passName, roughness); + Vector3 lightDirection = Vector3.Reflect(Vector3.forward, normal.normalized).normalized; + if (passName == "ForwardAdd") + return RenderLightDifference(material, CreateLightCaptureRequest(normal, new Vector4(0.015f, 0.012f, 0.009f, 1.0f), new Vector4(lightDirection.x, lightDirection.y, lightDirection.z, 1.0f), ShCoefficients.Zero, LightType.Point, 4.0f)); + return RenderWithLights(material, CreateDirectionalLightCaptureRequest(normal, new Vector4(0.015f, 0.012f, 0.009f, 1.0f), new Vector4(lightDirection.x, lightDirection.y, lightDirection.z, 0.0f), ShCoefficients.Zero)); + } + + /// Renders direct-light-free metallic reflection from fixture-owned mip-distinct cubemap data. + public Color RenderPbrRoughnessReflection(string shaderName, float roughness) + { + renderer.lightProbeUsage = LightProbeUsage.Off; + renderer.reflectionProbeUsage = ReflectionProbeUsage.BlendProbesAndSkybox; + ConfigurePbrRoughnessReflection(); + Material material = CreatePbrRoughnessMaterial(shaderName, "ForwardBase", roughness); + return RenderWithLights(material, CreateDirectionalLightCaptureRequest(Vector3.back, Vector4.zero, new Vector4(0.0f, 0.0f, -1.0f, 0.0f), ShCoefficients.Zero)); + } + + /// Creates a high-albedo metallic PBR-family material without a direct-diffuse contribution. + private Material CreatePbrRoughnessMaterial(string shaderName, string passName, float roughness) + { + Material material = CreateProductMaterial(shaderName, passName, 1.0f); + material.SetTexture("_BaseTexture", Texture2D.whiteTexture); + material.SetColor("_BaseColor", Color.white); + material.SetFloat("_Roughness", roughness); + material.SetInteger("_UseUnityStandardDiffuseBrightness", 0); + return material; + } + + /// Installs a transient custom reflection cubemap with distinct finite colors in every mip level. + private void ConfigurePbrRoughnessReflection() + { + var cubemap = new Cubemap(8, TextureFormat.RGBAFloat, true) { hideFlags = HideFlags.HideAndDontSave }; + pbrBrightnessResources.Add(cubemap); + for (int mip = 0; mip < cubemap.mipmapCount; mip++) + { + Color color = new Color(0.12f + (mip * 0.21f), 0.08f + (mip * 0.13f), 0.04f + (mip * 0.07f), 1.0f); + int size = Mathf.Max(1, cubemap.width >> mip); + Color[] pixels = CreatePbrRoughnessMipPixels(size, color); + foreach (CubemapFace face in new[] { CubemapFace.PositiveX, CubemapFace.NegativeX, CubemapFace.PositiveY, CubemapFace.NegativeY, CubemapFace.PositiveZ, CubemapFace.NegativeZ }) + cubemap.SetPixels(pixels, face, mip); + } + + cubemap.Apply(false, true); + RenderSettings.defaultReflectionMode = DefaultReflectionMode.Custom; + RenderSettings.customReflectionTexture = cubemap; + RenderSettings.reflectionIntensity = 1.0f; + } + + /// Creates the uniformly colored pixels assigned to one owned cubemap mip level. + private static Color[] CreatePbrRoughnessMipPixels(int size, Color color) + { + var pixels = new Color[size * size]; + for (int index = 0; index < pixels.Length; index++) + pixels[index] = color; + return pixels; + } + } + } +} diff --git a/Tests/Daily/Editor/PureBaseToonLightingContractTests.PbrRoughnessRuntime.cs.meta b/Tests/Daily/Editor/PureBaseToonLightingContractTests.PbrRoughnessRuntime.cs.meta new file mode 100644 index 0000000..f4aa240 --- /dev/null +++ b/Tests/Daily/Editor/PureBaseToonLightingContractTests.PbrRoughnessRuntime.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2f2b85140bca4d1f8c3bbf7edbc7ab68 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.PbrRoughness.cs b/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.PbrRoughness.cs new file mode 100644 index 0000000..9de3199 --- /dev/null +++ b/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.PbrRoughness.cs @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Penguin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Defines actual Meta-pass roughness-floor contracts using the existing validation-scene capture helpers. + +using NUnit.Framework; +using UnityEngine; + +namespace PureBase.Tests.Daily +{ + /// Provides roughness-floor Meta capture contracts without enlarging the validation-scene fixture. + public sealed partial class PureBaseValidationSceneRegressionTests + { + /// Requires below-floor Meta output to match the public floor while retaining an above-floor discriminator. + [Test] + public void PbrAndHybridMetaRoughnessFloorMatchesFormulaAndExactFloor() + { + WithPbrAndHybridMaterials(materials => + { + foreach (Material material in materials) + AssertPbrRoughnessMetaContract(material); + }); + } + + /// Captures below-floor, exact-floor, and above-floor Meta output with formula and toggle controls. + private static void AssertPbrRoughnessMetaContract(Material sourceMaterial) + { + Color albedo = new Color(0.92f, 0.61f, 0.28f, 1.0f); + MetaCaptureReadback exact = CapturePbrRoughnessMeta(sourceMaterial, albedo, 0.089f, 0); + MetaCaptureReadback above = CapturePbrRoughnessMeta(sourceMaterial, albedo, 0.25f, 0); + MetaCaptureReadback below = CapturePbrRoughnessMeta(sourceMaterial, albedo, 0.0f, 0); + AssertMetaReadback(exact, EvaluateExpectedMetaAlbedo(albedo, 0.9f, 0.089f, true), sourceMaterial.shader.name + " Meta exact floor"); + AssertMetaReadback(above, EvaluateExpectedMetaAlbedo(albedo, 0.9f, 0.25f, true), sourceMaterial.shader.name + " Meta above floor"); + AssertMetaReadback(below, EvaluateExpectedMetaAlbedo(albedo, 0.9f, 0.0f, true), sourceMaterial.shader.name + " Meta below floor"); + Assert.That(MaximumAbsoluteRgbDifference(below.meanColor, exact.meanColor), Is.LessThanOrEqualTo(MetaCaptureTolerance), sourceMaterial.shader.name + " Meta below-floor output must equal the exact floor."); + Assert.That(MaximumAbsoluteRgbDifference(exact.meanColor, above.meanColor), Is.GreaterThan(0.002f), sourceMaterial.shader.name + " Meta must distinguish 0.25 roughness."); + AssertPbrRoughnessMetaToggleInvariant(sourceMaterial, albedo, exact); + } + + /// Captures one fully covered finite metallic Meta readback at the requested stored roughness. + private static MetaCaptureReadback CapturePbrRoughnessMeta(Material sourceMaterial, Color albedo, float roughness, int toggle) + { + return RenderMetaCapture(sourceMaterial, material => + { + ConfigureMetaMaterial(material, albedo, 0.9f, roughness, 0.0f); + material.SetInteger("_UseUnityStandardDiffuseBrightness", toggle); + }, false, null, MetaAlbedoFragmentControl); + } + + /// Requires the direct-only brightness toggle to leave the exact-floor Meta result unchanged. + private static void AssertPbrRoughnessMetaToggleInvariant(Material sourceMaterial, Color albedo, MetaCaptureReadback exact) + { + MetaCaptureReadback enabled = CapturePbrRoughnessMeta(sourceMaterial, albedo, 0.089f, 1); + AssertMetaReadback(enabled, EvaluateExpectedMetaAlbedo(albedo, 0.9f, 0.089f, true), sourceMaterial.shader.name + " Meta exact-floor toggle"); + Assert.That(MaximumAbsoluteRgbDifference(exact.meanColor, enabled.meanColor), Is.LessThanOrEqualTo(MetaCaptureTolerance), sourceMaterial.shader.name + " Meta must ignore the direct-diffuse brightness toggle."); + } + } +} diff --git a/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.PbrRoughness.cs.meta b/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.PbrRoughness.cs.meta new file mode 100644 index 0000000..4da302f --- /dev/null +++ b/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.PbrRoughness.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9aef0d6a00e0470cad2893d1a53d0a09 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.cs b/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.cs index a325e4c..09afac4 100644 --- a/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.cs +++ b/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.cs @@ -29,7 +29,7 @@ namespace PureBase.Tests.Daily { /// Validates the committed BIRP validation scene without baking or saving persistent assets. - public sealed class PureBaseValidationSceneRegressionTests + public sealed partial class PureBaseValidationSceneRegressionTests { /// Identifies the canonical validation scene. public const string ScenePath = @@ -2801,7 +2801,7 @@ bool squareRoughness 1.0f ); float saturatedMetallic = Mathf.Clamp01(metallic); - float perceptualRoughness = Mathf.Clamp(roughness, 0.002f, 1.0f); + float perceptualRoughness = Mathf.Clamp(roughness, 0.089f, 1.0f); float actualRoughness = squareRoughness ? perceptualRoughness * perceptualRoughness : perceptualRoughness; diff --git a/Tests/Regeneration/Editor/PureBaseRoughnessLightmapBakeTests.cs b/Tests/Regeneration/Editor/PureBaseRoughnessLightmapBakeTests.cs new file mode 100644 index 0000000..9283d8e --- /dev/null +++ b/Tests/Regeneration/Editor/PureBaseRoughnessLightmapBakeTests.cs @@ -0,0 +1,439 @@ +/* + * Copyright 2026 Penguin + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Verifies the PBR roughness floor through one disposable Progressive CPU lightmap bake. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.SceneManagement; + +namespace PureBase.Tests.Regeneration +{ + /// Verifies stored PBR and Hybrid roughness values through one isolated real lightmap bake. + public sealed class PureBaseRoughnessLightmapBakeTests + { + /// Identifies the temporary asset root that this test creates and removes as one transaction. + private const string TemporaryRoot = "Assets/Artifacts/PureBaseRoughnessBake"; + + /// Identifies the validated read-only Progressive CPU settings used by the disposable scene. + private const string LightingSettingsPath = "Packages/jp.penguin.purebase/Tests/Fixtures/Lighting/PureBaseValidationLightingSettings.lighting"; + + /// Identifies the isolated layer used by source surfaces that must not reach the readback camera. + private const int SourceLayer = 30; + + /// Identifies the isolated layer used by baked-lightmap-only receiver surfaces. + private const int ReceiverLayer = 31; + + /// Allows calibrated spatial and atlas variation when comparing equivalent baked floor observations. + private const float BakeFloorEquivalenceTolerance = 0.005f; + + /// Requires one finite real bake and only equates below-floor and exact-floor observations. + [Test] + public void PbrAndHybridRoughnessFloorMatchesAfterOneProgressiveCpuBake() + { + SceneSetup[] setup = EditorSceneManager.GetSceneManagerSetup(); + LightingState lightingState = new LightingState(); + Scene owner = default; + Scene scene = default; + try + { + ResetTemporaryRoot(); + owner = CreateBakeOwnerScene(); + scene = CreateBakeScene(); + List cells = CreateBakedCells(scene); + Assert.That(Lightmapping.Bake(), Is.True, "The disposable Progressive CPU bake did not start."); + var observations = new Dictionary(); + Camera camera = CreateReadbackCamera(scene); + try + { + foreach (BakedCell cell in cells) + observations.Add(cell.key, ReadBakedReceiver(camera, cell.renderer)); + } + finally + { + UnityEngine.Object.DestroyImmediate(camera.gameObject); + } + + AssertBakedCells(observations); + } + finally + { + RestoreBakeState(setup, lightingState, owner, scene); + } + } + + /// Deletes stale disposable bake artifacts and creates the owned asset root. + private static void ResetTemporaryRoot() + { + AssetDatabase.DeleteAsset(TemporaryRoot); + if (!AssetDatabase.IsValidFolder("Assets/Artifacts")) + AssetDatabase.CreateFolder("Assets", "Artifacts"); + AssetDatabase.CreateFolder("Assets/Artifacts", "PureBaseRoughnessBake"); + } + + /// Creates and saves the persistent owner required before opening the additive disposable bake scene. + private static Scene CreateBakeOwnerScene() + { + Scene owner = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + Assert.That(EditorSceneManager.SaveScene(owner, TemporaryRoot + "/Owner.unity"), Is.True); + return owner; + } + + /// Creates, saves, and configures the additive disposable scene with read-only Progressive CPU settings. + private static Scene CreateBakeScene() + { + Scene scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); + EditorSceneManager.SetActiveScene(scene); + LightingSettings settings = AssetDatabase.LoadAssetAtPath(LightingSettingsPath); + Assert.That(settings, Is.Not.Null, "The validated Progressive CPU Lighting Settings asset is unavailable."); + Assert.That(settings.lightmapper, Is.EqualTo(LightingSettings.Lightmapper.ProgressiveCPU)); + Lightmapping.SetLightingSettingsForScene(scene, settings); + RenderSettings.ambientMode = AmbientMode.Flat; + RenderSettings.ambientLight = Color.black; + RenderSettings.reflectionIntensity = 0.0f; + RenderSettings.fog = false; + CreateBakedLight(scene); + Assert.That(EditorSceneManager.SaveScene(scene, TemporaryRoot + "/RoughnessBake.unity"), Is.True); + return scene; + } + + /// Creates the single baked directional source for every isolated PBR-family receiver cell. + private static void CreateBakedLight(Scene scene) + { + var lightObject = new GameObject("PureBase Roughness Bake Light"); + SceneManager.MoveGameObjectToScene(lightObject, scene); + Light light = lightObject.AddComponent(); + light.type = LightType.Directional; + light.lightmapBakeType = LightmapBakeType.Baked; + light.color = new Color(1.0f, 0.92f, 0.78f, 1.0f); + light.intensity = 8.0f; + light.transform.rotation = Quaternion.Euler(90.0f, 0.0f, 0.0f); + } + + /// Creates six spatially isolated PBR and Hybrid source-and-receiver cells at the requested stored roughness values. + private static List CreateBakedCells(Scene scene) + { + var cells = new List(); + string[] shaders = { "PureBase/PBR", "PureBase/Hybrid" }; + float[] roughnesses = { 0.0f, 0.089f, 0.25f }; + for (int shaderIndex = 0; shaderIndex < shaders.Length; shaderIndex++) + { + for (int roughnessIndex = 0; roughnessIndex < roughnesses.Length; roughnessIndex++) + cells.Add(CreateBakedCell(scene, shaders[shaderIndex], roughnesses[roughnessIndex], shaderIndex, roughnessIndex)); + } + + return cells; + } + + /// Creates a lit product source and an indirectly lit matched receiver that can be rendered from its baked lightmap. + private static BakedCell CreateBakedCell(Scene scene, string shaderName, float roughness, int shaderIndex, int roughnessIndex) + { + Shader shader = Shader.Find(shaderName); + Assert.That(shader, Is.Not.Null, "Missing product shader '" + shaderName + "'."); + var sourceMaterial = new Material(shader); + sourceMaterial.SetTexture("_BaseTexture", Texture2D.whiteTexture); + sourceMaterial.SetColor("_BaseColor", Color.white); + sourceMaterial.SetFloat("_Metallic", 0.9f); + sourceMaterial.SetFloat("_Roughness", roughness); + string key = shaderName + " " + roughness.ToString("0.000", System.Globalization.CultureInfo.InvariantCulture); + string materialPrefix = TemporaryRoot + "/" + shader.name.Replace("/", "-") + "-" + roughnessIndex; + AssetDatabase.CreateAsset(sourceMaterial, materialPrefix + "-source.mat"); + var receiverMaterial = new Material(Shader.Find("Standard")); + receiverMaterial.color = Color.white; + AssetDatabase.CreateAsset(receiverMaterial, materialPrefix + "-receiver.mat"); + Vector3 sourcePosition = new Vector3((roughnessIndex - 1) * 10.0f, 0.0f, shaderIndex * 12.0f); + CreateBakedSource(scene, sourcePosition, sourceMaterial, key); + MeshRenderer receiver = CreateBakedReceiver(scene, sourcePosition, receiverMaterial, key); + return new BakedCell(key, receiver); + } + + /// Creates the horizontal product source whose Meta albedo feeds a nearby receiver through bounced baked light. + private static void CreateBakedSource(Scene scene, Vector3 position, Material material, string key) + { + GameObject source = GameObject.CreatePrimitive(PrimitiveType.Plane); + source.name = "PureBase Roughness Source " + key; + source.transform.position = position; + source.transform.localScale = Vector3.one * 0.35f; + source.layer = SourceLayer; + SceneManager.MoveGameObjectToScene(source, scene); + MeshRenderer renderer = source.GetComponent(); + renderer.sharedMaterial = material; + renderer.receiveGI = ReceiveGI.Lightmaps; + GameObjectUtility.SetStaticEditorFlags(source, StaticEditorFlags.ContributeGI); + } + + /// Creates a vertical Standard receiver that has no direct contribution from the downward baked light. + private static MeshRenderer CreateBakedReceiver(Scene scene, Vector3 sourcePosition, Material material, string key) + { + GameObject receiver = GameObject.CreatePrimitive(PrimitiveType.Plane); + receiver.name = "PureBase Roughness Receiver " + key; + receiver.transform.position = sourcePosition + new Vector3(0.0f, 1.5f, 1.5f); + receiver.transform.rotation = Quaternion.Euler(-90.0f, 0.0f, 0.0f); + receiver.transform.localScale = Vector3.one * 0.35f; + receiver.layer = ReceiverLayer; + SceneManager.MoveGameObjectToScene(receiver, scene); + MeshRenderer renderer = receiver.GetComponent(); + renderer.sharedMaterial = material; + renderer.receiveGI = ReceiveGI.Lightmaps; + GameObjectUtility.SetStaticEditorFlags(receiver, StaticEditorFlags.ContributeGI); + return renderer; + } + + /// Creates a manually rendered camera that can see only the baked receiver layer. + private static Camera CreateReadbackCamera(Scene scene) + { + var cameraObject = new GameObject("PureBase Roughness Bake Readback Camera"); + SceneManager.MoveGameObjectToScene(cameraObject, scene); + Camera camera = cameraObject.AddComponent(); + camera.enabled = false; + camera.orthographic = true; + camera.orthographicSize = 2.0f; + camera.nearClipPlane = 0.01f; + camera.farClipPlane = 10.0f; + camera.allowHDR = true; + camera.allowMSAA = false; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = Color.black; + camera.cullingMask = 1 << ReceiverLayer; + camera.useOcclusionCulling = false; + return camera; + } + + /// Renders the centre of a receiver in baked-lightmap-only mode through a transient HDR readback texture. + private static Color ReadBakedReceiver(Camera camera, MeshRenderer renderer) + { + Assert.That(renderer.lightmapIndex, Is.GreaterThanOrEqualTo(0), renderer.name + " has no baked lightmap index."); + RenderTexture active = RenderTexture.active; + var target = RenderTexture.GetTemporary(64, 64, 0, RenderTextureFormat.ARGBFloat, RenderTextureReadWrite.Linear); + var readback = new Texture2D(64, 64, TextureFormat.RGBAFloat, false, true); + try + { + Vector3 normal = renderer.transform.up; + camera.transform.position = renderer.bounds.center + (normal * 2.0f); + camera.transform.rotation = Quaternion.LookRotation(-normal, Vector3.up); + camera.targetTexture = target; + camera.Render(); + RenderTexture.active = target; + readback.ReadPixels(new Rect(0, 0, 64, 64), 0, 0); + readback.Apply(false, false); + return readback.GetPixel(32, 32); + } + finally + { + camera.targetTexture = null; + RenderTexture.active = active; + RenderTexture.ReleaseTemporary(target); + UnityEngine.Object.DestroyImmediate(readback); + } + } + + /// Requires finite nonnegative nonblack data and below-floor equivalence for both baked product receivers. + private static void AssertBakedCells(IReadOnlyDictionary observations) + { + WriteBakedEvidence(observations, "PureBase/PBR"); + WriteBakedEvidence(observations, "PureBase/Hybrid"); + var failures = new List(); + foreach (KeyValuePair observation in observations) + AddBakedObservationFailures(failures, observation.Value, observation.Key); + AddBakedFloorEquivalenceFailure(failures, observations, "PureBase/PBR"); + AddBakedFloorEquivalenceFailure(failures, observations, "PureBase/Hybrid"); + AddBakedRoughnessDiscriminationFailure(failures, observations, "PureBase/PBR"); + AddBakedRoughnessDiscriminationFailure(failures, observations, "PureBase/Hybrid"); + Assert.That(failures.Count, Is.EqualTo(0), string.Join(Environment.NewLine, failures)); + } + + /// Requires the two stored values that must share the new runtime floor to match after baking. + private static void AddBakedFloorEquivalenceFailure(List failures, IReadOnlyDictionary observations, string shaderName) + { + Color below = observations[shaderName + " 0.000"]; + Color exact = observations[shaderName + " 0.089"]; + if (MaximumDifference(below, exact) > BakeFloorEquivalenceTolerance) + failures.Add(DescribeBakedDeltas(observations, shaderName) + ". " + shaderName + " baked below-floor output must equal exact-floor output."); + } + + /// Requires the above-floor cell to prove that the sampled baked receiver is roughness-sensitive. + private static void AddBakedRoughnessDiscriminationFailure(List failures, IReadOnlyDictionary observations, string shaderName) + { + Color exact = observations[shaderName + " 0.089"]; + Color above = observations[shaderName + " 0.250"]; + if (MaximumDifference(exact, above) <= 0.0005f) + failures.Add(DescribeBakedDeltas(observations, shaderName) + ". " + shaderName + " baked 0.25 output must differ from exact-floor output."); + } + + /// Describes both required roughness deltas with full single-precision fidelity for assertion failures. + private static string DescribeBakedDeltas(IReadOnlyDictionary observations, string shaderName) + { + float floorDifference = MaximumDifference(observations[shaderName + " 0.000"], observations[shaderName + " 0.089"]); + float discriminationDifference = MaximumDifference(observations[shaderName + " 0.089"], observations[shaderName + " 0.250"]); + return shaderName + " baked deltas: stored 0.000/exact 0.089 = " + floorDifference.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + "; exact 0.089/above 0.250 = " + discriminationDifference.ToString("R", System.Globalization.CultureInfo.InvariantCulture); + } + + /// Adds failures for one sampled baked observation that lacks valid nonblack HDR evidence. + private static void AddBakedObservationFailures(List failures, Color color, string label) + { + if (!float.IsFinite(color.r) || !float.IsFinite(color.g) || !float.IsFinite(color.b)) + failures.Add(label + " baked lightmap is non-finite."); + if (color.r < 0.0f || color.g < 0.0f || color.b < 0.0f) + failures.Add(label + " baked lightmap is negative."); + if (color.maxColorComponent <= 0.001f) + failures.Add(label + " baked lightmap is black."); + } + + /// Writes the stored source roughness observations and both required deltas to the focused test output. + private static void WriteBakedEvidence(IReadOnlyDictionary observations, string shaderName) + { + WriteBakedColor(observations, shaderName, "0.000"); + WriteBakedColor(observations, shaderName, "0.089"); + WriteBakedColor(observations, shaderName, "0.250"); + WriteBakedDifference(observations, shaderName, "0.000", "0.089"); + WriteBakedDifference(observations, shaderName, "0.089", "0.250"); + } + + /// Writes one baked receiver RGB observation with an invariant decimal representation. + private static void WriteBakedColor(IReadOnlyDictionary observations, string shaderName, string roughness) + { + Color color = observations[shaderName + " " + roughness]; + TestContext.WriteLine(shaderName + " receiver " + roughness + " RGB = (" + color.r.ToString("0.000000", System.Globalization.CultureInfo.InvariantCulture) + ", " + color.g.ToString("0.000000", System.Globalization.CultureInfo.InvariantCulture) + ", " + color.b.ToString("0.000000", System.Globalization.CultureInfo.InvariantCulture) + ")"); + } + + /// Writes one maximum RGB delta between two stored source roughness observations. + private static void WriteBakedDifference(IReadOnlyDictionary observations, string shaderName, string firstRoughness, string secondRoughness) + { + float difference = MaximumDifference(observations[shaderName + " " + firstRoughness], observations[shaderName + " " + secondRoughness]); + TestContext.WriteLine(shaderName + " receiver delta " + firstRoughness + "/" + secondRoughness + " = " + difference.ToString("R", System.Globalization.CultureInfo.InvariantCulture)); + } + + /// Calculates the maximum absolute RGB difference used by the bake-specific equivalence tolerance. + private static float MaximumDifference(Color first, Color second) + { + return Mathf.Max(Mathf.Abs(first.r - second.r), Mathf.Abs(first.g - second.g), Mathf.Abs(first.b - second.b)); + } + + /// Restores all scene, lightmap, lighting, renderer, and temporary asset state in failure-safe cleanup order. + private static void RestoreBakeState(SceneSetup[] setup, LightingState lightingState, Scene owner, Scene scene) + { + try + { + if (scene.IsValid() && scene.isLoaded) + EditorSceneManager.CloseScene(scene, true); + } + finally + { + try + { + lightingState.Restore(); + } + finally + { + try + { + EditorSceneManager.RestoreSceneManagerSetup(setup); + CloseOwnerAfterRestoringSetup(owner); + } + finally + { + try + { + AssetDatabase.DeleteAsset(TemporaryRoot); + } + finally + { + AssetDatabase.Refresh(); + } + } + } + } + } + + /// Closes a residual owner only after original setup restoration guarantees another loaded scene. + private static void CloseOwnerAfterRestoringSetup(Scene owner) + { + if (owner.IsValid() && owner.isLoaded && SceneManager.sceneCount > 1) + EditorSceneManager.CloseScene(owner, true); + } + + /// Pairs one receiver renderer with its stable product and stored-roughness key. + private sealed class BakedCell + { + /// Initializes one baked receiver cell. + public BakedCell(string key, MeshRenderer renderer) + { + this.key = key; + this.renderer = renderer; + } + + /// Gets the stable observation key for the receiver. + public string key { get; } + + /// Gets the receiver that receives and references the baked lightmap. + public MeshRenderer renderer { get; } + } + + /// Captures every global lighting value changed by the disposable bake before creating its owner scene. + private sealed class LightingState + { + /// Initializes the captured global lightmap and rendering state. + public LightingState() + { + lightmaps = LightmapSettings.lightmaps; + lightmapsMode = LightmapSettings.lightmapsMode; + lightingData = Lightmapping.lightingDataAsset; + ambientMode = RenderSettings.ambientMode; + ambientLight = RenderSettings.ambientLight; + reflectionIntensity = RenderSettings.reflectionIntensity; + fog = RenderSettings.fog; + } + + /// Restores every global lightmap and rendering value modified by the disposable bake. + public void Restore() + { + LightmapSettings.lightmaps = lightmaps; + LightmapSettings.lightmapsMode = lightmapsMode; + Lightmapping.lightingDataAsset = lightingData; + RenderSettings.ambientMode = ambientMode; + RenderSettings.ambientLight = ambientLight; + RenderSettings.reflectionIntensity = reflectionIntensity; + RenderSettings.fog = fog; + } + + /// Stores the original lightmap textures. + private readonly LightmapData[] lightmaps; + + /// Stores the original lightmap sampling mode. + private readonly LightmapsMode lightmapsMode; + + /// Stores the original baked lighting data asset. + private readonly LightingDataAsset lightingData; + + /// Stores the original ambient lighting mode. + private readonly AmbientMode ambientMode; + + /// Stores the original ambient lighting color. + private readonly Color ambientLight; + + /// Stores the original reflection contribution. + private readonly float reflectionIntensity; + + /// Stores the original fog enabled state. + private readonly bool fog; + } + } +} diff --git a/Tests/Regeneration/Editor/PureBaseRoughnessLightmapBakeTests.cs.meta b/Tests/Regeneration/Editor/PureBaseRoughnessLightmapBakeTests.cs.meta new file mode 100644 index 0000000..7d56063 --- /dev/null +++ b/Tests/Regeneration/Editor/PureBaseRoughnessLightmapBakeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6abdc4285dfd41ce9be221a5a4ac23b1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Release/ConsumerProject/Assets/Editor/PureBaseConsumerReleaseTests.cs b/Tests/Release/ConsumerProject/Assets/Editor/PureBaseConsumerReleaseTests.cs index b4eedee..6e75f07 100644 --- a/Tests/Release/ConsumerProject/Assets/Editor/PureBaseConsumerReleaseTests.cs +++ b/Tests/Release/ConsumerProject/Assets/Editor/PureBaseConsumerReleaseTests.cs @@ -77,6 +77,15 @@ public sealed class PureBaseConsumerModuleFreeImportTests private const string UnityStandardDiffuseBrightnessPropertyLabel = "Unity Standard Diffuse Brightness"; + /// Identifies the PBR and Hybrid roughness property. + private const string RoughnessPropertyName = "_Roughness"; + + /// Defines the exact PBR and Hybrid roughness property label. + private const string RoughnessPropertyLabel = "Roughness"; + + /// Defines the exact PBR and Hybrid roughness drawer attribute. + private const string RoughnessPropertyAttribute = "SCRange(0.089,1)"; + /// Imports all runner-configured module-free products and checks their public and generated contracts. [Test] public void ModuleFreeProductsCompileWithConfiguredPassPropertyAndSourceContracts() @@ -116,6 +125,7 @@ public void ModuleFreeProductsCompileWithConfiguredPassPropertyAndSourceContract $"Module-free consumer run '{contract.runLabel}' changed visible property order for '{product.shaderName}'." ); AssertStencilPropertyMetadata(contract, product, shader); + AssertRoughnessMetadata(contract, product, shader); AssertUnityStandardDiffuseBrightnessMetadata(contract, product, shader); string source = ConsumerValidationSupport.LoadGeneratedSource( product, @@ -132,6 +142,54 @@ public void ModuleFreeProductsCompileWithConfiguredPassPropertyAndSourceContract } } + /// Checks the imported PBR and Hybrid Roughness property metadata. + /// The runner-provided module-free contract. + /// The imported product contract. + /// The cold-imported product shader. + private static void AssertRoughnessMetadata( + ConsumerValidationContract contract, + ConsumerProductContract product, + Shader shader + ) + { + int propertyIndex = shader.FindPropertyIndex(RoughnessPropertyName); + if (!IsUnityStandardDiffuseBrightnessProduct(product.shaderName)) + { + Assert.That( + propertyIndex, + Is.EqualTo(-1), + $"Consumer run '{contract.runLabel}' product '{product.shaderName}' must not expose '{RoughnessPropertyName}'." + ); + return; + } + + Assert.That( + propertyIndex, + Is.GreaterThanOrEqualTo(0), + $"Consumer run '{contract.runLabel}' product '{product.shaderName}' must expose '{RoughnessPropertyName}'." + ); + Assert.That( + shader.GetPropertyType(propertyIndex), + Is.EqualTo(ShaderPropertyType.Float), + $"Consumer run '{contract.runLabel}' product '{product.shaderName}' property '{RoughnessPropertyName}' must be a Float." + ); + Assert.That( + shader.GetPropertyDefaultFloatValue(propertyIndex), + Is.EqualTo(0.5f), + $"Consumer run '{contract.runLabel}' product '{product.shaderName}' property '{RoughnessPropertyName}' default value." + ); + Assert.That( + shader.GetPropertyDescription(propertyIndex), + Is.EqualTo(RoughnessPropertyLabel), + $"Consumer run '{contract.runLabel}' product '{product.shaderName}' property '{RoughnessPropertyName}' label." + ); + CollectionAssert.AreEqual( + new[] { RoughnessPropertyAttribute }, + shader.GetPropertyAttributes(propertyIndex), + $"Consumer run '{contract.runLabel}' product '{product.shaderName}' property '{RoughnessPropertyName}' must expose exactly the SCRange attribute." + ); + } + /// Checks the cold-imported PBR and Hybrid direct-diffuse brightness metadata. /// The runner-provided module-free contract. /// The imported product contract. @@ -190,7 +248,7 @@ private static void AssertUnityStandardDiffuseBrightnessOrder( int propertyIndex ) { - int roughnessIndex = shader.FindPropertyIndex("_Roughness"); + int roughnessIndex = shader.FindPropertyIndex(RoughnessPropertyName); Assert.That( roughnessIndex, Is.GreaterThanOrEqualTo(0), diff --git a/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1 b/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1 index b4802d7..13fa408 100644 --- a/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1 +++ b/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1 @@ -74,11 +74,18 @@ Describe 'Release validation runner contracts' { } } $expectedPassNames = @($expectedPassContracts.Keys) + $expectedSourceFragments = [ordered]@{ + 'PureBase/Unlit' = @('#pragma shader_feature_local _ PUREBASE_RENDERING_OPAQUE PUREBASE_RENDERING_TRANSPARENT') + 'PureBase/Toon' = @('#pragma shader_feature_local _ PUREBASE_RENDERING_OPAQUE PUREBASE_RENDERING_TRANSPARENT') + 'PureBase/PBR' = @('#pragma shader_feature_local _ PUREBASE_RENDERING_OPAQUE PUREBASE_RENDERING_TRANSPARENT', '_UseUnityStandardDiffuseBrightness', 'SC_float(_Roughness, 0.5, [SCRange(0.089,1)], "Roughness", "")') + 'PureBase/Hybrid' = @('#pragma shader_feature_local _ PUREBASE_RENDERING_OPAQUE PUREBASE_RENDERING_TRANSPARENT', '_UseUnityStandardDiffuseBrightness', 'SC_float(_Roughness, 0.5, [SCRange(0.089,1)], "Roughness", "")') + } foreach ($shaderName in $expectedVisibleProperties.Keys) { $product = New-ProductContract -ShaderName $shaderName $product.shaderName | Should -BeExactly $shaderName (@($product.expectedVisiblePropertyNames) -join "`n") | Should -BeExactly ($expectedVisibleProperties[$shaderName] -join "`n") + (@($product.requiredSourceFragments) -join "`n") | Should -BeExactly ($expectedSourceFragments[$shaderName] -join "`n") (@($product.expectedPassNames) -join "`n") | Should -BeExactly ($expectedPassNames -join "`n") @($product.passContracts).Count | Should -Be $expectedPassNames.Count diff --git a/Tests/Release/Run-PureBaseReleaseValidation.ps1 b/Tests/Release/Run-PureBaseReleaseValidation.ps1 index f9b3b7b..4a64ab3 100644 --- a/Tests/Release/Run-PureBaseReleaseValidation.ps1 +++ b/Tests/Release/Run-PureBaseReleaseValidation.ps1 @@ -1689,6 +1689,7 @@ function New-ProductContract { $requiredSourceFragments = @('#pragma shader_feature_local _ PUREBASE_RENDERING_OPAQUE PUREBASE_RENDERING_TRANSPARENT') if ($ShaderName -eq 'PureBase/PBR' -or $ShaderName -eq 'PureBase/Hybrid') { $requiredSourceFragments += '_UseUnityStandardDiffuseBrightness' + $requiredSourceFragments += 'SC_float(_Roughness, 0.5, [SCRange(0.089,1)], "Roughness", "")' } return [ordered]@{ shaderName = $ShaderName