diff --git a/Docs/pure-base-shader-contract.md b/Docs/pure-base-shader-contract.md index 7e7024c1..15b19102 100644 --- a/Docs/pure-base-shader-contract.md +++ b/Docs/pure-base-shader-contract.md @@ -151,6 +151,16 @@ Unlit returns the base surface without host direct, baked, ambient, or environme Toon evaluates a binary direct diffuse response from the surface normal and light direction. Its `ForwardBase` direction combines the Shader-Core direct aggregate with the first-order SH direction, and its ambient result selects a fixed bright or dark SH band from that direction. Shader-Core continues to provide the lightmap input; when Shader-Core supplies the lightmap aggregate, Toon does not synthesize an additional baked-light contribution. `ForwardAdd` contributes direct light only. +### Toon direct-light visibility contract + +For `PureBase/Toon`, the per-light `light.color` exposed to the `light` phase is the scene/direct light color multiplied by non-shadow distance, spot, and cookie attenuation. Unity effective visibility is published separately as `sd.shadow` before the `light` phase, so the same value is available to the `modifylight` and `shade` phases. This contract applies across the supported Unity light-kind branches, including directional, point, spot, point-cookie, and directional-cookie inputs. + +`sd.shadow` is Unity effective per-light visibility: it includes realtime shadowing, baked occlusion and Shadowmask mixing, and shadow-distance fade wherever Unity enables those behaviors. It is not a raw realtime-only shadow sample. Existing Toon light modules that assumed Shader-Core had already multiplied shadow visibility into `light.color` must migrate to `sd.shadow`. + +After the `light` phase, the Toon host consumes `sd.shadow` exactly once while accumulating host-managed direct radiance into `lightSum.color`. It does not apply visibility to aggregate light direction, SH, lightmap, or environment lighting, and it does not consume the value again after that direct-radiance evaluation. This Toon-only ownership adds no second `sd.shadow` consumption to PBR, Hybrid, or Unlit. A module may observe `sd.shadow` for classification or use it for an independent module-owned effect, but it must not multiply host-managed Toon direct radiance or color by `sd.shadow` again. `customlight` remains responsible for the visibility of lights it authors or changes after main-light aggregation; the Toon host does not infer or add that visibility for it. + +In the exact `LIGHTMAP_ON && LIGHTMAP_SHADOW_MIXING && !SHADOWS_SHADOWMASK && SHADOWS_SCREEN` case, Shader-Core suppresses the main-light callback, `sd.shadow` remains at its initialized value `1`, and Shader-Core owns Subtractive application exactly once. This Shader-Core Mixed/Subtractive handling does not add a second Toon visibility consumption. Lightmap and SH environment lighting remain separate from direct-light visibility. `ShadowCaster` controls casting only and is unchanged by this receiving-side split; there is no material ABI, render-mode, or pass change. + ### PBR PBR evaluates a continuous metallic BRDF for direct lighting. Its `ForwardBase` also evaluates Unity Standard indirect GI and reflection probes. Its `ForwardAdd` evaluates only the additional direct BRDF contribution. diff --git a/Docs/technical-information.ja.md b/Docs/technical-information.ja.md index f4a77545..cd82839c 100644 --- a/Docs/technical-information.ja.md +++ b/Docs/technical-information.ja.md @@ -121,6 +121,16 @@ Hybrid は PBR の経路の中にある既存の2値化直接拡散反射の式 この固定されたホスト動作によって、公開項目、キーワード、パス、バリアント、依存関係は増えません。公開プロパティ ABI は変わらないため、既存のマテリアルを移行する必要はなく、自動的にこの動作を受け取ります。 +### Toon の直接光と可視性の契約 + +`PureBase/Toon` の `light` 差し込み位置へ渡す各ライトの `light.color` は、シーンの直接光の色に、影以外の距離・スポット・クッキー減衰を乗じた値です。Unity のライト単位の実効可視性は `sd.shadow` として分離して公開され、`light` の前に設定されるため、`modifylight` と `shade` からも同じ値を参照できます。この分離は、対応する方向ライト、ポイントライト、スポットライト、ポイントクッキー、方向クッキーの各ライト分岐に適用されます。 + +`sd.shadow` は、Unity が有効にする範囲で、リアルタイムの影、焼き込みの遮蔽と Shadowmask の混合、影距離によるフェードを含む Unity のライト単位の実効可視性です。リアルタイム影だけを取得した生の値ではありません。`light.color` に影の可視性がすでに乗っていると仮定していた既存の Toon ライトモジュールは、`sd.shadow` を使うように移行する必要があります。 + +`light` フェーズ後、Toon ホストはホスト管理の直接放射輝度を `lightSum.color` に集計するときに `sd.shadow` を1回だけ消費します。集計ライト方向、SH、ライトマップ、環境光には可視性を適用せず、その直接放射輝度の評価後に同じ値を再び消費しません。この Toon 専用の責務によって、PBR、Hybrid、Unlit に `sd.shadow` を再度消費する処理は追加されません。モジュールは `sd.shadow` を分類のために参照したり、モジュール自身が担当する独立した効果へ使ったりできますが、ホスト管理の Toon 直接放射輝度または色へ `sd.shadow` をもう一度乗じてはいけません。`customlight` はメインライトの集計後に動作するため、自身が作成または変更するライトの可視性を担当します。ホストはこの差し込み位置へ暗黙の可視性を追加しません。 + +`LIGHTMAP_ON && LIGHTMAP_SHADOW_MIXING && !SHADOWS_SHADOWMASK && SHADOWS_SCREEN` の場合は Shader-Core がメインライトのコールバックを抑制し、`sd.shadow` は初期化値の `1` のままになり、Subtractive の適用は Shader-Core が1回だけ担当します。この Shader-Core の Mixed/Subtractive 処理によって、Toon の可視性がもう一度消費されることはありません。ライトマップと SH の環境光は、直接光の可視性とは分離されています。`ShadowCaster` は影を cast する処理だけを担当し、この受け側の分離によって変わりません。マテリアル ABI、描画モード、パス定義も変更しません。 + ## 公開の準備と実行 `package.json` が、公開名と版番号を決める唯一の情報源です。 diff --git a/Docs/technical-information.md b/Docs/technical-information.md index f13c8207..5216437e 100644 --- a/Docs/technical-information.md +++ b/Docs/technical-information.md @@ -121,6 +121,16 @@ Hybrid retains its unchanged binary direct-diffuse equation inside the PBR path. This fixed host behavior adds no public property, keyword, pass, variant, or dependency. The public property ABI is unchanged, so existing materials need no migration and receive the behavior automatically. +### Toon direct-light visibility contract + +For `PureBase/Toon`, `light.color` in the `light` phase is the scene/direct light color multiplied by non-shadow distance, spot, and cookie attenuation. Unity effective per-light visibility is exposed independently through `sd.shadow` before `light`, `modifylight`, and `shade`. The same split is used for the supported directional, point, spot, point-cookie, and directional-cookie light branches. + +`sd.shadow` represents Unity effective visibility, including realtime shadows, baked occlusion and Shadowmask mixing, and shadow-distance fade where enabled. It is not a raw realtime-only sample. Existing Toon light modules that assumed `light.color` was already pre-shadowed must read `sd.shadow` instead. + +After the `light` phase, the Toon host consumes `sd.shadow` exactly once while accumulating host-managed direct radiance into `lightSum.color`. It does not apply visibility to aggregate light direction, SH, lightmap, or environment lighting, and it does not consume the value again after that direct-radiance evaluation. This Toon-only ownership adds no second `sd.shadow` consumption to PBR, Hybrid, or Unlit. A module may observe `sd.shadow` for classification or use it for an independent module-owned effect, but it must not multiply host-managed Toon direct radiance or color by `sd.shadow` again. `customlight` owns the visibility of its own lights after main-light aggregation; the host does not add an implicit visibility factor for that phase. + +For `LIGHTMAP_ON && LIGHTMAP_SHADOW_MIXING && !SHADOWS_SHADOWMASK && SHADOWS_SCREEN`, Shader-Core suppresses the main-light callback, leaves `sd.shadow` at its initialized value `1`, and applies Subtractive shadowing exactly once. This Shader-Core Mixed/Subtractive handling does not add a second Toon visibility consumption. Lightmap and SH environment lighting remain separate from direct visibility. `ShadowCaster` remains casting-only and unchanged; this receiving-side contract does not change the material ABI, render modes, or pass declarations. + ## Release preparation and publication `package.json` is the sole release identity and version declaration. diff --git a/Shaders/Common/birp_host.hlsl b/Shaders/Common/birp_host.hlsl index 50c1ceba..acf46f70 100644 --- a/Shaders/Common/birp_host.hlsl +++ b/Shaders/Common/birp_host.hlsl @@ -30,8 +30,13 @@ void SCCalculateLight(inout SCLightData lightSum, inout SCShadingData sd, inout { light.direction = SCModelSelectMainLightDirection(vertex, light.direction); cd.mainLightDirection = light.direction; - if (SCModelUsesIsolatedMainLightColor()) - light.color = cd.mainLightColor * cd.mainLightAttenuation; + SCModelPrepareMainLight( + light, + sd, + cd.mainLightColor, + cd.mainLightAttenuation, + cd.mainLightNonShadowAttenuation, + cd.mainLightShadowVisibility); __SC_PHASE_light__ @@ -56,6 +61,7 @@ void SCCalculateEnvironmentLight(inout SCLightData lightSum, inout half3 env, in } #include "Packages/jp.lilxyzw.shadercore/ShaderLibrary/birp_lighting.hlsl" +#include "Packages/jp.penguin.purebase/Shaders/Common/birp_light_attenuation.hlsl" /// Evaluates the selected model's ForwardBase or ForwardAdd result with all standard pixel phase insertion points. half4 frag(v2f input, bool isFront : SV_IsFrontFace) : SV_Target @@ -76,9 +82,12 @@ half4 frag(v2f input, bool isFront : SV_IsFrontFace) : SV_Target SCLightData lightSum = (SCLightData)0; half3 env = half3(0, 0, 0); - UNITY_LIGHT_ATTENUATION(mainLightAttenuation, input, vertex.position); + half mainLightShadowVisibility = UNITY_SHADOW_ATTENUATION(input, vertex.position); + half mainLightNonShadowAttenuation = PureBaseEvaluateNonShadowLightAttenuation(input, vertex.position); cd.mainLightColor = _LightColor0.rgb; - cd.mainLightAttenuation = saturate(mainLightAttenuation); + cd.mainLightShadowVisibility = saturate(mainLightShadowVisibility); + cd.mainLightNonShadowAttenuation = saturate(mainLightNonShadowAttenuation); + cd.mainLightAttenuation = cd.mainLightNonShadowAttenuation * cd.mainLightShadowVisibility; cd.mainLightDirection = half3(0, 0, 0); SCCalculateAllLights(lightSum, env, sd, cd, vertex, input, SCModelSelectVertexLighting(SCVertexLighting(vertex.position))); diff --git a/Shaders/Common/birp_light_attenuation.hlsl b/Shaders/Common/birp_light_attenuation.hlsl new file mode 100644 index 00000000..f7cbcd19 --- /dev/null +++ b/Shaders/Common/birp_light_attenuation.hlsl @@ -0,0 +1,59 @@ +/* + * 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 Unity BIRP's non-shadow light attenuation terms for the PureBase fragment host. + +#ifndef PUREBASE_BIRP_LIGHT_ATTENUATION_INCLUDED +#define PUREBASE_BIRP_LIGHT_ATTENUATION_INCLUDED + +/// Evaluates the active Unity BIRP light's distance and cookie attenuation without visibility. +/// The current BIRP fragment input containing light coordinates where Unity requires them. +/// The current world-space pixel position. +/// The active light's non-shadow attenuation term. +inline fixed PureBaseEvaluateNonShadowLightAttenuation(v2f input, float3 worldPos) +{ + #if defined(DIRECTIONAL) + return 1; + #elif defined(POINT) + unityShadowCoord3 lightCoord = mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)).xyz; + return tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).r; + #elif defined(SPOT) + #if !defined(UNITY_HALF_PRECISION_FRAGMENT_SHADER_REGISTERS) + unityShadowCoord4 lightCoord = mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)); + #else + unityShadowCoord4 lightCoord = input._LightCoord; + #endif + return (lightCoord.z > 0) * UnitySpotCookie(lightCoord) * UnitySpotAttenuate(lightCoord.xyz); + #elif defined(POINT_COOKIE) + #if !defined(UNITY_HALF_PRECISION_FRAGMENT_SHADER_REGISTERS) + unityShadowCoord3 lightCoord = mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)).xyz; + #else + unityShadowCoord3 lightCoord = input._LightCoord; + #endif + return tex2D(_LightTextureB0, dot(lightCoord, lightCoord).rr).r * texCUBE(_LightTexture0, lightCoord).w; + #elif defined(DIRECTIONAL_COOKIE) + #if !defined(UNITY_HALF_PRECISION_FRAGMENT_SHADER_REGISTERS) + unityShadowCoord2 lightCoord = mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)).xy; + #else + unityShadowCoord2 lightCoord = input._LightCoord; + #endif + return tex2D(_LightTexture0, lightCoord).w; + #else + return 1; + #endif +} + +#endif \ No newline at end of file diff --git a/Shaders/Common/birp_light_attenuation.hlsl.meta b/Shaders/Common/birp_light_attenuation.hlsl.meta new file mode 100644 index 00000000..5a27980c --- /dev/null +++ b/Shaders/Common/birp_light_attenuation.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: bc15fbe9898547b5907fa5f1ccc04feb +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Shaders/Models/pbr.hlsl b/Shaders/Models/pbr.hlsl index 1fece81f..9ab5cd1c 100644 --- a/Shaders/Models/pbr.hlsl +++ b/Shaders/Models/pbr.hlsl @@ -28,8 +28,12 @@ struct SCCustomData half reserved; /// Stores the Unity wrapper's unattenuated main-light color. half3 mainLightColor; - /// Stores the Unity wrapper's main-light attenuation and shadow factor. + /// Stores the Unity wrapper's full main-light attenuation. half mainLightAttenuation; + /// Stores the Unity wrapper's distance and cookie main-light attenuation without visibility. + half mainLightNonShadowAttenuation; + /// Stores Unity's effective visibility for the current light, including realtime shadow, mixed or baked occlusion, and fade. + half mainLightShadowVisibility; /// Stores the normalized main-light direction before Shader-Core light-phase modifications. half3 mainLightDirection; }; @@ -47,10 +51,10 @@ half SCModelEvaluateDirectFactor(SCShadingData shadingData, SCLightData light) return 1; } -/// Requests main-light color replacement because Unity Standard owns the public _LightColor0 declaration. -bool SCModelUsesIsolatedMainLightColor() +/// Prepares the Unity Standard main light while keeping Shader-Core visibility neutral. +void SCModelPrepareMainLight(inout SCLightData light, inout SCShadingData sd, half3 mainLightColor, half mainLightAttenuation, half mainLightNonShadowAttenuation, half mainLightShadowVisibility) { - return true; + light.color = mainLightColor * mainLightAttenuation; } /// Selects a normalized per-pixel Unity main-light direction before Shader-Core's light phase. diff --git a/Shaders/Models/toon.hlsl b/Shaders/Models/toon.hlsl index 007e6d9d..d24e8eb4 100644 --- a/Shaders/Models/toon.hlsl +++ b/Shaders/Models/toon.hlsl @@ -28,8 +28,12 @@ struct SCCustomData half reserved; /// Stores the Unity wrapper's unattenuated main-light color. half3 mainLightColor; - /// Stores the Unity wrapper's main-light attenuation and shadow factor. + /// Stores the Unity wrapper's full main-light attenuation. half mainLightAttenuation; + /// Stores the Unity wrapper's distance and cookie main-light attenuation without visibility. + half mainLightNonShadowAttenuation; + /// Stores Unity's effective visibility for the current light, including realtime shadow, mixed or baked occlusion, and fade. + half mainLightShadowVisibility; /// Stores the normalized main-light direction before Shader-Core light-phase modifications. half3 mainLightDirection; }; @@ -41,16 +45,17 @@ void SCModelInitializeTangentNormal(inout SCShadingData shadingData) shadingData.N_detail = shadingData.N; } -/// Returns the quantized per-light Toon response after the Shader-Core light phase. +/// Returns the quantized per-light Toon response with Unity effective visibility applied once after the Shader-Core light phase. half SCModelEvaluateDirectFactor(SCShadingData shadingData, SCLightData light) { - return PureBaseToonEvaluateDirectFactor(shadingData.N, light.direction); + return PureBaseToonEvaluateDirectFactor(shadingData.N, light.direction) * shadingData.shadow; } -/// Retains Shader-Core's light color because the Toon wrapper does not isolate Unity Standard declarations. -bool SCModelUsesIsolatedMainLightColor() +/// Prepares the Toon main light so direct radiance remains independent from directional visibility. +void SCModelPrepareMainLight(inout SCLightData light, inout SCShadingData sd, half3 mainLightColor, half mainLightAttenuation, half mainLightNonShadowAttenuation, half mainLightShadowVisibility) { - return false; + light.color = mainLightColor * mainLightNonShadowAttenuation; + sd.shadow = mainLightShadowVisibility; } /// Preserves the Shader-Core light direction for the Toon quantization response. diff --git a/Shaders/Models/unlit.hlsl b/Shaders/Models/unlit.hlsl index c5eb09a5..e3b5fcf2 100644 --- a/Shaders/Models/unlit.hlsl +++ b/Shaders/Models/unlit.hlsl @@ -26,8 +26,12 @@ struct SCCustomData half reserved; /// Stores the Unity wrapper's unattenuated main-light color. half3 mainLightColor; - /// Stores the Unity wrapper's main-light attenuation and shadow factor. + /// Stores the Unity wrapper's full main-light attenuation. half mainLightAttenuation; + /// Stores the Unity wrapper's distance and cookie main-light attenuation without visibility. + half mainLightNonShadowAttenuation; + /// Stores Unity's effective visibility for the current light, including realtime shadow, mixed or baked occlusion, and fade. + half mainLightShadowVisibility; /// Stores the normalized main-light direction before Shader-Core light-phase modifications. half3 mainLightDirection; }; @@ -45,10 +49,9 @@ half SCModelEvaluateDirectFactor(SCShadingData shadingData, SCLightData light) return 1; } -/// Retains Shader-Core's light color because the Unlit wrapper does not isolate Unity Standard declarations. -bool SCModelUsesIsolatedMainLightColor() +/// Leaves the Shader-Core main light and visibility unchanged for the lighting-independent Unlit model. +void SCModelPrepareMainLight(inout SCLightData light, inout SCShadingData sd, half3 mainLightColor, half mainLightAttenuation, half mainLightNonShadowAttenuation, half mainLightShadowVisibility) { - return false; } /// Preserves the Shader-Core light direction for the lighting-independent Unlit model. diff --git a/Tests/Config/shader-core-test-hosts.json b/Tests/Config/shader-core-test-hosts.json index 8c076c15..bdcb6959 100644 --- a/Tests/Config/shader-core-test-hosts.json +++ b/Tests/Config/shader-core-test-hosts.json @@ -301,6 +301,51 @@ "minimumAbsoluteDelta": 0.05 } }, + { + "shaderName": "PureBase/Tests/ShaderCore/ToonShadow", + "moduleUniqueId": "jp.penguin.purebase.tests.shadercore.toonshadow", + "expectedSentinels": [ + "PUREBASE_TEST_TOON_SHADOW_SENTINEL_LIGHT", + "PUREBASE_TEST_TOON_SHADOW_SENTINEL_MODIFYLIGHT", + "PUREBASE_TEST_TOON_SHADOW_SENTINEL_SHADE" + ], + "inactiveSentinels": [ + "PUREBASE_TEST_PHASE_SENTINEL_MORPH", + "PUREBASE_TEST_PHASE_SENTINEL_POSTVERTEX", + "PUREBASE_TEST_PHASE_SENTINEL_BASE", + "PUREBASE_TEST_PHASE_SENTINEL_LIGHT", + "PUREBASE_TEST_PHASE_SENTINEL_CUSTOMLIGHT", + "PUREBASE_TEST_PHASE_SENTINEL_MODIFYLIGHT", + "PUREBASE_TEST_PHASE_SENTINEL_SHADE", + "PUREBASE_TEST_PHASE_SENTINEL_REFLECTION", + "PUREBASE_TEST_PHASE_SENTINEL_ADD", + "PUREBASE_TEST_PHASE_SENTINEL_POSTPIXEL" + ], + "expectedPassSentinelCounts": { + "ForwardBase": 1, + "ForwardAdd": 1, + "ShadowCaster": 0, + "Meta": 0 + }, + "runtimeEvidence": { + "phaseChannels": { + "light": "red", + "modifylight": "green", + "shade": "blue" + }, + "shadowModes": [ + "None", + "Hard", + "Soft" + ], + "unshadowedValue": 1.0, + "hardShadowMaximum": 0.95, + "softShadowMinimum": 0.05, + "softShadowMaximum": 0.95, + "requireFinite": true, + "requireChannelAgreement": true + } + }, { "shaderName": "PureBase/Tests/ShaderCore/ModuleOrder", "moduleUniqueIds": [ diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.FrameReadbacks.cs b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.FrameReadbacks.cs index bd69b9c0..0dd2b8e2 100644 --- a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.FrameReadbacks.cs +++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.FrameReadbacks.cs @@ -42,6 +42,8 @@ public void NumericObservationMetricsRejectOpaqueAlphaLeakCutoutLeakAndTranspare 2, new Color(0.8f, 0.2f, 0.1f, 0.25f) ); + GameObject contaminant = CreateActiveSceneOpaqueReadbackContaminant(); + try { RequireRenderingModeProperty(opaque); Color opaquePixel = RenderCenterPixel(opaque, Color.clear); @@ -66,6 +68,10 @@ public void NumericObservationMetricsRejectOpaqueAlphaLeakCutoutLeakAndTranspare "Transparent source alpha 0.25 over clear destination alpha 0 must use standard alpha blending: 0.25 * 0.25." ); } + finally + { + UnityEngine.Object.DestroyImmediate(contaminant); + } } /// Requires Transparent material sorting to produce the expected finite back-to-front two-layer readback without depth writes. @@ -75,6 +81,8 @@ public void TransparentDepthOrderingUsesBackToFrontCompositionWithoutDepthWrite( Shader shader = RequireProductShader("PureBase/Unlit"); var red = CreateConfiguredMaterial(shader, 2, new Color(1.0f, 0.0f, 0.0f, 0.25f)); var blue = CreateConfiguredMaterial(shader, 2, new Color(0.0f, 0.0f, 1.0f, 0.25f)); + GameObject contaminant = CreateActiveSceneOpaqueReadbackContaminant(); + try { Color redInFront = RenderLayeredCenterPixel(red, blue); Color blueInFront = RenderLayeredCenterPixel(blue, red); @@ -87,6 +95,10 @@ public void TransparentDepthOrderingUsesBackToFrontCompositionWithoutDepthWrite( Assert.That(redInFront.r, Is.GreaterThan(redInFront.b + 0.02f)); Assert.That(blueInFront.b, Is.GreaterThan(blueInFront.r + 0.02f)); } + finally + { + UnityEngine.Object.DestroyImmediate(contaminant); + } } /// Requires Transparent ForwardBase to leave depth unchanged so an explicitly later opaque marker behind it remains visible. diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs index 1b036dea..fa81bf7b 100644 --- a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs +++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs @@ -36,10 +36,22 @@ public sealed partial class PureBaseRenderingModeRenderingTests private const string ToonModelPath = "Packages/jp.penguin.purebase/Shaders/Models/toon.hlsl"; + /// Identifies the fixed Toon shadow host whose importer-valid source layout is part of the test contract. + private const string ToonShadowFixturePath = + "Packages/jp.penguin.purebase/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow.scshader"; + /// Identifies the Toon-only helper that owns dominant-direction and two-band SH evaluation. private const string ToonLightingHelperPath = "Packages/jp.penguin.purebase/Shaders/Common/toon_lighting.hlsl"; + /// Identifies the future Pure Base-owned helper for non-shadow BIRP attenuation. + private const string BirpLightAttenuationHelperPath = + "Packages/jp.penguin.purebase/Shaders/Common/birp_light_attenuation.hlsl"; + + /// Identifies the Unlit model that must retain its existing light and shadow ownership. + private const string UnlitModelPath = + "Packages/jp.penguin.purebase/Shaders/Models/unlit.hlsl"; + /// Identifies the PBR model that must not consume Toon lighting direction or environment bands. private const string PbrModelPath = "Packages/jp.penguin.purebase/Shaders/Models/pbr.hlsl"; @@ -178,6 +190,269 @@ public void ToonLightingOwnershipKeepsBinaryDirectTwoBandShaderCoreLightmapsAndF AssertLightingPhaseOrder(host); } + /// Requires Toon to separate non-shadow attenuation from Unity effective visibility before Shader-Core light phases. + [Test] + public void ToonShadowSeparationRequiresSplitInputsModelPreparationAndUnchangedNonToonOwnership() + { + Assert.That( + File.Exists(BirpLightAttenuationHelperPath), + Is.True, + "Pure Base must own a dedicated non-shadow BIRP attenuation helper." + ); + + ToonShadowSourceSet sources = ToonShadowSourceSet.Load(); + AssertNonShadowAttenuationHelperContract(sources.helper); + AssertRequiredToonAndNonToonSourceTokens(sources); + AssertModelPreparationOwnership(sources); + AssertToonShadowPhaseOrder(sources.host); + AssertDirectionalVisibilityOwnership(sources.host, sources.toon); + AssertToonShadowFixtureSourceContracts(); + } + + /// Stores the source texts participating in the Toon directional-shadow ownership contract. + private sealed class ToonShadowSourceSet + { + /// Gets the dedicated non-shadow attenuation helper source. + public string helper { get; private set; } + + /// Gets the Pure Base BIRP host source. + public string host { get; private set; } + + /// Gets the Toon model source. + public string toon { get; private set; } + + /// Gets the PBR model source. + public string pbr { get; private set; } + + /// Gets the Unlit model source. + public string unlit { get; private set; } + + /// Gets the Shader-Core BIRP lighting source. + public string shaderCoreLighting { get; private set; } + + /// Loads all source texts participating in the shadow ownership contract. + /// The loaded source-text set. + public static ToonShadowSourceSet Load() + { + return new ToonShadowSourceSet + { + helper = File.ReadAllText(BirpLightAttenuationHelperPath), + host = File.ReadAllText(BirpHostPath), + toon = File.ReadAllText(ToonModelPath), + pbr = File.ReadAllText(PbrModelPath), + unlit = File.ReadAllText(UnlitModelPath), + shaderCoreLighting = File.ReadAllText(ShaderCoreBirpLightingPath), + }; + } + } + + /// Asserts that the dedicated attenuation helper preserves Unity branches without reconstructing visibility. + /// The non-shadow attenuation helper source. + private static void AssertNonShadowAttenuationHelperContract(string helper) + { + foreach (string lightKind in new[] { "DIRECTIONAL", "POINT", "SPOT", "POINT_COOKIE", "DIRECTIONAL_COOKIE" }) + { + StringAssert.Contains(lightKind, helper, "The non-shadow helper must preserve Unity's " + lightKind + " branch."); + } + + StringAssert.Contains("PureBaseEvaluateNonShadowLightAttenuation", helper); + Assert.That( + Regex.IsMatch(helper, @"/\s*(?:[A-Za-z0-9_]*[Ss]hadow|[Ss]hadow[A-Za-z0-9_]*)"), + Is.False, + "The non-shadow helper must not reconstruct attenuation by dividing through visibility." + ); + } + + /// Asserts the tokens that separate Toon visibility ownership from shared and non-Toon lighting sources. + /// The loaded source texts to inspect. + private static void AssertRequiredToonAndNonToonSourceTokens(ToonShadowSourceSet sources) + { + StringAssert.Contains("UNITY_SHADOW_ATTENUATION", sources.host); + StringAssert.Contains("mainLightNonShadowAttenuation", sources.host); + StringAssert.Contains("mainLightShadowVisibility", sources.host); + StringAssert.Contains("SCModelPrepareMainLight", sources.host); + StringAssert.Contains("SCModelPrepareMainLight", sources.toon); + StringAssert.Contains("sd.shadow", sources.toon); + StringAssert.Contains("mainLightAttenuation", sources.pbr); + StringAssert.Contains("SCModelPrepareMainLight", sources.unlit); + StringAssert.Contains("LIGHTMAP_SHADOW_MIXING", sources.shaderCoreLighting); + StringAssert.DoesNotContain("LIGHTMAP_SHADOW_MIXING", sources.host); + } + + /// Asserts PBR attenuation preservation and Unlit's explicit no-op main-light callback. + /// The loaded source texts to inspect. + private static void AssertModelPreparationOwnership(ToonShadowSourceSet sources) + { + Assert.That( + Regex.IsMatch( + sources.toon, + @"void\s+SCModelPrepareMainLight\s*\([^)]*\)\s*\{\s*light\.color\s*=\s*mainLightColor\s*\*\s*mainLightNonShadowAttenuation\s*;\s*sd\.shadow\s*=\s*mainLightShadowVisibility\s*;\s*\}", + RegexOptions.Singleline + ), + Is.True, + "Toon must publish non-shadow direct color and independent effective visibility before Shader-Core light phases." + ); + Assert.That( + Regex.IsMatch( + sources.pbr, + @"void\s+SCModelInitializeGiInput\s*\(\s*out\s+UnityGIInput\s+input\s*,\s*SCCustomData\s+customData\s*,\s*SCVertexData\s+vertex\s*\)\s*\{[^}]*\binput\.atten\s*=\s*customData\.mainLightAttenuation\s*;", + RegexOptions.Singleline + ), + Is.True, + "PBR and Hybrid must preserve UnityGIInput.atten ownership through the shared PBR model." + ); + Assert.That( + Regex.IsMatch( + sources.unlit, + @"void\s+SCModelPrepareMainLight\s*\([^)]*\)\s*\{\s*\}", + RegexOptions.Singleline + ), + Is.True, + "Unlit must retain an explicit no-op main-light preparation callback." + ); + } + + /// Asserts that BIRP captures shadow visibility and orders Shader-Core light phases before aggregation. + /// The Pure Base BIRP host source. + private static void AssertToonShadowPhaseOrder(string host) + { + int surfaceInitialization = RequireIndex(host, "SCShadingData sd"); + int shadowCapture = RequireIndex(host, "UNITY_SHADOW_ATTENUATION"); + int allLights = RequireIndex(host, "SCCalculateAllLights"); + int lightPhase = RequireIndex(host, "__SC_PHASE_light__"); + int aggregateDirection = RequireIndex(host, "lightSum.direction +="); + int aggregateColor = RequireIndex(host, "lightSum.color +="); + int modifyLightPhase = RequireIndex(host, "__SC_PHASE_modifylight__"); + int surfaceColor = RequireIndex(host, "SCModelBaseSurfaceColor"); + int shadePhase = RequireIndex(host, "__SC_PHASE_shade__"); + Assert.That(surfaceInitialization, Is.LessThan(shadowCapture)); + Assert.That(shadowCapture, Is.LessThan(allLights)); + Assert.That(lightPhase, Is.LessThan(aggregateDirection)); + Assert.That(aggregateDirection, Is.LessThan(aggregateColor)); + Assert.That(allLights, Is.LessThan(modifyLightPhase)); + Assert.That(modifyLightPhase, Is.LessThan(shadePhase)); + Assert.That(surfaceColor, Is.LessThan(shadePhase)); + Assert.That( + Regex.IsMatch( + host, + @"void\s+SCCalculateLight\s*\([^)]*\)\s*\{[^}]*SCModelSelectMainLightDirection\s*\([^;]*\)\s*;[^}]*SCModelPrepareMainLight\s*\([^;]*\)\s*;[^}]*__SC_PHASE_light__[^}]*lightSum\.direction\s*\+=", + RegexOptions.Singleline + ), + Is.True, + "SCCalculateLight must select the main-light direction, prepare model data, expose the light phase, and only then aggregate the light." + ); + } + + /// Asserts that Toon consumes directional visibility once in direct radiance without shadowing aggregate direction. + /// The Pure Base BIRP host source. + /// The Toon model source. + private static void AssertDirectionalVisibilityOwnership(string host, string toon) + { + Assert.That( + Regex.IsMatch(host, @"lightSum\.(?:color|direction)\s*\*?=\s*[^;]*sd\.shadow"), + Is.False + ); + Assert.That( + Regex.IsMatch( + host, + @"lightSum\.(?:color|direction)[^;]*mainLightShadowVisibility" + ), + Is.False, + "Directional visibility must be published through sd.shadow, not folded into the direct color or aggregate direction." + ); + Assert.That( + Regex.IsMatch( + host, + @"lightSum\.direction\s*\+=\s*[^;]*(?:sd\.shadow|mainLightShadowVisibility)" + ), + Is.False, + "Directional visibility must not enter Toon aggregate light direction." + ); + Assert.That( + Regex.IsMatch( + toon, + @"sd\.shadow\s*=\s*mainLightShadowVisibility\s*;" + ), + Is.True, + "The Toon callback must publish the captured directional visibility as the phase-local sd.shadow value." + ); + AssertToonDirectFactorConsumesVisibilityOnce(toon); + } + + /// Asserts that Toon direct-factor evaluation consumes the published visibility exactly once. + /// The Toon model source. + private static void AssertToonDirectFactorConsumesVisibilityOnce(string toon) + { + Match directFactor = Regex.Match( + toon, + @"half\s+SCModelEvaluateDirectFactor\s*\(\s*SCShadingData\s+shadingData\s*,\s*SCLightData\s+light\s*\)\s*\{(?[^}]*)\}", + RegexOptions.Singleline + ); + Assert.That(directFactor.Success, Is.True, "The Toon model must define its direct-factor callback."); + MatchCollection shadowUses = Regex.Matches(toon, @"\bshadingData\.shadow\b"); + Assert.That( + shadowUses.Count, + Is.EqualTo(1), + "The Toon model source must consume effective visibility through shadingData.shadow exactly once." + ); + string body = directFactor.Groups["body"].Value; + Assert.That( + Regex.IsMatch( + body, + @"\breturn\s+PureBaseToonEvaluateDirectFactor\s*\(\s*shadingData\.N\s*,\s*light\.direction\s*\)\s*\*\s*shadingData\.shadow\s*;" + ), + Is.True, + "Toon must apply the quantized direct response to effective visibility in its direct-factor callback." + ); + Assert.That( + shadowUses[0].Index, + Is.GreaterThanOrEqualTo(directFactor.Groups["body"].Index) + .And.LessThan(directFactor.Groups["body"].Index + body.Length), + "The Toon model's sole shadingData.shadow consumption must remain within SCModelEvaluateDirectFactor." + ); + } + + /// Asserts the fixed Toon host keeps its read-only ForwardAdd stencil state and properties-header license placement. + private static void AssertToonShadowFixtureSourceContracts() + { + string fixture = File.ReadAllText(ToonShadowFixturePath); + Match forwardAddStencil = Regex.Match( + fixture, + @"Name\s+""ForwardAdd""[\s\S]*?Stencil\s*\{(?[\s\S]*?)\}", + RegexOptions.Singleline + ); + Assert.That(forwardAddStencil.Success, Is.True, "The fixed Toon host must retain a ForwardAdd Stencil block."); + string stencilBody = forwardAddStencil.Groups["body"].Value; + MatchCollection writeMaskDirectives = Regex.Matches( + stencilBody, + @"\bWriteMask\s+(?[^\s}]+)" + ); + Assert.That( + writeMaskDirectives.Count, + Is.EqualTo(1), + "The fixed Toon host ForwardAdd Stencil block must contain exactly one read-only WriteMask 0 directive." + ); + Assert.That( + writeMaskDirectives[0].Groups["value"].Value, + Is.EqualTo("0"), + "The fixed Toon host ForwardAdd Stencil block must use 0 as its only WriteMask value." + ); + StringAssert.DoesNotContain( + "_StencilWriteMask", + stencilBody, + "The fixed Toon host ForwardAdd Stencil block must not restore the dynamic write mask." + ); + Assert.That( + Regex.IsMatch( + fixture, + @"Properties\s*\{\s*/\*.*?Copyright 2026 Penguin.*?Licensed under the Apache License, Version 2\.0.*?WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND.*?\*/\s*__SC_SHADERLAB_properties__", + RegexOptions.Singleline + ), + Is.True, + "The fixed Toon host must retain its Apache notice in the importer-valid location before properties expansion." + ); + } + /// Asserts that Toon alone owns its binary direct response and two-band environment interpretation. /// The Toon model source. /// The Toon lighting helper source. @@ -188,9 +463,12 @@ private static void AssertToonHelperAndModelContracts(string toon, string helper helper, "The Toon helper must own the stable binary direct-light response." ); - StringAssert.Contains( - "return PureBaseToonEvaluateDirectFactor(shadingData.N, light.direction);", - toon, + Assert.That( + Regex.IsMatch( + toon, + @"\breturn\s+PureBaseToonEvaluateDirectFactor\s*\(\s*shadingData\.N\s*,\s*light\.direction\s*\)\s*\*\s*shadingData\.shadow\s*;" + ), + Is.True, "The Toon model must delegate direct-light evaluation to its binary helper." ); StringAssert.Contains( diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.cs b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.cs index ef1767e4..324e496a 100644 --- a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.cs +++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.cs @@ -36,6 +36,9 @@ public sealed partial class PureBaseRenderingModeRenderingTests /// Defines the small readback dimension used by transient numeric observations. private const int RenderSize = 64; + /// Defines the dedicated layer used by isolated quad readback fixtures. + private const int QuadReadbackFixtureLayer = 30; + /// Defines the largest per-channel readback difference treated as directional-shadow noise. private const float ShadowPixelNoiseThreshold = 0.002f; @@ -453,6 +456,7 @@ private static void ConfigureMode(Material material, int mode) /// The center readback pixel. private static Color RenderCenterPixel(Material material, Color background) { + Scene scene = default; GameObject cameraObject = null; GameObject quadObject = null; RenderTexture renderTexture = null; @@ -460,6 +464,7 @@ private static Color RenderCenterPixel(Material material, Color background) Camera camera = null; try { + scene = EditorSceneManager.NewPreviewScene(); cameraObject = new GameObject("PureBaseRenderingModeCamera"); quadObject = GameObject.CreatePrimitive(PrimitiveType.Quad); renderTexture = new RenderTexture( @@ -476,7 +481,11 @@ private static Color RenderCenterPixel(Material material, Color background) true ); camera = cameraObject.AddComponent(); - ConfigureCenterPixelCamera(camera, renderTexture, background); + SceneManager.MoveGameObjectToScene(cameraObject, scene); + SceneManager.MoveGameObjectToScene(quadObject, scene); + cameraObject.layer = QuadReadbackFixtureLayer; + quadObject.layer = QuadReadbackFixtureLayer; + ConfigureCenterPixelCamera(camera, renderTexture, background, scene); quadObject.GetComponent().sharedMaterial = material; camera.Render(); return ReadCenterPixel(renderTexture, texture); @@ -490,16 +499,43 @@ private static Color RenderCenterPixel(Material material, Color background) renderTexture, texture ); + if (scene.IsValid() && scene.isLoaded) + { + EditorSceneManager.ClosePreviewScene(scene); + } } } + /// Creates an active-scene opaque quad that must not affect isolated readback observations. + /// The caller-owned active-scene renderer. + private GameObject CreateActiveSceneOpaqueReadbackContaminant() + { + Shader shader = Shader.Find("Unlit/Color"); + Assert.That( + shader, + Is.Not.Null, + "The Built-in Unlit/Color shader is unavailable for the readback isolation probe." + ); + Material material = CreateMaterial(shader); + material.SetColor("_Color", Color.white); + GameObject quadObject = GameObject.CreatePrimitive(PrimitiveType.Quad); + quadObject.name = "PureBaseRenderingModeActiveSceneContaminant"; + quadObject.transform.position = new Vector3(0.0f, 0.0f, 1.0f); + quadObject.GetComponent().sharedMaterial = material; + return quadObject; + } + /// Configures the temporary camera used for one center-pixel readback. private static void ConfigureCenterPixelCamera( Camera camera, RenderTexture renderTexture, - Color background + Color background, + Scene scene ) { + camera.enabled = false; + camera.cullingMask = 1 << QuadReadbackFixtureLayer; + camera.overrideSceneCullingMask = EditorSceneManager.GetSceneCullingMask(scene); camera.orthographic = true; camera.orthographicSize = 0.5f; camera.transform.position = new Vector3(0.0f, 0.0f, -2.0f); @@ -538,6 +574,7 @@ Texture2D texture /// The sorted layered center readback. private static Color RenderLayeredCenterPixel(Material frontMaterial, Material rearMaterial) { + Scene scene = default; GameObject cameraObject = null; GameObject frontObject = null; GameObject rearObject = null; @@ -545,6 +582,7 @@ private static Color RenderLayeredCenterPixel(Material frontMaterial, Material r Texture2D texture = null; try { + scene = EditorSceneManager.NewPreviewScene(); cameraObject = new GameObject("PureBaseRenderingModeDepthCamera"); frontObject = GameObject.CreatePrimitive(PrimitiveType.Quad); rearObject = GameObject.CreatePrimitive(PrimitiveType.Quad); @@ -562,12 +600,13 @@ private static Color RenderLayeredCenterPixel(Material frontMaterial, Material r true ); Camera camera = cameraObject.AddComponent(); - camera.orthographic = true; - camera.orthographicSize = 0.5f; - camera.transform.position = new Vector3(0.0f, 0.0f, -2.0f); - camera.clearFlags = CameraClearFlags.SolidColor; - camera.backgroundColor = Color.clear; - camera.targetTexture = renderTexture; + SceneManager.MoveGameObjectToScene(cameraObject, scene); + SceneManager.MoveGameObjectToScene(frontObject, scene); + SceneManager.MoveGameObjectToScene(rearObject, scene); + cameraObject.layer = QuadReadbackFixtureLayer; + frontObject.layer = QuadReadbackFixtureLayer; + rearObject.layer = QuadReadbackFixtureLayer; + ConfigureCenterPixelCamera(camera, renderTexture, Color.clear, scene); frontObject.transform.position = Vector3.zero; rearObject.transform.position = new Vector3(0.0f, 0.0f, 0.1f); frontObject.GetComponent().sharedMaterial = frontMaterial; @@ -593,6 +632,10 @@ private static Color RenderLayeredCenterPixel(Material frontMaterial, Material r UnityEngine.Object.DestroyImmediate(frontObject); if (cameraObject != null) UnityEngine.Object.DestroyImmediate(cameraObject); + if (scene.IsValid() && scene.isLoaded) + { + EditorSceneManager.ClosePreviewScene(scene); + } } } @@ -916,7 +959,9 @@ private static Type FindLoadedType(string fullName) { Type type = assembly.GetType(fullName, false); if (type != null) + { return type; + } } return null; diff --git a/Tests/Daily/Editor/PureBaseToonLightingContractTests.Runtime.cs b/Tests/Daily/Editor/PureBaseToonLightingContractTests.Runtime.cs index b4351398..f853bc0e 100644 --- a/Tests/Daily/Editor/PureBaseToonLightingContractTests.Runtime.cs +++ b/Tests/Daily/Editor/PureBaseToonLightingContractTests.Runtime.cs @@ -18,6 +18,10 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Reflection; +using System.Security.Cryptography; using NUnit.Framework; using UnityEditor; using UnityEditor.SceneManagement; @@ -29,12 +33,153 @@ namespace PureBase.Tests.Daily { public sealed partial class PureBaseToonLightingContractTests { + /// Identifies the saved owner scene used by Daily regular additive shadow captures. + private const string ShadowCaptureOwnerScenePath = "Assets/Pure-Base.unity"; + + /// Warms representative ForwardAdd variants for every Unity light kind and shadow keyword form. + /// The number of individually warmed nonpersistent variants. + private static int WarmAllLightKindVariants() + { + var requests = new[] + { + new LightVariantRequest("ForwardBase Baseline", PassType.ForwardBase, Array.Empty()), + new LightVariantRequest("ForwardBase Opaque", PassType.ForwardBase, new[] { "PUREBASE_RENDERING_OPAQUE" }), + new LightVariantRequest("ForwardBase Transparent", PassType.ForwardBase, new[] { "PUREBASE_RENDERING_TRANSPARENT" }), + new LightVariantRequest("ForwardBase Screen Shadow", PassType.ForwardBase, new[] { "SHADOWS_SCREEN" }), + new LightVariantRequest("Directional ForwardAdd", PassType.ForwardAdd, new[] { "DIRECTIONAL" }), + new LightVariantRequest("Directional Cookie ForwardAdd", PassType.ForwardAdd, new[] { "DIRECTIONAL_COOKIE" }), + new LightVariantRequest("Point ForwardAdd", PassType.ForwardAdd, new[] { "POINT" }), + new LightVariantRequest("Point Cookie ForwardAdd", PassType.ForwardAdd, new[] { "POINT_COOKIE" }), + new LightVariantRequest("Spot ForwardAdd", PassType.ForwardAdd, new[] { "SPOT" }), + new LightVariantRequest("Directional Depth Shadow ForwardAdd", PassType.ForwardAdd, new[] { "DIRECTIONAL", "SHADOWS_DEPTH" }), + new LightVariantRequest("Directional Cookie Depth Shadow ForwardAdd", PassType.ForwardAdd, new[] { "DIRECTIONAL_COOKIE", "SHADOWS_DEPTH" }), + new LightVariantRequest("Point Cube Shadow ForwardAdd", PassType.ForwardAdd, new[] { "POINT", "SHADOWS_CUBE" }), + new LightVariantRequest("Point Cookie Cube Shadow ForwardAdd", PassType.ForwardAdd, new[] { "POINT_COOKIE", "SHADOWS_CUBE" }), + new LightVariantRequest("Spot Depth Shadow ForwardAdd", PassType.ForwardAdd, new[] { "SPOT", "SHADOWS_DEPTH" }), + }; + var warmedCount = 0; + foreach (string shaderName in new[] { "PureBase/Unlit", "PureBase/Toon", "PureBase/PBR", "PureBase/Hybrid" }) + { + Shader shader = Shader.Find(shaderName); + Assert.That(shader, Is.Not.Null, "Product shader '" + shaderName + "' is unavailable."); + Assert.That(ShaderUtil.ShaderHasError(shader), Is.False, "Product shader '" + shaderName + "' has compiler errors."); + foreach (LightVariantRequest request in requests) + { + var variants = new ShaderVariantCollection(); + try + { + Assert.That( + variants.Add(new ShaderVariantCollection.ShaderVariant(shader, request.passType, request.keywords)), + Is.True, + "The " + request.label + " variant could not be added for '" + shaderName + "'." + ); + variants.WarmUp(); + Assert.That(variants.variantCount, Is.EqualTo(1)); + warmedCount++; + } + finally + { + UnityEngine.Object.DestroyImmediate(variants); + } + } + } + + return warmedCount; + } + + /// Stores one representative transient product-pass variant request. + private sealed class LightVariantRequest + { + /// Initializes one variant request. + /// The diagnostic light-kind label. + /// The product pass that must compile the variant. + /// The exact enabled Unity variant keywords. + public LightVariantRequest(string label, PassType passType, string[] keywords) + { + this.label = label; + this.passType = passType; + this.keywords = keywords; + } + + /// Gets the diagnostic light-kind label. + public string label { get; } + + /// Gets the product pass that must compile the variant. + public PassType passType { get; } + + /// Gets the exact enabled Unity variant keywords. + public string[] keywords { get; } + } + + /// Groups the inputs for one isolated Unity light readback. + private sealed class LightCaptureRequest + { + /// Initializes one light capture request with an optional caller-owned transient cookie. + /// The caller-owned cookie to apply to transient Unity lights, if any. + public LightCaptureRequest(Texture cookie = null) + { + this.cookie = cookie; + } + + /// Gets or sets the uniform mesh world normal. + public Vector3 normal { get; set; } + + /// Gets or sets the real main or additional light color. + public Vector4 lightColor { get; set; } + + /// Gets or sets the real directional or local-light position. + public Vector4 lightPosition { get; set; } + + /// Gets or sets the spherical-harmonic globals for the render. + public ShCoefficients coefficients { get; set; } + + /// Gets or sets the real Unity light type. + public LightType lightType { get; set; } + + /// Gets or sets the number of ForcePixel lights to create. + public int lightCount { get; set; } + + /// Gets or sets the Point or Spot light range. + public float range { get; set; } = 4.0f; + + /// Gets or sets the Spot outer angle. + public float spotAngle { get; set; } = 30.0f; + + /// Gets the optional caller-owned transient cookie. + public Texture cookie { get; } + } + /// Owns one isolated regular-render fixture and restores every Unity global it changes. private class ToonLightingCaptureRuntimeScope : IDisposable { /// Stores the dedicated layer used by the preview-scene renderer and lights. private const int FixtureLayer = 31; + /// Owns transient objects and readback resources for one directional shadow receiver capture. + private sealed class ShadowReceiverCapture + { + /// Stores the transient diagnostic camera object. + public GameObject cameraObject; + + /// Stores the transient receiver object. + public GameObject receiver; + + /// Stores the transient shadow caster object. + public GameObject caster; + + /// Stores the transient directional-light object. + public GameObject lightObject; + + /// Stores the transient linear receiver render target. + public RenderTexture target; + + /// Stores the transient CPU receiver readback texture. + public Texture2D readback; + + /// Stores the configured diagnostic camera. + public Camera camera; + } + /// Lists the spherical-harmonic globals injected immediately before each render. private static readonly string[] GlobalNames = { @@ -65,6 +210,12 @@ private class ToonLightingCaptureRuntimeScope : IDisposable /// Stores the original pixel-light budget. private readonly int pixelLightCount; + /// Stores the caller's directional shadow quality. + private readonly ShadowQuality shadowQuality; + + /// Stores the caller's directional shadow draw distance. + private readonly float shadowDistance; + /// Stores the original fog setting for the formerly active scene. private readonly bool fogEnabled; @@ -111,6 +262,8 @@ public ToonLightingCaptureRuntimeScope() activeScene = SceneManager.GetActiveScene(); sceneCount = SceneManager.sceneCount; pixelLightCount = QualitySettings.pixelLightCount; + shadowQuality = QualitySettings.shadows; + shadowDistance = QualitySettings.shadowDistance; fogEnabled = RenderSettings.fog; foreach (string globalName in GlobalNames) { @@ -122,6 +275,8 @@ public ToonLightingCaptureRuntimeScope() scene = EditorSceneManager.NewPreviewScene(); RenderSettings.fog = false; QualitySettings.pixelLightCount = Mathf.Max(2, pixelLightCount); + QualitySettings.shadows = ShadowQuality.All; + QualitySettings.shadowDistance = Mathf.Max(32.0f, shadowDistance); InitializeRenderResources(); } @@ -198,40 +353,393 @@ public Color Render( Material material = CreateProductMaterial(shaderName, passName, metallic); if (pointLight) { - Color oneLight = RenderWithLights( + return RenderLightDifference( material, - normal, - lightColor, - lightPosition, - coefficients, - true, - 1 + CreateLightCaptureRequest( + normal, + lightColor, + lightPosition, + coefficients, + LightType.Point + ) ); - Color twoLights = RenderWithLights( - material, + } + + return RenderWithLights( + material, + CreateDirectionalLightCaptureRequest( normal, lightColor, lightPosition, - coefficients, - true, - 2 + coefficients + ) + ); + } + + /// Renders one direct or additional light with an optional transient Unity cookie. + /// The required product shader name. + /// The product pass that receives the light. + /// The coherent light and cookie input for the capture. + /// The center linear readback. + public Color RenderLightWithCookie(string shaderName, string passName, LightCaptureRequest request) + { + Material material = CreateProductMaterial(shaderName, passName, 0.0f); + return RenderWithLights( + material, + request + ); + } + + /// Renders a shadowed horizontal receiver and returns a whole-region RGB observation. + /// The imported product or fixed host shader name. + /// The requested real Unity directional shadow mode. + /// The visible receiver region's mean RGB and sample count. + public ShadowReceiverObservation RenderDirectionalShadowReceiver(string shaderName, LightShadows shadows) + { + Material material = CreateProductMaterial(shaderName, "ForwardBase", 0.0f); + Scene receiverScene = GetShadowReceiverScene(out bool receiverSceneWasLoaded); + var capture = new ShadowReceiverCapture(); + try + { + ConfigureShadowReceiverCapture(capture, receiverScene, material, shadows); + return ReadShadowReceiverObservation(capture); + } + finally + { + DestroyShadowReceiverCapture(capture); + RestoreShadowReceiverScene(receiverScene, receiverSceneWasLoaded); + } + } + + /// Gets the existing shadow-owner scene or opens it additively for one capture. + /// Receives whether the owner scene was already loaded. + /// The shadow-owner scene. + private static Scene GetShadowReceiverScene(out bool receiverSceneWasLoaded) + { + Scene receiverScene = SceneManager.GetSceneByPath(ShadowCaptureOwnerScenePath); + receiverSceneWasLoaded = receiverScene.isLoaded; + if (!receiverSceneWasLoaded) + { + receiverScene = EditorSceneManager.OpenScene( + ShadowCaptureOwnerScenePath, + OpenSceneMode.Additive ); - return new Color( - twoLights.r - oneLight.r, - twoLights.g - oneLight.g, - twoLights.b - oneLight.b, - twoLights.a + EditorSceneManager.SetSceneCullingMask( + receiverScene, + EditorSceneManager.CalculateAvailableSceneCullingMask() ); } - return RenderWithLights( + return receiverScene; + } + + /// Allocates and configures the complete directional shadow receiver capture. + /// Owns every allocated capture resource from its first allocation. + /// The scene that owns all generated capture objects. + /// The registered product material applied to the receiver. + /// The requested Unity directional shadow mode. + private void ConfigureShadowReceiverCapture( + ShadowReceiverCapture capture, + Scene receiverScene, + Material material, + LightShadows shadows + ) + { + CreateShadowReceiverResources(capture, receiverScene); + ConfigureShadowReceiverCamera(capture, receiverScene); + ConfigureShadowReceiverGeometry(capture, material); + ConfigureShadowReceiverLight(capture, shadows); + } + + /// Allocates and immediately registers all transient objects and readback resources for a receiver capture. + /// The capture that owns every allocated resource. + /// The scene that owns all generated capture objects. + private static void CreateShadowReceiverResources( + ShadowReceiverCapture capture, + Scene receiverScene + ) + { + capture.cameraObject = CreateShadowSceneObject( + receiverScene, + "PureBase Toon Shadow Diagnostic Camera" + ); + capture.receiver = CreateShadowSceneObject( + receiverScene, + "PureBase Toon Shadow Diagnostic Receiver" + ); + capture.caster = CreateShadowSceneObject( + receiverScene, + "PureBase Toon Shadow Diagnostic Caster" + ); + capture.lightObject = CreateShadowSceneObject( + receiverScene, + "PureBase Toon Shadow Diagnostic Light" + ); + capture.target = new RenderTexture( + 64, + 64, + 24, + RenderTextureFormat.ARGBFloat, + RenderTextureReadWrite.Linear + ) { hideFlags = HideFlags.HideAndDontSave }; + capture.target.Create(); + capture.readback = new Texture2D(64, 64, TextureFormat.RGBAFloat, false, true) + { + hideFlags = HideFlags.HideAndDontSave, + }; + } + + /// Configures the receiver camera after its target and readback resources are registered. + /// The capture whose diagnostic camera is configured. + /// The scene isolated by the camera culling mask. + private static void ConfigureShadowReceiverCamera( + ShadowReceiverCapture capture, + Scene receiverScene + ) + { + capture.camera = capture.cameraObject.AddComponent(); + capture.camera.enabled = false; + capture.camera.cullingMask = 1 << FixtureLayer; + capture.camera.overrideSceneCullingMask = EditorSceneManager.GetSceneCullingMask( + receiverScene + ); + capture.camera.clearFlags = CameraClearFlags.SolidColor; + capture.camera.backgroundColor = new Color(0.0f, 0.0f, 0.0f, 0.0f); + capture.camera.fieldOfView = 42.0f; + capture.camera.nearClipPlane = 0.1f; + capture.camera.farClipPlane = 20.0f; + capture.camera.transform.position = new Vector3(0.0f, 3.6f, -5.0f); + capture.camera.transform.LookAt(Vector3.zero); + capture.camera.targetTexture = capture.target; + } + + /// Configures the receiver and caster geometry for one directional shadow capture. + /// The capture whose receiver and caster are configured. + /// The registered product material applied to the receiver. + private void ConfigureShadowReceiverGeometry( + ShadowReceiverCapture capture, + Material material + ) + { + MeshRenderer receiverRenderer = capture.receiver.AddComponent(); + capture.receiver.AddComponent().sharedMesh = CreateShadowReceiverMesh(); + receiverRenderer.sharedMaterial = material; + receiverRenderer.receiveShadows = true; + capture.caster.transform.position = new Vector3(0.0f, 1.0f, 0.0f); + capture.caster.transform.localScale = new Vector3(1.1f, 1.8f, 1.1f); + MeshRenderer casterRenderer = capture.caster.AddComponent(); + capture.caster.AddComponent().sharedMesh = CreateShadowCasterMesh(); + casterRenderer.sharedMaterial = CreateStandardShadowCasterMaterial(); + casterRenderer.shadowCastingMode = ShadowCastingMode.ShadowsOnly; + casterRenderer.receiveShadows = false; + } + + /// Configures the directional shadow-casting light for one receiver capture. + /// The capture whose directional light is configured. + /// The requested Unity directional shadow mode. + private static void ConfigureShadowReceiverLight( + ShadowReceiverCapture capture, + LightShadows shadows + ) + { + Light light = capture.lightObject.AddComponent(); + light.type = LightType.Directional; + light.renderMode = LightRenderMode.ForcePixel; + light.color = Color.white; + light.intensity = 1.0f; + light.cullingMask = 1 << FixtureLayer; + light.shadows = shadows; + capture.lightObject.transform.rotation = Quaternion.Euler(55.0f, -35.0f, 0.0f); + } + + /// Renders a configured receiver capture and returns its visible-region observation. + /// The configured receiver capture. + /// The visible receiver-region measurement. + private ShadowReceiverObservation ReadShadowReceiverObservation(ShadowReceiverCapture capture) + { + capture.camera.Render(); + return MeasureReceiverRegion(ReadPixels(capture.target, capture.readback)); + } + + /// Releases one receiver capture in the established render-target and object destruction order. + /// The capture whose owned transient resources are released. + private static void DestroyShadowReceiverCapture(ShadowReceiverCapture capture) + { + if (capture.readback != null) + { + UnityEngine.Object.DestroyImmediate(capture.readback); + } + + if (capture.target != null) + { + capture.target.Release(); + UnityEngine.Object.DestroyImmediate(capture.target); + } + + DestroyShadowSceneObject(capture.lightObject); + DestroyShadowSceneObject(capture.caster); + DestroyShadowSceneObject(capture.receiver); + DestroyShadowSceneObject(capture.cameraObject); + } + + /// Restores the owner-scene load and active-scene state after a shadow receiver capture. + /// The capture owner scene. + /// Whether the owner scene preceded the capture. + private void RestoreShadowReceiverScene(Scene receiverScene, bool receiverSceneWasLoaded) + { + if (!receiverSceneWasLoaded && receiverScene.IsValid() && receiverScene.isLoaded) + { + EditorSceneManager.CloseScene(receiverScene, true); + } + + if (activeScene.IsValid() && activeScene.isLoaded) + { + SceneManager.SetActiveScene(activeScene); + } + } + + /// Renders an isolated Point or Spot ForwardAdd contribution without changing the caller's existing capture configuration. + /// The required product shader name. + /// The uniform mesh world normal. + /// The additional light color. + /// The additional light position. + /// The supported additional-light type. + /// The transient light range. + /// The transient Spot outer angle. + /// The isolated second additional-light contribution. + public Color RenderAdditionalLight( + string shaderName, + Vector3 normal, + Vector4 lightColor, + Vector4 lightPosition, + LightType lightType, + float range, + float spotAngle + ) + { + return RenderAdditionalLight( + shaderName, + normal, + lightColor, + lightPosition, + lightType, + range, + spotAngle, + ShCoefficients.Zero + ); + } + + /// Renders an isolated Point or Spot ForwardAdd contribution with caller-controlled SH globals. + /// The required product shader name. + /// The uniform mesh world normal. + /// The additional light color. + /// The additional light position. + /// The supported additional-light type. + /// The transient light range. + /// The transient Spot outer angle. + /// The SH globals installed only for this readback. + /// The isolated second additional-light contribution. + public Color RenderAdditionalLight( + string shaderName, + Vector3 normal, + Vector4 lightColor, + Vector4 lightPosition, + LightType lightType, + float range, + float spotAngle, + ShCoefficients coefficients + ) + { + Assert.That( + lightType == LightType.Point || lightType == LightType.Spot, + Is.True, + "The additional-light capture supports only Point and Spot lights." + ); + Material material = CreateProductMaterial(shaderName, "ForwardAdd", 0.0f); + return RenderLightDifference( material, + CreateLightCaptureRequest( + normal, + lightColor, + lightPosition, + coefficients, + lightType, + range, + spotAngle + ) + ); + } + + /// Creates one Point, Spot, or cookie-capable light capture request with no lights enabled yet. + /// The uniform mesh world normal. + /// The light color. + /// The directional vector or local-light position. + /// The spherical-harmonic globals for the render. + /// The Unity light type. + /// The Point or Spot light range. + /// The Spot outer angle. + /// The coherent light capture request. + private static LightCaptureRequest CreateLightCaptureRequest( + Vector3 normal, + Vector4 lightColor, + Vector4 lightPosition, + ShCoefficients coefficients, + LightType lightType, + float range = 4.0f, + float spotAngle = 30.0f + ) + { + return new LightCaptureRequest + { + normal = normal, + lightColor = lightColor, + lightPosition = lightPosition, + coefficients = coefficients, + lightType = lightType, + range = range, + spotAngle = spotAngle, + }; + } + + /// Creates one Directional light capture request with the established zero-light color control. + /// The uniform mesh world normal. + /// The directional light color. + /// The directional light vector. + /// The spherical-harmonic globals for the render. + /// The configured directional light capture request. + private static LightCaptureRequest CreateDirectionalLightCaptureRequest( + Vector3 normal, + Vector4 lightColor, + Vector4 lightPosition, + ShCoefficients coefficients + ) + { + LightCaptureRequest request = CreateLightCaptureRequest( normal, lightColor, lightPosition, coefficients, - false, - lightColor == Vector4.zero ? 0 : 1 + LightType.Directional + ); + request.lightCount = lightColor == Vector4.zero ? 0 : 1; + return request; + } + + /// Renders one and two equivalent lights, returning only the isolated second-light contribution. + /// The configured transient material. + /// The light capture request reused for one- and two-light rendering. + /// The isolated second-light contribution. + private Color RenderLightDifference(Material material, LightCaptureRequest request) + { + request.lightCount = 1; + Color oneLight = RenderWithLights(material, request); + request.lightCount = 2; + Color twoLights = RenderWithLights(material, request); + return new Color( + twoLights.r - oneLight.r, + twoLights.g - oneLight.g, + twoLights.b - oneLight.b, + twoLights.a ); } @@ -282,34 +790,35 @@ float metallic return material; } + /// Creates a fixture-owned Standard material used only to cast a controlled directional shadow. + /// The registered nonpersistent shadow-caster material. + private Material CreateStandardShadowCasterMaterial() + { + Shader shader = Shader.Find("Standard"); + Assert.That(shader, Is.Not.Null, "The Built-in Standard shader is unavailable for the shadow receiver readback."); + var material = new Material(shader) { hideFlags = HideFlags.HideAndDontSave }; + materials.Add(material); + return material; + } + /// Renders a controlled mesh with the requested real Unity light setup. /// The configured transient material. - /// The uniform mesh world normal. - /// The real main or additional light color. - /// The real directional or point light vector. - /// The seven SH globals for the render. - /// Whether the setup uses Point lights. - /// The number of ForcePixel lights to create. + /// The coherent light and spherical-harmonic input for one render. /// The center linear float readback color. private Color RenderWithLights( Material material, - Vector3 normal, - Vector4 lightColor, - Vector4 lightPosition, - ShCoefficients coefficients, - bool pointLight, - int lightCount + LightCaptureRequest request ) { var lightObjects = new List(); try { - InjectShGlobals(coefficients); - ApplyShProperties(coefficients); - meshFilter.sharedMesh = CreateNormalControlledQuad(normal); + InjectShGlobals(request.coefficients); + ApplyShProperties(request.coefficients); + meshFilter.sharedMesh = CreateNormalControlledQuad(request.normal); renderer.sharedMaterial = material; renderer.enabled = true; - CreateLights(lightObjects, lightColor, lightPosition, pointLight, lightCount); + CreateLights(lightObjects, request); camera.Render(); Assert.That( camera.actualRenderingPath, @@ -357,19 +866,13 @@ private void ApplyShProperties(ShCoefficients coefficients) /// Creates real ForcePixel lights on the isolated preview-scene layer. /// Receives the caller-owned light GameObjects immediately after allocation. - /// The real main or additional light color. - /// The directional or point light vector. - /// Whether the setup uses Point lights. - /// The number of ForcePixel lights to create. + /// The coherent light input for every generated light. private void CreateLights( List lightObjects, - Vector4 lightColor, - Vector4 lightPosition, - bool pointLight, - int lightCount + LightCaptureRequest request ) { - for (int index = 0; index < lightCount; index++) + for (int index = 0; index < request.lightCount; index++) { GameObject lightObject = CreateHiddenObject( "PureBase Toon Lighting Contract Light " + index, @@ -377,26 +880,17 @@ int lightCount ); Light light = lightObject.AddComponent(); light.renderMode = LightRenderMode.ForcePixel; - light.color = new Color(lightColor.x, lightColor.y, lightColor.z, 1.0f).gamma; + light.color = new Color(request.lightColor.x, request.lightColor.y, request.lightColor.z, 1.0f).gamma; light.intensity = 1.0f; light.cullingMask = 1 << FixtureLayer; - if (pointLight) + light.type = request.lightType; + light.cookie = request.cookie; + if (request.lightType == LightType.Directional) { - light.type = LightType.Point; - light.range = 4.0f; - lightObject.transform.position = new Vector3( - lightPosition.x, - lightPosition.y, - lightPosition.z - ); - } - else - { - light.type = LightType.Directional; Vector3 direction = new Vector3( - lightPosition.x, - lightPosition.y, - lightPosition.z + request.lightPosition.x, + request.lightPosition.y, + request.lightPosition.z ).normalized; Assert.That( direction, @@ -405,6 +899,20 @@ int lightCount ); lightObject.transform.rotation = Quaternion.LookRotation(-direction, Vector3.up); } + else + { + light.range = request.range; + light.spotAngle = request.spotAngle; + lightObject.transform.position = new Vector3( + request.lightPosition.x, + request.lightPosition.y, + request.lightPosition.z + ); + if (request.lightType == LightType.Spot) + { + lightObject.transform.rotation = Quaternion.LookRotation(Vector3.forward, Vector3.up); + } + } } } @@ -430,6 +938,31 @@ private GameObject CreateHiddenObject(string name, List objects) return gameObject; } + /// Creates one hidden receiver-scene object on the capture layer. + /// The isolated regular additive scene. + /// The diagnostic object name. + /// The caller-owned temporary GameObject. + private static GameObject CreateShadowSceneObject(Scene receiverScene, string name) + { + var gameObject = new GameObject(name) + { + hideFlags = HideFlags.HideAndDontSave, + layer = FixtureLayer, + }; + SceneManager.MoveGameObjectToScene(gameObject, receiverScene); + return gameObject; + } + + /// Destroys one temporary regular-scene object when it was allocated. + /// The caller-owned temporary object. + private static void DestroyShadowSceneObject(GameObject gameObject) + { + if (gameObject != null) + { + UnityEngine.Object.DestroyImmediate(gameObject); + } + } + /// Releases command-buffer, temporary objects, material, texture, target, and render-mesh resources. private void ReleaseRenderResources() { @@ -503,6 +1036,8 @@ private void RestoreCallerState() RenderTexture.active = activeRenderTexture; QualitySettings.pixelLightCount = pixelLightCount; + QualitySettings.shadows = shadowQuality; + QualitySettings.shadowDistance = shadowDistance; if (activeScene.IsValid() && activeScene.isLoaded) { SceneManager.SetActiveScene(activeScene); @@ -580,6 +1115,56 @@ private Mesh CreateNormalControlledQuad(Vector3 normal) return result; } + /// Creates the horizontal receiver mesh used for the directional shadow region readback. + /// The caller-owned transient receiver mesh. + private Mesh CreateShadowReceiverMesh() + { + var result = new Mesh { hideFlags = HideFlags.HideAndDontSave }; + meshes.Add(result); + result.vertices = new[] + { + new Vector3(-2.75f, 0.0f, -2.75f), + new Vector3(-2.75f, 0.0f, 2.75f), + new Vector3(2.75f, 0.0f, 2.75f), + new Vector3(2.75f, 0.0f, -2.75f), + }; + result.uv = new[] { Vector2.zero, Vector2.up, Vector2.one, Vector2.right }; + result.triangles = new[] { 0, 1, 2, 0, 2, 3 }; + result.normals = new[] { Vector3.up, Vector3.up, Vector3.up, Vector3.up }; + result.tangents = new[] + { + new Vector4(1.0f, 0.0f, 0.0f, 1.0f), + new Vector4(1.0f, 0.0f, 0.0f, 1.0f), + new Vector4(1.0f, 0.0f, 0.0f, 1.0f), + new Vector4(1.0f, 0.0f, 0.0f, 1.0f), + }; + result.RecalculateBounds(); + return result; + } + + /// Creates the hidden cube mesh used only to cast one directional diagnostic shadow. + /// The caller-owned transient caster mesh. + private Mesh CreateShadowCasterMesh() + { + var result = new Mesh { hideFlags = HideFlags.HideAndDontSave }; + meshes.Add(result); + result.vertices = new[] + { + new Vector3(-0.5f, -0.5f, -0.5f), new Vector3(-0.5f, -0.5f, 0.5f), + new Vector3(-0.5f, 0.5f, -0.5f), new Vector3(-0.5f, 0.5f, 0.5f), + new Vector3(0.5f, -0.5f, -0.5f), new Vector3(0.5f, -0.5f, 0.5f), + new Vector3(0.5f, 0.5f, -0.5f), new Vector3(0.5f, 0.5f, 0.5f), + }; + result.triangles = new[] + { + 0, 2, 3, 0, 3, 1, 4, 5, 7, 4, 7, 6, + 0, 1, 5, 0, 5, 4, 2, 6, 7, 2, 7, 3, + 0, 4, 6, 0, 6, 2, 1, 3, 7, 1, 7, 5, + }; + result.RecalculateBounds(); + return result; + } + /// Reads the center pixel while restoring the caller's active render target. /// The center linear color. private Color ReadCenterPixel() @@ -597,6 +1182,547 @@ private Color ReadCenterPixel() RenderTexture.active = previous; } } + + /// Reads the full transient target while restoring the caller's active render target. + /// The linear HDR receiver pixels. + private Color[] ReadPixels() + { + RenderTexture previous = RenderTexture.active; + try + { + RenderTexture.active = target; + readback.ReadPixels(new Rect(0.0f, 0.0f, 64.0f, 64.0f), 0, 0); + readback.Apply(false, false); + return readback.GetPixels(); + } + finally + { + RenderTexture.active = previous; + } + } + + /// Reads an arbitrary transient shadow target while restoring the active render target. + /// The completed shadow render target. + /// The transient CPU readback texture. + /// The copied linear HDR pixels. + private static Color[] ReadPixels(RenderTexture source, Texture2D destination) + { + RenderTexture previous = RenderTexture.active; + try + { + RenderTexture.active = source; + destination.ReadPixels(new Rect(0.0f, 0.0f, 64.0f, 64.0f), 0, 0); + destination.Apply(false, false); + return destination.GetPixels(); + } + finally + { + RenderTexture.active = previous; + } + } + + /// Computes a region mean from all finite opaque receiver samples rather than one fragile pixel. + /// The complete receiver readback. + /// The observed region statistics. + private static ShadowReceiverObservation MeasureReceiverRegion(Color[] pixels) + { + var sum = Color.black; + var count = 0; + foreach (Color pixel in pixels) + { + if (pixel.a < 0.99f) + { + continue; + } + + sum += pixel; + count++; + } + + return new ShadowReceiverObservation(count, count == 0 ? Color.black : sum / count); + } + } + + /// Stores a finite mean RGB measurement for one shadow receiver region. + [SuppressMessage("SonarAnalyzer.CSharp", "S3898", Justification = "Field assertions are the only intended contract for this private test carrier; it has no equality or hash-based use.")] + private readonly struct ShadowReceiverObservation + { + /// Initializes one region observation. + /// The number of opaque receiver samples. + /// The receiver's mean linear color. + public ShadowReceiverObservation(int sampleCount, Color meanColor) + { + this.sampleCount = sampleCount; + this.meanColor = meanColor; + } + + /// Gets the count of receiver samples contributing to the mean. + public int sampleCount { get; } + + /// Gets the region's mean linear color. + public Color meanColor { get; } + } + + /// Temporarily selects and imports only the fixed Toon shadow host without persisting Shader-Core settings. + private sealed class ToonShadowHostSelectionScope : IDisposable + { + private const string ShaderCoreAssemblyName = "jp.lilxyzw.shadercore"; + private const string ProjectSettingsTypeName = "jp.lilxyzw.shadercore.ProjectSettings"; + private const string ShaderSettingsFieldName = "shaderSettings"; + private const string ShaderNameFieldName = "shadername"; + private const string ModulesFieldName = "modules"; + private const string MultiModulesFieldName = "multiModules"; + private const string MultiModuleNameFieldName = "name"; + private const string MultiModuleCountFieldName = "count"; + private const string ToonShadowShaderName = "PureBase/Tests/ShaderCore/ToonShadow"; + private const string ToonShadowModuleId = "jp.penguin.purebase.tests.shadercore.toonshadow"; + private const string ToonShadowHostAssetPath = "Packages/jp.penguin.purebase/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow.scshader"; + private const string ProjectSettingsRelativePath = "ProjectSettings/jp.lilxyzw.shadercore.asset"; + + private readonly UnityEngine.Object settings; + private readonly ToonShadowSettingsRow originalRow; + private readonly string projectSettingsHash; + private bool temporarySelectionApplied; + private bool disposed; + + /// Captures the original fixed-host row, applies a temporary selection, and synchronously imports only its host. + public ToonShadowHostSelectionScope() + { + settings = GetProjectSettings(); + projectSettingsHash = GetFileSha256(GetProjectSettingsPath()); + try + { + using (var serializedSettings = new SerializedObject(settings)) + { + SerializedProperty settingsProperty = GetShaderSettingsProperty(serializedSettings); + originalRow = ReadToonShadowRow(settingsProperty); + WriteTemporaryToonShadowRow(settingsProperty); + serializedSettings.ApplyModifiedPropertiesWithoutUndo(); + temporarySelectionApplied = true; + } + + AssetDatabase.ImportAsset( + ToonShadowHostAssetPath, + ImportAssetOptions.ForceSynchronousImport | ImportAssetOptions.ForceUpdate + ); + } + catch + { + if (temporarySelectionApplied) + { + RestoreAndAssertUnchanged(); + } + + throw; + } + } + + /// Restores only the captured ToonShadow row without reimporting or saving Shader-Core settings. + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + if (temporarySelectionApplied) + { + RestoreAndAssertUnchanged(); + } + } + + /// Restores the temporary row, then checks its semantic state and the persisted ProjectSettings bytes. + private void RestoreAndAssertUnchanged() + { + using (var serializedSettings = new SerializedObject(settings)) + { + SerializedProperty settingsProperty = GetShaderSettingsProperty(serializedSettings); + RestoreToonShadowRow(settingsProperty); + serializedSettings.ApplyModifiedPropertiesWithoutUndo(); + } + + temporarySelectionApplied = false; + Assert.That( + GetFileSha256(GetProjectSettingsPath()), + Is.EqualTo(projectSettingsHash), + "The temporary ToonShadow host selection must not persist Shader-Core ProjectSettings." + ); + using (var serializedSettings = new SerializedObject(settings)) + { + ToonShadowSettingsRow restoredRow = ReadToonShadowRow( + GetShaderSettingsProperty(serializedSettings) + ); + Assert.That( + restoredRow.Equals(originalRow), + Is.True, + "The temporary ToonShadow host selection must restore only its original serialized row." + ); + } + } + + /// Gets the loaded Shader-Core ProjectSettings singleton without invoking its persistence API. + private static UnityEngine.Object GetProjectSettings() + { + Assembly shaderCoreAssembly = null; + foreach (Assembly candidate in AppDomain.CurrentDomain.GetAssemblies()) + { + if (candidate.GetName().Name == ShaderCoreAssemblyName) + { + shaderCoreAssembly = candidate; + break; + } + } + + Type settingsType = shaderCoreAssembly?.GetType(ProjectSettingsTypeName, false); + Assert.That( + settingsType, + Is.Not.Null, + "Shader-Core ProjectSettings was not loaded." + ); + Type singletonType = typeof(ScriptableSingleton<>).MakeGenericType(settingsType); + PropertyInfo instanceProperty = singletonType.GetProperty( + "instance", + BindingFlags.Public | BindingFlags.Static + ); + UnityEngine.Object resolvedSettings = instanceProperty?.GetValue(null) as UnityEngine.Object; + Assert.That( + resolvedSettings, + Is.Not.Null, + "Shader-Core ProjectSettings singleton was unavailable." + ); + return resolvedSettings; + } + + /// Gets the validated serialized Shader-Core selection array. + private static SerializedProperty GetShaderSettingsProperty(SerializedObject serializedSettings) + { + SerializedProperty settingsProperty = serializedSettings.FindProperty( + ShaderSettingsFieldName + ); + Assert.That( + settingsProperty, + Is.Not.Null.And.Property("isArray").True, + "Shader-Core ProjectSettings did not expose the expected shaderSettings array." + ); + return settingsProperty; + } + + /// Reads only the original ToonShadow row, rejecting duplicate target rows before mutation. + private static ToonShadowSettingsRow ReadToonShadowRow(SerializedProperty settingsProperty) + { + int rowIndex = FindToonShadowRowIndex(settingsProperty); + if (rowIndex < 0) + { + return ToonShadowSettingsRow.Missing; + } + + SerializedProperty row = settingsProperty.GetArrayElementAtIndex(rowIndex); + return new ToonShadowSettingsRow( + true, + ReadStringArray(row.FindPropertyRelative(ModulesFieldName)), + ReadMultiModules(row.FindPropertyRelative(MultiModulesFieldName)) + ); + } + + /// Upserts only the target row with its required one-module selection. + private static void WriteTemporaryToonShadowRow(SerializedProperty settingsProperty) + { + int rowIndex = FindToonShadowRowIndex(settingsProperty); + if (rowIndex < 0) + { + rowIndex = settingsProperty.arraySize; + settingsProperty.InsertArrayElementAtIndex(rowIndex); + } + + SerializedProperty row = settingsProperty.GetArrayElementAtIndex(rowIndex); + row.FindPropertyRelative(ShaderNameFieldName).stringValue = ToonShadowShaderName; + WriteStringArray(row.FindPropertyRelative(ModulesFieldName), new[] { ToonShadowModuleId }); + WriteMultiModules(row.FindPropertyRelative(MultiModulesFieldName), Array.Empty()); + } + + /// Restores only the target row to its captured presence and exact module collections. + private void RestoreToonShadowRow(SerializedProperty settingsProperty) + { + int rowIndex = FindToonShadowRowIndex(settingsProperty); + if (!originalRow.present) + { + Assert.That( + rowIndex, + Is.GreaterThanOrEqualTo(0), + "The temporary ToonShadow row disappeared before it could be removed." + ); + settingsProperty.DeleteArrayElementAtIndex(rowIndex); + return; + } + + Assert.That( + rowIndex, + Is.GreaterThanOrEqualTo(0), + "The original ToonShadow row disappeared before it could be restored." + ); + SerializedProperty row = settingsProperty.GetArrayElementAtIndex(rowIndex); + row.FindPropertyRelative(ShaderNameFieldName).stringValue = ToonShadowShaderName; + WriteStringArray(row.FindPropertyRelative(ModulesFieldName), originalRow.modules); + WriteMultiModules(row.FindPropertyRelative(MultiModulesFieldName), originalRow.multiModules); + } + + /// Finds the sole ToonShadow row without reading or changing unrelated module-selection rows. + private static int FindToonShadowRowIndex(SerializedProperty settingsProperty) + { + var foundIndex = -1; + for (var index = 0; index < settingsProperty.arraySize; index++) + { + SerializedProperty shaderName = settingsProperty + .GetArrayElementAtIndex(index) + .FindPropertyRelative(ShaderNameFieldName); + if (shaderName == null || shaderName.stringValue != ToonShadowShaderName) + { + continue; + } + + Assert.That( + foundIndex, + Is.EqualTo(-1), + "Shader-Core ProjectSettings contains duplicate ToonShadow rows." + ); + foundIndex = index; + } + + return foundIndex; + } + + /// Copies an ordered serialized string list without retaining SerializedProperty instances. + private static string[] ReadStringArray(SerializedProperty property) + { + Assert.That(property, Is.Not.Null.And.Property("isArray").True); + var values = new string[property.arraySize]; + for (var index = 0; index < property.arraySize; index++) + { + values[index] = property.GetArrayElementAtIndex(index).stringValue; + } + + return values; + } + + /// Writes an ordered serialized string list. + private static void WriteStringArray(SerializedProperty property, string[] values) + { + Assert.That(property, Is.Not.Null.And.Property("isArray").True); + property.arraySize = values.Length; + for (var index = 0; index < values.Length; index++) + { + property.GetArrayElementAtIndex(index).stringValue = values[index]; + } + } + + /// Copies the ToonShadow multi-module selection exactly. + private static MultiModuleSetting[] ReadMultiModules(SerializedProperty property) + { + Assert.That(property, Is.Not.Null.And.Property("isArray").True); + var values = new MultiModuleSetting[property.arraySize]; + for (var index = 0; index < property.arraySize; index++) + { + SerializedProperty value = property.GetArrayElementAtIndex(index); + values[index] = new MultiModuleSetting( + value.FindPropertyRelative(MultiModuleNameFieldName).stringValue, + value.FindPropertyRelative(MultiModuleCountFieldName).intValue + ); + } + + return values; + } + + /// Writes the ToonShadow multi-module selection exactly. + private static void WriteMultiModules( + SerializedProperty property, + MultiModuleSetting[] values + ) + { + Assert.That(property, Is.Not.Null.And.Property("isArray").True); + property.arraySize = values.Length; + for (var index = 0; index < values.Length; index++) + { + SerializedProperty value = property.GetArrayElementAtIndex(index); + value.FindPropertyRelative(MultiModuleNameFieldName).stringValue = values[index].name; + value.FindPropertyRelative(MultiModuleCountFieldName).intValue = values[index].count; + } + } + + /// Gets the persistent Shader-Core ProjectSettings path for byte-level non-persistence checks. + private static string GetProjectSettingsPath() + { + string projectRoot = Directory.GetParent(Application.dataPath).FullName; + string path = Path.Combine( + projectRoot, + ProjectSettingsRelativePath.Replace('/', Path.DirectorySeparatorChar) + ); + Assert.That(path, Does.Exist, "Shader-Core ProjectSettings asset was not found."); + return path; + } + + /// Returns one lowercase SHA-256 digest for persisted-state equality checks. + private static string GetFileSha256(string path) + { + using (SHA256 hasher = SHA256.Create()) + using (FileStream stream = File.OpenRead(path)) + { + return BitConverter.ToString(hasher.ComputeHash(stream)) + .Replace("-", string.Empty) + .ToLowerInvariant(); + } + } + + /// Stores one multi-module name and count without retaining SerializedProperty state. + private readonly struct MultiModuleSetting : IEquatable + { + /// Initializes one multi-module selection. + public MultiModuleSetting(string name, int count) + { + this.name = name; + this.count = count; + } + + /// Gets the module identifier. + public string name { get; } + + /// Gets the selected property count. + public int count { get; } + + /// Compares one multi-module selection exactly. + public bool Equals(MultiModuleSetting other) + { + return name == other.name && count == other.count; + } + + /// + public override bool Equals(object obj) + { + return obj is MultiModuleSetting other && Equals(other); + } + + /// + public override int GetHashCode() + { + unchecked + { + return ((name == null ? 0 : name.GetHashCode()) * 31) + count; + } + } + } + + /// Stores only the captured ToonShadow row presence and exact module collections. + private readonly struct ToonShadowSettingsRow : IEquatable + { + /// Initializes one captured ToonShadow selection row. + public ToonShadowSettingsRow( + bool present, + string[] modules, + MultiModuleSetting[] multiModules + ) + { + this.present = present; + this.modules = modules; + this.multiModules = multiModules; + } + + /// Gets a missing ToonShadow row capture. + public static ToonShadowSettingsRow Missing => new ToonShadowSettingsRow( + false, + Array.Empty(), + Array.Empty() + ); + + /// Gets whether the original ToonShadow row was present. + public bool present { get; } + + /// Gets the original ordered module selection. + public string[] modules { get; } + + /// Gets the original ordered multi-module selection. + public MultiModuleSetting[] multiModules { get; } + + /// Compares one captured ToonShadow row semantically and in selection order. + public bool Equals(ToonShadowSettingsRow other) + { + if (present != other.present) + { + return false; + } + + if (!ReferenceEquals(modules, other.modules)) + { + if (modules == null || other.modules == null || modules.Length != other.modules.Length) + { + return false; + } + + for (var index = 0; index < modules.Length; index++) + { + if (modules[index] != other.modules[index]) + { + return false; + } + } + } + + if (ReferenceEquals(multiModules, other.multiModules)) + { + return true; + } + + if (multiModules == null || other.multiModules == null || multiModules.Length != other.multiModules.Length) + { + return false; + } + + for (var index = 0; index < multiModules.Length; index++) + { + if (!multiModules[index].Equals(other.multiModules[index])) + { + return false; + } + } + + return true; + } + + /// + public override bool Equals(object obj) + { + return obj is ToonShadowSettingsRow other && Equals(other); + } + + /// + public override int GetHashCode() + { + unchecked + { + var hash = present ? 1 : 0; + hash = (hash * 31) + (modules == null ? 0 : 1); + if (modules != null) + { + hash = (hash * 31) + modules.Length; + foreach (string module in modules) + { + hash = (hash * 31) + (module == null ? 0 : module.GetHashCode()); + } + } + + hash = (hash * 31) + (multiModules == null ? 0 : 1); + if (multiModules != null) + { + hash = (hash * 31) + multiModules.Length; + foreach (MultiModuleSetting multiModule in multiModules) + { + hash = (hash * 31) + multiModule.GetHashCode(); + } + } + + return hash; + } + } + } } } } diff --git a/Tests/Daily/Editor/PureBaseToonLightingContractTests.Shadow.cs b/Tests/Daily/Editor/PureBaseToonLightingContractTests.Shadow.cs new file mode 100644 index 00000000..e399fe50 --- /dev/null +++ b/Tests/Daily/Editor/PureBaseToonLightingContractTests.Shadow.cs @@ -0,0 +1,573 @@ +/* + * 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 numerical and additional-light contracts for Toon direct-light visibility attenuation. + +using System.Diagnostics.CodeAnalysis; +using NUnit.Framework; +using UnityEditor; +using UnityEngine.Rendering; +using UnityEngine; + +namespace PureBase.Tests.Daily +{ + /// Defines numerical and additional-light contracts for Toon direct-light visibility attenuation. + [SuppressMessage("SonarAnalyzer.CSharp", "S2333", Justification = "This declaration must remain partial because the test fixture is split between its base, runtime capture, and shadow oracle source files.")] + public sealed partial class PureBaseToonLightingContractTests + { + /// Requires effective visibility to attenuate Toon direct radiance once while leaving direction weighting independent. + [Test] + public void ToonShadowSeparationOracleConsumesPublishedVisibilityOnlyInDirectRadiance() + { + ToonShadowInputs visible = new ToonShadowInputs(0.8f, 0.65f, 1.0f, 0.4f); + ToonShadowInputs shadowed = new ToonShadowInputs(0.8f, 0.65f, 0.25f, 0.4f); + ToonShadowObservation visibleObservation = EvaluateToonShadowContract(visible); + ToonShadowObservation shadowedObservation = EvaluateToonShadowContract(shadowed); + + Assert.That(shadowedObservation.directRadiance, Is.LessThan(visibleObservation.directRadiance - 0.02f)); + Assert.That( + shadowedObservation.directRadiance, + Is.EqualTo(visibleObservation.directRadiance * shadowed.effectiveVisibility).Within(OracleTolerance) + ); + Assert.That(shadowedObservation.directionWeight, Is.EqualTo(visibleObservation.directionWeight).Within(OracleTolerance)); + Assert.That(shadowedObservation.publishedVisibility, Is.EqualTo(0.25f).Within(OracleTolerance)); + Assert.That(visibleObservation.fullAttenuation, Is.EqualTo(0.65f).Within(OracleTolerance)); + Assert.That(shadowedObservation.fullAttenuation, Is.EqualTo(0.1625f).Within(OracleTolerance)); + } + + /// Requires Point and Spot non-shadow attenuation to change both direct radiance and aggregate direction weight. + [Test] + public void ToonPointAndSpotNonShadowAttenuationOracleChangesDirectRadianceAndDirectionWeight() + { + ToonShadowObservation pointNear = EvaluateToonShadowContract(new ToonShadowInputs(0.8f, 0.8f, 1.0f, 0.4f)); + ToonShadowObservation pointRangeEdge = EvaluateToonShadowContract(new ToonShadowInputs(0.8f, 0.2f, 1.0f, 0.4f)); + ToonShadowObservation spotInside = EvaluateToonShadowContract(new ToonShadowInputs(0.8f, 0.75f, 1.0f, 0.4f)); + ToonShadowObservation spotConeEdge = EvaluateToonShadowContract(new ToonShadowInputs(0.8f, 0.15f, 1.0f, 0.4f)); + + Assert.That(pointRangeEdge.directRadiance, Is.LessThan(pointNear.directRadiance - 0.02f)); + Assert.That(pointRangeEdge.directionWeight, Is.LessThan(pointNear.directionWeight - 0.02f)); + Assert.That(spotConeEdge.directRadiance, Is.LessThan(spotInside.directRadiance - 0.02f)); + Assert.That(spotConeEdge.directionWeight, Is.LessThan(spotInside.directionWeight - 0.02f)); + } + + /// Requires Point and Spot non-shadow attenuation to remain observable in isolated ForwardAdd rendering. + [Test] + public void ToonForwardAddPointAndSpotRetainFiniteRangeConeShAndDestinationAlphaContracts() + { + using (var capture = new ToonLightingCaptureScope()) + { + Vector4 color = new Vector4(0.3f, 0.2f, 0.1f, 1.0f); + Color pointNear = capture.RenderAdditionalLight("PureBase/Toon", Vector3.back, color, new Vector4(0.0f, 0.0f, -2.0f, 1.0f), LightType.Point, 4.0f, 30.0f); + Color pointEdge = capture.RenderAdditionalLight("PureBase/Toon", Vector3.back, color, new Vector4(0.0f, 0.0f, -3.8f, 1.0f), LightType.Point, 4.0f, 30.0f); + Color spotInside = capture.RenderAdditionalLight("PureBase/Toon", Vector3.back, color, new Vector4(0.0f, 0.0f, -2.0f, 1.0f), LightType.Spot, 4.0f, 35.0f); + Color spotOutside = capture.RenderAdditionalLight("PureBase/Toon", Vector3.back, color, new Vector4(2.0f, 0.0f, -2.0f, 1.0f), LightType.Spot, 4.0f, 20.0f); + Color pointWithSh = capture.RenderAdditionalLight("PureBase/Toon", Vector3.back, color, new Vector4(0.0f, 0.0f, -2.0f, 1.0f), LightType.Point, 4.0f, 30.0f, ShCoefficients.FixedOracle); + Color spotWithSh = capture.RenderAdditionalLight("PureBase/Toon", Vector3.back, color, new Vector4(0.0f, 0.0f, -2.0f, 1.0f), LightType.Spot, 4.0f, 35.0f, ShCoefficients.FixedOracle); + + AssertFinite(pointNear, "Toon ForwardAdd Point near contribution"); + AssertFinite(pointEdge, "Toon ForwardAdd Point range-edge contribution"); + AssertFinite(spotInside, "Toon ForwardAdd Spot inside-cone contribution"); + AssertFinite(spotOutside, "Toon ForwardAdd Spot outside-cone contribution"); + AssertFinite(pointWithSh, "Toon ForwardAdd Point SH-isolated contribution"); + AssertFinite(spotWithSh, "Toon ForwardAdd Spot SH-isolated contribution"); + Assert.That(RgbMagnitude(pointNear), Is.GreaterThan(0.001f)); + Assert.That(RgbMagnitude(spotInside), Is.GreaterThan(0.001f)); + Assert.That(RgbMagnitude(pointEdge), Is.LessThan(RgbMagnitude(pointNear) - 0.01f)); + Assert.That(RgbMagnitude(spotOutside), Is.LessThan(0.001f)); + Assert.That(MaximumRgbDifference(pointNear, pointWithSh), Is.LessThanOrEqualTo(0.002f)); + Assert.That(MaximumRgbDifference(spotInside, spotWithSh), Is.LessThanOrEqualTo(0.002f)); + Assert.That(pointEdge.a, Is.EqualTo(pointNear.a).Within(0.002f)); + Assert.That(spotInside.a, Is.EqualTo(spotOutside.a).Within(0.002f)); + } + } + + /// Requires the fixed Toon host to publish directional visibility through all three selected phases. + [Test] + public void FixedToonShadowHostPublishesFinitePhaseLocalRgbVisibilityForNoneHardAndSoft() + { + using (var selection = new ToonShadowHostSelectionScope()) + using (var capture = new ToonLightingCaptureScope()) + { + AssertImportedToonShadowGeneratedSource(); + ShadowReceiverObservation none = capture.RenderDirectionalShadowReceiver( + "PureBase/Tests/ShaderCore/ToonShadow", + LightShadows.None + ); + ShadowReceiverObservation hard = capture.RenderDirectionalShadowReceiver( + "PureBase/Tests/ShaderCore/ToonShadow", + LightShadows.Hard + ); + ShadowReceiverObservation soft = capture.RenderDirectionalShadowReceiver( + "PureBase/Tests/ShaderCore/ToonShadow", + LightShadows.Soft + ); + + AssertShadowReceiverFinite(none, "Toon shadow host None"); + AssertShadowReceiverFinite(hard, "Toon shadow host Hard"); + AssertShadowReceiverFinite(soft, "Toon shadow host Soft"); + AssertPhaseChannelsAgree(none.meanColor, "Toon shadow host None"); + AssertPhaseChannelsAgree(hard.meanColor, "Toon shadow host Hard"); + AssertPhaseChannelsAgree(soft.meanColor, "Toon shadow host Soft"); + AssertPhaseVisibilityBoundaries(none, hard, soft); + } + } + + /// Requires PBR and Hybrid to retain real Hard-shadow response while Unlit remains shadow-invariant. + [Test] + public void NonToonDirectionalShadowControlsRetainLightingResponseAndUnlitInvariance() + { + using (var capture = new ToonLightingCaptureScope()) + { + AssertNonToonShadowControl(capture, "PureBase/PBR", true); + AssertNonToonShadowControl(capture, "PureBase/Hybrid", true); + AssertNonToonShadowControl(capture, "PureBase/Unlit", false); + } + } + + /// Checks that the temporary fixed-host selection produced all three phase diagnostics before runtime evidence is sampled. + private static void AssertImportedToonShadowGeneratedSource() + { + const string assetPath = "Packages/jp.penguin.purebase/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow.scshader"; + Shader shader = AssetDatabase.LoadAssetAtPath(assetPath); + Assert.That(shader, Is.Not.Null, "The temporary ToonShadow host import did not produce a shader."); + Assert.That(ShaderUtil.ShaderHasError(shader), Is.False, "The temporary ToonShadow host import produced shader compiler errors."); + + TextAsset generatedSource = null; + foreach (UnityEngine.Object asset in AssetDatabase.LoadAllAssetsAtPath(assetPath)) + { + if (asset is TextAsset textAsset && textAsset.name == "Shader Source") + { + generatedSource = textAsset; + break; + } + } + + Assert.That(generatedSource, Is.Not.Null, "The temporary ToonShadow host import did not produce generated source."); + StringAssert.Contains("PUREBASE_TEST_TOON_SHADOW_SENTINEL_LIGHT", generatedSource.text); + StringAssert.Contains("PUREBASE_TEST_TOON_SHADOW_SENTINEL_MODIFYLIGHT", generatedSource.text); + StringAssert.Contains("PUREBASE_TEST_TOON_SHADOW_SENTINEL_SHADE", generatedSource.text); + } + + /// Requires product Toon directional visibility to attenuate host-managed direct radiance. + [Test] + public void ToonProductDirectionalShadowAttenuatesDirectRadiance() + { + using (var capture = new ToonLightingCaptureScope()) + { + ShadowReceiverObservation none = capture.RenderDirectionalShadowReceiver( + "PureBase/Toon", + LightShadows.None + ); + ShadowReceiverObservation hard = capture.RenderDirectionalShadowReceiver( + "PureBase/Toon", + LightShadows.Hard + ); + ShadowReceiverObservation soft = capture.RenderDirectionalShadowReceiver( + "PureBase/Toon", + LightShadows.Soft + ); + + AssertShadowReceiverFinite(none, "Product Toon None"); + AssertShadowReceiverFinite(hard, "Product Toon Hard"); + AssertShadowReceiverFinite(soft, "Product Toon Soft"); + Assert.That( + RgbMagnitude(hard.meanColor), + Is.LessThan(RgbMagnitude(none.meanColor) - 0.02f), + "Hard directional visibility must measurably attenuate Toon host-managed direct radiance." + ); + Assert.That( + RgbMagnitude(soft.meanColor), + Is.LessThan(RgbMagnitude(none.meanColor) - 0.02f), + "Soft directional visibility must measurably attenuate Toon host-managed direct radiance." + ); + } + } + + /// Requires visibility to attenuate direct radiance without changing aggregate direction or the Toon SH band. + [Test] + public void ToonShadowVisibilityOracleLeavesAggregateDirectionAndShBandUnchanged() + { + ToonShadowInputs visibleInputs = new ToonShadowInputs(0.8f, 0.65f, 1.0f, 0.4f); + ToonShadowInputs shadowedInputs = new ToonShadowInputs(0.8f, 0.65f, 0.1f, 0.4f); + ToonShadowObservation visible = EvaluateToonShadowContract(visibleInputs); + ToonShadowObservation shadowed = EvaluateToonShadowContract(shadowedInputs); + Vector4 shAr = new Vector4(0.3f, 0.0f, 0.0f, 0.2f); + Vector3 normal = (Vector3.forward - 0.5f * Vector3.right).normalized; + Vector3 visibleDirection = EvaluateDominantDirection( + Vector3.forward * visible.directionWeight, + shAr, + Vector4.zero, + Vector4.zero + ); + Vector3 shadowedDirection = EvaluateDominantDirection( + Vector3.forward * shadowed.directionWeight, + shAr, + Vector4.zero, + Vector4.zero + ); + Color visibleBand = EvaluateTwoBandSh(normal, visibleDirection, shAr, Vector4.zero, Vector4.zero); + Color shadowedBand = EvaluateTwoBandSh(normal, shadowedDirection, shAr, Vector4.zero, Vector4.zero); + + Assert.That(shadowed.directRadiance, Is.LessThan(visible.directRadiance - 0.02f)); + Assert.That(shadowed.directionWeight, Is.EqualTo(visible.directionWeight).Within(OracleTolerance)); + Assert.That(Vector3.Distance(visibleDirection, shadowedDirection), Is.LessThanOrEqualTo(OracleTolerance)); + Assert.That(MaximumRgbDifference(visibleBand, shadowedBand), Is.LessThanOrEqualTo(OracleTolerance)); + } + + /// Requires a transient Directional Texture2D cookie to preserve white and suppress black contributions without persistent assets. + [Test] + public void ToonDirectionalCookieReadbacksAreSemanticAndTransient() + { + Texture2D whiteDirectionalCookie = CreateCookieTexture(Color.white); + Texture2D blackDirectionalCookie = CreateCookieTexture(Color.clear); + try + { + using (var capture = new ToonLightingCaptureScope()) + { + AssertCookieSemanticReadback( + capture, + new CookieReadbackCase + { + passName = "ForwardBase", + normal = Vector3.forward, + lightColor = new Vector4(0.45f, 0.35f, 0.25f, 1.0f), + lightPosition = new Vector4(0.0f, 0.0f, 1.0f, 0.0f), + lightType = LightType.Directional, + whiteCookie = whiteDirectionalCookie, + blackCookie = blackDirectionalCookie, + label = "Directional", + } + ); + } + } + finally + { + Object.DestroyImmediate(whiteDirectionalCookie); + Object.DestroyImmediate(blackDirectionalCookie); + } + } + + /// Requires a transient Point Cubemap cookie to preserve white, suppress black, and retain range attenuation without persistent assets. + [Test] + public void ToonPointCookieReadbacksAreSemanticAndTransient() + { + Cubemap whitePointCookie = CreatePointCookie(Color.white); + Cubemap blackPointCookie = CreatePointCookie(Color.clear); + try + { + using (var capture = new ToonLightingCaptureScope()) + { + AssertCookieSemanticReadback( + capture, + new CookieReadbackCase + { + passName = "ForwardAdd", + normal = Vector3.back, + lightColor = new Vector4(0.45f, 0.35f, 0.25f, 1.0f), + lightPosition = new Vector4(0.0f, 0.0f, -2.0f, 1.0f), + lightType = LightType.Point, + whiteCookie = whitePointCookie, + blackCookie = blackPointCookie, + label = "Point", + } + ); + AssertPointWhiteCookieRetainsRangeAttenuation(capture, whitePointCookie); + } + } + finally + { + Object.DestroyImmediate(whitePointCookie); + Object.DestroyImmediate(blackPointCookie); + } + } + + /// Warms each required Unity light-kind form with nonpersistent collections only. + [Test] + public void ProductLightKindsAndApplicableShadowFormsWarmWithoutPersistentVariants() + { + Assert.That(WarmAllLightKindVariants(), Is.EqualTo(56)); + } + + /// Asserts Hard-shadow response for lit models and invariance for the unlit control on one shared receiver route. + private static void AssertNonToonShadowControl(ToonLightingCaptureScope capture, string shaderName, bool expectShadowResponse) + { + ShadowReceiverObservation none = capture.RenderDirectionalShadowReceiver(shaderName, LightShadows.None); + ShadowReceiverObservation hard = capture.RenderDirectionalShadowReceiver(shaderName, LightShadows.Hard); + AssertShadowReceiverFinite(none, shaderName + " None"); + AssertShadowReceiverFinite(hard, shaderName + " Hard"); + if (expectShadowResponse) + { + Assert.That(RgbMagnitude(hard.meanColor), Is.LessThan(RgbMagnitude(none.meanColor) - 0.02f), shaderName + " must retain measurable Hard-shadow response."); + return; + } + + Assert.That(MaximumRgbDifference(none.meanColor, hard.meanColor), Is.LessThanOrEqualTo(0.02f), shaderName + " must remain invariant to the shared shadow route."); + } + + /// Checks the three independently written phase channels against the required visibility boundaries. + private static void AssertPhaseVisibilityBoundaries(ShadowReceiverObservation none, ShadowReceiverObservation hard, ShadowReceiverObservation soft) + { + foreach (float value in new[] { none.meanColor.r, none.meanColor.g, none.meanColor.b }) + { + Assert.That(value, Is.EqualTo(1.0f).Within(0.05f)); + } + + foreach (float value in new[] { hard.meanColor.r, hard.meanColor.g, hard.meanColor.b }) + { + Assert.That(value, Is.LessThanOrEqualTo(0.95f)); + } + + foreach (float value in new[] { soft.meanColor.r, soft.meanColor.g, soft.meanColor.b }) + { + Assert.That(value, Is.InRange(0.05f, 0.95f)); + } + + Assert.That(hard.meanColor.r, Is.LessThan(none.meanColor.r - 0.02f)); + Assert.That(soft.meanColor.r, Is.LessThan(none.meanColor.r - 0.02f)); + } + + /// Stores the fixed light and texture inputs for one semantic cookie readback. + private sealed class CookieReadbackCase + { + /// Gets or sets the product pass that receives the light. + public string passName { get; set; } + + /// Gets or sets the uniform mesh world normal. + public Vector3 normal { get; set; } + + /// Gets or sets the light color. + public Vector4 lightColor { get; set; } + + /// Gets or sets the directional vector or local-light position. + public Vector4 lightPosition { get; set; } + + /// Gets or sets the real Unity light type. + public LightType lightType { get; set; } + + /// Gets or sets the caller-owned white transmission cookie. + public Texture whiteCookie { get; set; } + + /// Gets or sets the caller-owned black transmission cookie. + public Texture blackCookie { get; set; } + + /// Gets or sets the assertion label. + public string label { get; set; } + + /// Creates one readback request for the supplied caller-owned cookie. + /// The cookie to apply to the transient Unity light. + /// The complete light capture request. + public LightCaptureRequest CreateRequest(Texture cookie) + { + return new LightCaptureRequest(cookie) + { + normal = normal, + lightColor = lightColor, + lightPosition = lightPosition, + coefficients = ShCoefficients.Zero, + lightType = lightType, + lightCount = 1, + }; + } + } + + /// Asserts semantic no-cookie, white-cookie, and black-cookie readbacks for one Unity light kind. + private static void AssertCookieSemanticReadback(ToonLightingCaptureScope capture, CookieReadbackCase readbackCase) + { + Color noCookieReadback = capture.RenderLightWithCookie("PureBase/Toon", readbackCase.passName, readbackCase.CreateRequest(null)); + Color whiteCookieReadback = capture.RenderLightWithCookie("PureBase/Toon", readbackCase.passName, readbackCase.CreateRequest(readbackCase.whiteCookie)); + Color blackCookieReadback = capture.RenderLightWithCookie("PureBase/Toon", readbackCase.passName, readbackCase.CreateRequest(readbackCase.blackCookie)); + AssertFinite(noCookieReadback, readbackCase.label + " no-cookie readback"); + AssertFinite(whiteCookieReadback, readbackCase.label + " white-cookie readback"); + AssertFinite(blackCookieReadback, readbackCase.label + " black-cookie readback"); + Assert.That(MaximumRgbDifference(noCookieReadback, whiteCookieReadback), Is.LessThanOrEqualTo(0.02f), readbackCase.label + " white cookie must preserve the no-cookie contribution."); + Assert.That(RgbMagnitude(blackCookieReadback), Is.LessThan(RgbMagnitude(whiteCookieReadback) - 0.02f), readbackCase.label + " black cookie must suppress the light contribution."); + Assert.That(whiteCookieReadback.a, Is.EqualTo(noCookieReadback.a).Within(0.002f), readbackCase.label + " white cookie must preserve destination alpha."); + Assert.That(blackCookieReadback.a, Is.EqualTo(noCookieReadback.a).Within(0.002f), readbackCase.label + " black cookie must preserve destination alpha."); + } + + /// Requires a white Point cubemap cookie to preserve the ordinary finite range falloff. + private static void AssertPointWhiteCookieRetainsRangeAttenuation(ToonLightingCaptureScope capture, Cubemap whiteCookie) + { + Vector4 color = new Vector4(0.45f, 0.35f, 0.25f, 1.0f); + Color near = capture.RenderLightWithCookie( + "PureBase/Toon", + "ForwardAdd", + new LightCaptureRequest(whiteCookie) + { + normal = Vector3.back, + lightColor = color, + lightPosition = new Vector4(0.0f, 0.0f, -2.0f, 1.0f), + coefficients = ShCoefficients.Zero, + lightType = LightType.Point, + lightCount = 1, + range = 4.0f, + } + ); + Color edge = capture.RenderLightWithCookie( + "PureBase/Toon", + "ForwardAdd", + new LightCaptureRequest(whiteCookie) + { + normal = Vector3.back, + lightColor = color, + lightPosition = new Vector4(0.0f, 0.0f, -3.8f, 1.0f), + coefficients = ShCoefficients.Zero, + lightType = LightType.Point, + lightCount = 1, + range = 4.0f, + } + ); + AssertFinite(near, "Point white-cookie near readback"); + AssertFinite(edge, "Point white-cookie range-edge readback"); + Assert.That(RgbMagnitude(edge), Is.LessThan(RgbMagnitude(near) - 0.02f)); + Assert.That(edge.a, Is.EqualTo(near.a).Within(0.002f)); + } + + /// Creates a transient LDR directional cookie that is never persisted to the AssetDatabase. + /// The exact cookie transmission color. + /// The caller-owned transient cookie. + private static Texture2D CreateCookieTexture(Color color) + { + var texture = new Texture2D(2, 2, TextureFormat.RGBA32, false, false) + { + hideFlags = HideFlags.HideAndDontSave, + filterMode = FilterMode.Point, + wrapMode = TextureWrapMode.Clamp, + }; + texture.SetPixels(new[] { color, color, color, color }); + texture.Apply(false, true); + return texture; + } + + /// Creates a transient LDR cubemap cookie for Unity Point-light cookie variants. + /// The exact cookie transmission color for every cubemap face. + /// The caller-owned transient Point cookie. + private static Cubemap CreatePointCookie(Color color) + { + var texture = new Cubemap(2, TextureFormat.RGBA32, false) + { + hideFlags = HideFlags.HideAndDontSave, + filterMode = FilterMode.Point, + wrapMode = TextureWrapMode.Clamp, + }; + foreach (CubemapFace face in new[] + { + CubemapFace.PositiveX, + CubemapFace.NegativeX, + CubemapFace.PositiveY, + CubemapFace.NegativeY, + CubemapFace.PositiveZ, + CubemapFace.NegativeZ, + }) + { + texture.SetPixels(new[] { color, color, color, color }, face); + } + + texture.Apply(false, true); + return texture; + } + + /// Asserts that one region contains finite opaque receiver data. + private static void AssertShadowReceiverFinite(ShadowReceiverObservation observation, string label) + { + Assert.That(observation.sampleCount, Is.GreaterThan(64), label + " requires a receiver region."); + AssertFinite(observation.meanColor, label + " mean RGB"); + } + + /// Asserts that phase-local red, green, and blue diagnostics publish one shadow visibility value. + private static void AssertPhaseChannelsAgree(Color value, string label) + { + Assert.That(Mathf.Abs(value.r - value.g), Is.LessThanOrEqualTo(0.02f), label + " red and green phase visibility disagree."); + Assert.That(Mathf.Abs(value.r - value.b), Is.LessThanOrEqualTo(0.02f), label + " red and blue phase visibility disagree."); + } + + /// Separates the inputs that the Toon host must retain for direct lighting and future shade phases. + [SuppressMessage("SonarAnalyzer.CSharp", "S3898", Justification = "Field assertions are the only intended contract for this private test carrier; it has no equality or hash-based use.")] + private readonly struct ToonShadowInputs + { + /// Initializes one direct-light attenuation and visibility sample. + /// The unattenuated direct scene-light color magnitude. + /// The distance, cone, and cookie attenuation. + /// Unity's effective per-light visibility. + /// The direct luminance used to weight aggregate direction. + public ToonShadowInputs(float sceneColor, float nonShadowAttenuation, float effectiveVisibility, float directionLuminance) + { + this.sceneColor = sceneColor; + this.nonShadowAttenuation = nonShadowAttenuation; + this.effectiveVisibility = effectiveVisibility; + this.directionLuminance = directionLuminance; + } + + /// Gets the unattenuated direct scene-light color magnitude. + public float sceneColor { get; } + + /// Gets the non-shadow attenuation. + public float nonShadowAttenuation { get; } + + /// Gets Unity's effective per-light visibility. + public float effectiveVisibility { get; } + + /// Gets the direct luminance used for aggregate direction. + public float directionLuminance { get; } + } + + /// Stores one evaluated Toon split-light observation. + [SuppressMessage("SonarAnalyzer.CSharp", "S3898", Justification = "Field assertions are the only intended contract for this private test carrier; it has no equality or hash-based use.")] + private readonly struct ToonShadowObservation + { + /// Initializes one evaluated Toon split-light observation. + /// The host-owned direct radiance. + /// The host-owned aggregate direction weight. + /// The visibility published to Toon phases. + /// The full attenuation retained by PBR and Hybrid. + public ToonShadowObservation(float directRadiance, float directionWeight, float publishedVisibility, float fullAttenuation) + { + this.directRadiance = directRadiance; + this.directionWeight = directionWeight; + this.publishedVisibility = publishedVisibility; + this.fullAttenuation = fullAttenuation; + } + + /// Gets the host-owned direct radiance. + public float directRadiance { get; } + + /// Gets the host-owned aggregate direction weight. + public float directionWeight { get; } + + /// Gets the visibility published to Toon phases. + public float publishedVisibility { get; } + + /// Gets full attenuation retained by PBR and Hybrid inputs. + public float fullAttenuation { get; } + } + + /// Evaluates the intended host contract without coupling the numerical oracle to product HLSL. + /// The split light inputs to evaluate. + /// The resulting Toon and full-attenuation observations. + private static ToonShadowObservation EvaluateToonShadowContract(ToonShadowInputs inputs) + { + float directRadiance = inputs.sceneColor * inputs.nonShadowAttenuation * inputs.effectiveVisibility; + float directionWeight = inputs.directionLuminance * inputs.nonShadowAttenuation; + float fullAttenuation = inputs.nonShadowAttenuation * inputs.effectiveVisibility; + return new ToonShadowObservation( + directRadiance, + directionWeight, + inputs.effectiveVisibility, + fullAttenuation + ); + } + } +} \ No newline at end of file diff --git a/Tests/Daily/Editor/PureBaseToonLightingContractTests.Shadow.cs.meta b/Tests/Daily/Editor/PureBaseToonLightingContractTests.Shadow.cs.meta new file mode 100644 index 00000000..9740c482 --- /dev/null +++ b/Tests/Daily/Editor/PureBaseToonLightingContractTests.Shadow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 63452a6c2ca522649b8fb5a6a6d914a0 +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 04aa3af7..5ebdff37 100644 --- a/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.cs +++ b/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.cs @@ -67,6 +67,15 @@ public sealed class PureBaseValidationSceneRegressionTests /// Identifies the persisted scene used when an isolated restoration test needs a non-canonical owner. private const string TestOwnerScenePath = "Assets/Pure-Base.unity"; + /// Identifies the root camera used by the reviewed BIRP baseline. + private const string LegacyBaselineCameraName = "Validation Camera"; + + /// Defines the reviewed baseline camera world position. + private static readonly Vector3 LegacyBaselineCameraPosition = new Vector3(0.0f, 4.0f, -16.0f); + + /// Defines the reviewed baseline camera vertical field of view. + private const float LegacyBaselineCameraFieldOfView = 50.0f; + /// Lists the product shaders expected in the canonical scene. private static readonly string[] ProductShaderNames = { @@ -113,6 +122,9 @@ public sealed class PureBaseValidationSceneRegressionTests /// Reports whether the latest transient capture disposed every tracked native resource. private static bool lastCaptureResourcesReleased; + /// Stores the latest canonical scene readback state for mismatch diagnostics. + private static string lastSceneReadbackState; + /// Ensures a missing baseline fails before Daily opens or changes the canonical scene. [Test] public void MissingBaselineFailsBeforeSceneMutation() @@ -1117,38 +1129,77 @@ public void CanonicalStaticLightmapCountIgnoresLoadedPersistedOwnerScene() [Test] public void CanonicalSceneMatchesCommittedBirpBaseline() { + if (!Application.isBatchMode) + { + Assert.Ignore( + "The strict BIRP baseline requires clean batchmode isolation and cannot run in an interactive Editor." + ); + } + SceneRegressionBaseline baseline = LoadBaseline(); - EditorStateSnapshot state = EditorStateSnapshot.Capture(); - Scene validationScene = default; - bool sceneWasLoaded = false; - bool sceneWasDirty = false; + ValidateRuntimeConfiguration(); + Scene validationScene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Additive); + Assert.That(validationScene.IsValid(), Is.True, "The canonical validation scene is invalid."); + Assert.That(validationScene.isLoaded, Is.True, "The canonical validation scene is not loaded."); + Assert.That( + validationScene.path, + Is.EqualTo(ScenePath), + "The canonical validation scene was loaded from an unexpected path." + ); + Assert.That( + SceneManager.SetActiveScene(validationScene), + Is.True, + "The canonical validation scene could not become active for strict baseline capture." + ); + Assert.That( + SceneManager.GetActiveScene(), + Is.EqualTo(validationScene), + "The canonical validation scene must be active for strict baseline capture." + ); - try + var loadedScenes = new List(SceneManager.sceneCount); + for (int index = 0; index < SceneManager.sceneCount; index++) { - ValidateRuntimeConfiguration(); - validationScene = SceneManager.GetSceneByPath(ScenePath); - sceneWasLoaded = validationScene.isLoaded; - if (!sceneWasLoaded) + loadedScenes.Add(SceneManager.GetSceneAt(index)); + } + + foreach (Scene scene in loadedScenes) + { + if (scene != validationScene) { - validationScene = EditorSceneManager.OpenScene( - ScenePath, - OpenSceneMode.Additive + Assert.That( + EditorSceneManager.CloseScene(scene, true), + Is.True, + $"The non-canonical scene '{scene.path}' could not be closed before strict baseline capture." ); } - - sceneWasDirty = validationScene.isDirty; - Assert.That( - SceneManager.SetActiveScene(validationScene), - Is.True, - "The canonical validation scene could not become active." - ); - SceneRegressionObservation observation = CaptureObservation(validationScene); - AssertObservationMatchesBaseline(observation, baseline); - } - finally - { - state.Restore(validationScene, sceneWasLoaded, sceneWasDirty); } + + Assert.That(validationScene.IsValid(), Is.True, "The canonical validation scene became invalid."); + Assert.That(validationScene.isLoaded, Is.True, "The canonical validation scene became unloaded."); + Assert.That( + SceneManager.GetActiveScene(), + Is.EqualTo(validationScene), + "The canonical validation scene must remain active after strict capture isolation." + ); + Assert.That( + SceneManager.sceneCount, + Is.EqualTo(1), + $"Strict baseline capture requires only the canonical scene to be loaded; loaded scenes: {DescribeLoadedScenePaths()}." + ); + Assert.That( + SceneManager.GetSceneAt(0).path, + Is.EqualTo(ScenePath), + "Strict baseline capture loaded an unexpected scene." + ); + Assert.That( + SceneManager.GetSceneAt(0).handle, + Is.EqualTo(validationScene.handle), + "Strict baseline capture retained an unexpected scene handle." + ); + + SceneRegressionObservation observation = CaptureObservation(validationScene); + AssertObservationMatchesBaseline(observation, baseline); } /// Loads the reviewed baseline without creating or updating it. @@ -1361,7 +1412,7 @@ SceneRegressionBaseline baseline AssertRange( observation.sceneVisiblePixelCount, baseline.sceneVisiblePixelCount, - "scene visible pixel count" + $"scene visible pixel count ({lastSceneReadbackState})" ); Assert.That(observation.metaAlbedo, Has.Length.EqualTo(baseline.metaAlbedo.Length)); for (int index = 0; index < baseline.metaAlbedo.Length; index++) @@ -1835,7 +1886,10 @@ SceneRegressionObservation observation try { camera.CopyFrom(sourceCamera); + camera.overrideSceneCullingMask = EditorSceneManager.GetSceneCullingMask(scene); camera.enabled = false; + lastSceneReadbackState = + $"scene='{scene.path}', camera='{sourceCamera.name}', position={sourceCamera.transform.position}, fov={sourceCamera.fieldOfView}, cullingMask={camera.overrideSceneCullingMask}, loadedScenes={DescribeLoadedScenePaths()}"; target.Create(); camera.targetTexture = target; camera.Render(); @@ -2255,21 +2309,72 @@ private static int WarmRepresentativeVariants() return warmedCount; } - /// Finds the enabled canonical scene camera. + /// Finds the enabled root camera used by the reviewed BIRP baseline. /// The scene to search. - /// The enabled camera. + /// The enabled reviewed baseline camera. private static Camera FindSceneCamera(Scene scene) { + GameObject baselineCameraRoot = null; foreach (GameObject root in scene.GetRootGameObjects()) { - foreach (Camera camera in root.GetComponentsInChildren(true)) + if (!string.Equals(root.name, LegacyBaselineCameraName, StringComparison.Ordinal)) + continue; + + if (baselineCameraRoot != null) { - if (camera.enabled) - return camera; + throw new AssertionException( + $"The canonical validation scene contains multiple root '{LegacyBaselineCameraName}' cameras." + ); } + + baselineCameraRoot = root; + } + + if (baselineCameraRoot == null) + { + throw new AssertionException( + $"The canonical validation scene has no root '{LegacyBaselineCameraName}' camera." + ); + } + + Camera baselineCamera = baselineCameraRoot.GetComponent(); + if (baselineCamera == null) + { + throw new AssertionException( + $"The root '{LegacyBaselineCameraName}' object has no Camera component." + ); + } + + Assert.That( + baselineCameraRoot.GetComponents(), + Has.Length.EqualTo(1), + $"The root baseline camera '{LegacyBaselineCameraName}' must have exactly one Camera component." + ); + + Assert.That( + baselineCamera.enabled, + Is.True, + $"The root baseline camera '{LegacyBaselineCameraName}' is disabled." + ); + Assert.That( + baselineCamera.transform.position, + Is.EqualTo(LegacyBaselineCameraPosition), + $"The root baseline camera '{LegacyBaselineCameraName}' has an unexpected position." + ); + Assert.That( + baselineCamera.fieldOfView, + Is.EqualTo(LegacyBaselineCameraFieldOfView).Within(0.0001f), + $"The root baseline camera '{LegacyBaselineCameraName}' has an unexpected field of view." + ); + + if (baselineCamera.transform.parent != null) + { + throw new AssertionException( + $"The reviewed baseline camera '{LegacyBaselineCameraName}' must be scene-root owned." + ); } - throw new AssertionException("The canonical validation scene has no enabled camera."); + return baselineCamera; } /// Gets the four committed product materials in fixed shader order. @@ -2951,11 +3056,14 @@ bool throwInsideScope { Scene ownerScene = GetOrOpenPersistedOwnerScene(); string ownerScenePath = ownerScene.path; - Assert.That( - SceneManager.SetActiveScene(ownerScene), - Is.True, - "The persisted owner scene could not become active before the canonical scene state is prepared." - ); + if (!SceneManager.GetActiveScene().Equals(ownerScene)) + { + Assert.That( + SceneManager.SetActiveScene(ownerScene), + Is.True, + "The persisted owner scene could not become active before the canonical scene state is prepared." + ); + } if (canonicalPreloaded && !validationScene.isLoaded) { validationScene = EditorSceneManager.OpenScene( @@ -3099,22 +3207,38 @@ bool throwInsideScope } } - /// Gets a loaded saved scene that can own active-scene settings during an isolated restoration test. - /// A loaded non-canonical scene. + /// Gets the loaded persisted owner scene that can own active-scene settings during an isolated restoration test. + /// The valid loaded persisted owner scene. private static Scene GetOrOpenPersistedOwnerScene() { - for (int sceneIndex = 0; sceneIndex < SceneManager.sceneCount; sceneIndex++) + Scene ownerScene = SceneManager.GetSceneByPath(TestOwnerScenePath); + if (ownerScene.IsValid() && ownerScene.isLoaded) { - Scene scene = SceneManager.GetSceneAt(sceneIndex); - if ( - scene.isLoaded - && !string.IsNullOrEmpty(scene.path) - && !string.Equals(scene.path, ScenePath, StringComparison.Ordinal) - ) - return scene; + return ownerScene; } - return EditorSceneManager.OpenScene(TestOwnerScenePath, OpenSceneMode.Additive); + if (ownerScene.IsValid()) + { + Assert.That( + EditorSceneManager.CloseScene(ownerScene, true), + Is.True, + $"The existing unloaded owner scene entry '{TestOwnerScenePath}' could not be removed before reopening." + ); + } + + EditorSceneManager.OpenScene(TestOwnerScenePath, OpenSceneMode.Additive); + ownerScene = SceneManager.GetSceneByPath(TestOwnerScenePath); + Assert.That( + ownerScene.IsValid(), + Is.True, + $"The persisted owner scene '{TestOwnerScenePath}' was invalid after reopening." + ); + Assert.That( + ownerScene.isLoaded, + Is.True, + $"The persisted owner scene '{TestOwnerScenePath}' was not loaded after reopening." + ); + return ownerScene; } /// Loads controlled fixtures defensively and restores only their original scene-manager entries. diff --git a/Tests/Daily/Editor/ShaderCoreTestHostManifestTests.cs b/Tests/Daily/Editor/ShaderCoreTestHostManifestTests.cs index dcc656a6..3c2f7a6f 100644 --- a/Tests/Daily/Editor/ShaderCoreTestHostManifestTests.cs +++ b/Tests/Daily/Editor/ShaderCoreTestHostManifestTests.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -68,57 +69,86 @@ public sealed class ShaderCoreTestHostManifestTests /// Ensures every fixed host has one non-empty shader name and module selection. [Test] - public void ManifestContainsElevenUniqueFixedHostSelections() + public void ManifestContainsTwelveUniqueFixedHostSelections() { var manifest = JsonUtility.FromJson(File.ReadAllText(GetManifestPath())); Assert.That(manifest, Is.Not.Null); Assert.That(manifest.schemaVersion, Is.EqualTo(1)); Assert.That(manifest.hosts, Is.Not.Null); - Assert.That(manifest.hosts.Length, Is.EqualTo(11)); + Assert.That(manifest.hosts.Length, Is.EqualTo(12)); + AssertHostSelections(manifest.hosts); + AssertManifestRuntimeContracts(manifest.hosts); + } + + /// Asserts unique shader names and required module and sentinel configuration for every host entry. + /// The manifest host entries to inspect. + private static void AssertHostSelections(HostManifestEntry[] hosts) + { var shaderNames = new System.Collections.Generic.HashSet( StringComparer.Ordinal ); - foreach (HostManifestEntry host in manifest.hosts) + foreach (HostManifestEntry host in hosts) { - Assert.That(host.shaderName, Is.Not.Empty); - Assert.That( - shaderNames.Add(host.shaderName), - Is.True, - $"Duplicate fixed host shader '{host.shaderName}'." - ); - - var moduleCount = string.IsNullOrEmpty(host.moduleUniqueId) - ? host.moduleUniqueIds?.Length ?? 0 - : 1; - Assert.That( - moduleCount, - Is.GreaterThan(0), - $"Host '{host.shaderName}' has no fixed module selection." - ); - Assert.That( - host.expectedSentinels, - Is.Not.Null.And.Not.Empty, - $"Host '{host.shaderName}' has no expected sentinels." - ); - Assert.That( - host.expectedPassSentinelCounts, - Is.Not.Null, - $"Host '{host.shaderName}' has no expected pass counts." - ); + AssertHostSelection(host, shaderNames); } + } + /// Asserts the expected aggregate runtime contract counts across all fixed hosts. + /// The manifest host entries to inspect. + private static void AssertManifestRuntimeContracts(HostManifestEntry[] hosts) + { Assert.That( - manifest.hosts.Count(HasConfiguredRuntimeDelta), + hosts.Count(HasConfiguredRuntimeDelta), Is.EqualTo(10), "Each phase host must declare one valid runtime delta and the module-order host must not." ); Assert.That( - manifest.hosts.Count(HasConfiguredModuleOrder), + hosts.Count(HasConfiguredModuleOrder), Is.EqualTo(1), "Only the module-order host must declare one valid module-order contract." ); + Assert.That( + hosts.Count(HasConfiguredRuntimeEvidence), + Is.EqualTo(1), + "Only the Toon shadow host must declare one valid phase-shadow runtime contract." + ); + } + + /// Asserts that one host has required unique module and sentinel configuration. + /// The host entry to inspect. + /// The set used to detect duplicate shader names. + private static void AssertHostSelection( + HostManifestEntry host, + System.Collections.Generic.HashSet shaderNames + ) + { + Assert.That(host.shaderName, Is.Not.Empty); + Assert.That( + shaderNames.Add(host.shaderName), + Is.True, + $"Duplicate fixed host shader '{host.shaderName}'." + ); + + var moduleCount = string.IsNullOrEmpty(host.moduleUniqueId) + ? host.moduleUniqueIds?.Length ?? 0 + : 1; + Assert.That( + moduleCount, + Is.GreaterThan(0), + $"Host '{host.shaderName}' has no fixed module selection." + ); + Assert.That( + host.expectedSentinels, + Is.Not.Null.And.Not.Empty, + $"Host '{host.shaderName}' has no expected sentinels." + ); + Assert.That( + host.expectedPassSentinelCounts, + Is.Not.Null, + $"Host '{host.shaderName}' has no expected pass counts." + ); } /// Checks every imported host's compiler status and generated source sentinel contract without importing or modifying assets. @@ -250,7 +280,7 @@ private static HostManifest LoadManifest() ); Assert.That(manifest, Is.Not.Null); Assert.That(manifest.schemaVersion, Is.EqualTo(1)); - Assert.That(manifest.hosts, Is.Not.Null.And.Length.EqualTo(11)); + Assert.That(manifest.hosts, Is.Not.Null.And.Length.EqualTo(12)); return manifest; } @@ -273,6 +303,82 @@ private static bool HasConfiguredModuleOrder(HostManifestEntry host) && !string.IsNullOrEmpty(moduleOrder.secondSentinel); } + /// Returns whether a host declares the complete three-phase shadow-visibility runtime contract. + private static bool HasConfiguredRuntimeEvidence(HostManifestEntry host) + { + RuntimeEvidence runtimeEvidence = host.runtimeEvidence; + if (runtimeEvidence == null || runtimeEvidence.phaseChannels == null) + { + return false; + } + + if (!HasConfiguredPhaseChannels(runtimeEvidence.phaseChannels)) + { + return false; + } + + if (!HasConfiguredShadowModes(runtimeEvidence.shadowModes)) + { + return false; + } + + if (!HasValidShadowEvidenceRange(runtimeEvidence)) + { + return false; + } + + return runtimeEvidence.requireFinite && runtimeEvidence.requireChannelAgreement; + } + + /// Returns whether the manifest declares the required red, green, and blue phase channels. + /// The phase-channel declaration to inspect. + /// Whether every phase channel has its expected semantic color. + private static bool HasConfiguredPhaseChannels(PhaseChannels phaseChannels) + { + if (!string.Equals(phaseChannels.light, "red", StringComparison.Ordinal)) + { + return false; + } + + if (!string.Equals(phaseChannels.modifylight, "green", StringComparison.Ordinal)) + { + return false; + } + + return string.Equals(phaseChannels.shade, "blue", StringComparison.Ordinal); + } + + /// Returns whether the manifest declares the expected ordered shadow modes. + /// The optional shadow-mode declaration to inspect. + /// Whether the declaration exactly lists None, Hard, and Soft modes. + private static bool HasConfiguredShadowModes(string[] shadowModes) + { + return shadowModes != null && shadowModes.SequenceEqual(new[] { "None", "Hard", "Soft" }); + } + + /// Returns whether the configured shadow numeric bounds are internally valid. + /// The runtime evidence declaration to inspect. + /// Whether all unshadowed, Hard-shadow, and Soft-shadow bounds are valid. + private static bool HasValidShadowEvidenceRange(RuntimeEvidence runtimeEvidence) + { + if (runtimeEvidence.unshadowedValue <= 0.0f) + { + return false; + } + + if (runtimeEvidence.hardShadowMaximum >= runtimeEvidence.unshadowedValue) + { + return false; + } + + if (runtimeEvidence.softShadowMinimum <= 0.0f) + { + return false; + } + + return runtimeEvidence.softShadowMaximum < runtimeEvidence.unshadowedValue; + } + /// Finds a Shader-Core asset by imported shader name in one read-only asset search root. private static string FindShaderCoreAssetPath(string shaderName, string searchRoot) { @@ -664,6 +770,7 @@ private static string GetManifestPath() /// Represents the read-only top-level host manifest. [Serializable] + [SuppressMessage("SonarAnalyzer.CSharp", "S3459", Justification = "Unity JsonUtility populates these public fields, and optional reference fields must remain null when their JSON sections are absent.")] private sealed class HostManifest { /// Gets the manifest format version. @@ -675,6 +782,7 @@ private sealed class HostManifest /// Represents one fixed host's selected modules. [Serializable] + [SuppressMessage("SonarAnalyzer.CSharp", "S3459", Justification = "Unity JsonUtility populates these public fields, and optional reference fields must remain null when their JSON sections are absent.")] private sealed class HostManifestEntry { /// Gets the Shader-Core shader name. @@ -700,10 +808,14 @@ private sealed class HostManifestEntry /// Gets configured generated-source order expectations for the two-module host. public ModuleOrder moduleOrder; + + /// Gets the Toon phase-shadow runtime evidence requirements. + public RuntimeEvidence runtimeEvidence; } /// Stores selected sentinel counts for every generated ShaderLab pass. [Serializable] + [SuppressMessage("SonarAnalyzer.CSharp", "S3459", Justification = "Unity JsonUtility populates these public fields, and optional reference fields must remain null when their JSON sections are absent.")] private sealed class PassSentinelCounts { /// Gets the expected ForwardBase sentinel count. @@ -721,6 +833,7 @@ private sealed class PassSentinelCounts /// Stores a phase host's gate-on versus gate-off runtime measurement contract. [Serializable] + [SuppressMessage("SonarAnalyzer.CSharp", "S3459", Justification = "Unity JsonUtility populates these public fields, and optional reference fields must remain null when their JSON sections are absent.")] private sealed class RuntimeDelta { /// Gets the measured runtime observation field. @@ -735,6 +848,7 @@ private sealed class RuntimeDelta /// Stores generated source ordering expectations for the selected same-phase modules. [Serializable] + [SuppressMessage("SonarAnalyzer.CSharp", "S3459", Justification = "Unity JsonUtility populates these public fields, and optional reference fields must remain null when their JSON sections are absent.")] private sealed class ModuleOrder { /// Gets the first expected generated source sentinel. @@ -744,6 +858,51 @@ private sealed class ModuleOrder public string secondSentinel; } + /// Stores the fixed Toon phase-shadow render requirements. + [Serializable] + [SuppressMessage("SonarAnalyzer.CSharp", "S3459", Justification = "Unity JsonUtility populates these public fields, and optional reference fields must remain null when their JSON sections are absent.")] + private sealed class RuntimeEvidence + { + /// Gets the phase-to-RGB-channel map. + public PhaseChannels phaseChannels; + + /// Gets the ordered directional-shadow modes. + public string[] shadowModes; + + /// Gets the expected unshadowed diagnostic value. + public float unshadowedValue; + + /// Gets the highest accepted hard-shadow diagnostic value. + public float hardShadowMaximum; + + /// Gets the lowest accepted fractional soft-shadow diagnostic value. + public float softShadowMinimum; + + /// Gets the highest accepted fractional soft-shadow diagnostic value. + public float softShadowMaximum; + + /// Gets whether every diagnostic channel must remain finite. + public bool requireFinite; + + /// Gets whether phase channels must agree on one visibility value. + public bool requireChannelAgreement; + } + + /// Stores the RGB channel published by every selected Toon phase. + [Serializable] + [SuppressMessage("SonarAnalyzer.CSharp", "S3459", Justification = "Unity JsonUtility populates these public fields, and optional reference fields must remain null when their JSON sections are absent.")] + private sealed class PhaseChannels + { + /// Gets the light-phase output channel. + public string light; + + /// Gets the modifylight-phase output channel. + public string modifylight; + + /// Gets the shade-phase output channel. + public string shade; + } + /// Stores one gate-state measurement from the transient host renderer. private readonly struct RuntimeObservation { diff --git a/Tests/Fixtures/Hosts/ToonShadow.meta b/Tests/Fixtures/Hosts/ToonShadow.meta new file mode 100644 index 00000000..e291a2ad --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c7d98b7fdc29d724581f1930265d255a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow.scshader b/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow.scshader new file mode 100644 index 00000000..65ad296d --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow.scshader @@ -0,0 +1,214 @@ +Shader "PureBase/Tests/ShaderCore/ToonShadow" +{ + // The Apache notice follows because Shader-Core importer discovery requires Shader first. + /* + * 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 a fixed product-like Toon host for phase-local shadow visibility diagnostics. + Properties + { + /* + * 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. + */ + __SC_SHADERLAB_properties__ + [HideInInspector] _SrcBlend ("", Float) = 1 + [HideInInspector] _DstBlend ("", Float) = 0 + [HideInInspector] _ZWrite ("", Float) = 1 + [HideInInspector] _AddSrcBlend ("", Float) = 1 + [HideInInspector] _AddDstBlend ("", Float) = 1 + } + + HLSLINCLUDE + __SC_SHADERKEYWORDS__ + ENDHLSL + + SubShader + { + Tags { "RenderType" = "TransparentCutout" "Queue" = "AlphaTest" } + + Pass + { + Name "ForwardBase" + Tags { "LightMode" = "ForwardBase" } + Stencil + { + Ref [_StencilRef] + ReadMask [_StencilReadMask] + WriteMask [_StencilWriteMask] + Comp [_StencilComp] + Pass [_StencilPass] + Fail [_StencilFail] + ZFail [_StencilZFail] + } + Cull [_Cull] + ZWrite [_ZWrite] + ZTest LEqual + Blend [_SrcBlend] [_DstBlend] + + HLSLPROGRAM + #pragma target 5.0 + #define PUREBASE_MODEL_INCLUDE "Packages/jp.penguin.purebase/Shaders/Models/toon.hlsl" + #include "Packages/jp.lilxyzw.shadercore/ShaderLibrary/birp_forward.hlsl" + #include "Packages/jp.penguin.purebase/Shaders/Common/birp_host.hlsl" + ENDHLSL + } + + Pass + { + Name "ForwardAdd" + Tags { "LightMode" = "ForwardAdd" } + Stencil + { + Ref [_StencilRef] + ReadMask [_StencilReadMask] + Comp [_StencilComp] + WriteMask 0 + Pass Keep + Fail Keep + ZFail Keep + } + Cull [_Cull] + ZWrite Off + ZTest LEqual + Blend [_AddSrcBlend] [_AddDstBlend] + // Keep the ForwardBase phase diagnostic as the fixture's sole framebuffer RGB contribution. + ColorMask 0 + + HLSLPROGRAM + #pragma target 5.0 + #define PUREBASE_MODEL_INCLUDE "Packages/jp.penguin.purebase/Shaders/Models/toon.hlsl" + #include "Packages/jp.lilxyzw.shadercore/ShaderLibrary/birp_forwardadd.hlsl" + #include "Packages/jp.penguin.purebase/Shaders/Common/birp_host.hlsl" + ENDHLSL + } + + Pass + { + Name "ShadowCaster" + Tags { "LightMode" = "ShadowCaster" } + Cull [_Cull] + ZWrite On + ZTest LEqual + ColorMask 0 + + HLSLPROGRAM + #pragma target 5.0 + #define PUREBASE_MODEL_INCLUDE "Packages/jp.penguin.purebase/Shaders/Models/toon.hlsl" + #include "Packages/jp.lilxyzw.shadercore/ShaderLibrary/birp_shadowcaster.hlsl" + ENDHLSL + } + + Pass + { + Name "Meta" + Tags { "LightMode" = "Meta" } + Cull Off + + HLSLPROGRAM + #pragma target 2.0 + #pragma vertex PureBaseTestToonShadowMetaVertex + #pragma fragment PureBaseTestToonShadowMetaFragment + #include "UnityCG.cginc" + #include "UnityMetaPass.cginc" + #include "Packages/jp.lilxyzw.shadercore/ShaderLibrary/birp.hlsl" + __SC_BIRP_properties__ + #include "Packages/jp.penguin.purebase/Shaders/Common/rendering_mode.hlsl" + #include "Packages/jp.penguin.purebase/Shaders/Models/toon.hlsl" + + /// Defines the bind-pose vertex and lightmap UV inputs for Unity's Meta pass. + struct PureBaseTestToonShadowMetaAppData + { + /// Provides the bind-pose object-space vertex position. + float4 vertex : POSITION; + /// Provides the primary material UV. + float2 uv0 : TEXCOORD0; + /// Provides the static lightmap UV. + float2 uv1 : TEXCOORD1; + /// Provides the dynamic lightmap UV. + float2 uv2 : TEXCOORD2; + /// Provides Unity GPU-instancing input data. + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + + /// Defines the interpolants required for Unity's Meta pass. + struct PureBaseTestToonShadowMetaVaryings + { + /// Provides the Meta pass clip-space position. + float4 position : SV_POSITION; + /// Interpolates the primary material UV. + float2 uv : TEXCOORD0; + #ifdef EDITOR_VISUALIZATION + /// Interpolates the Unity material visualization UV. + float2 visualizationUv : TEXCOORD1; + /// Interpolates the Unity light visualization coordinate. + float4 lightCoordinate : TEXCOORD2; + #endif + }; + + /// Converts bind-pose geometry and lightmap UVs into Meta pass varyings. + PureBaseTestToonShadowMetaVaryings PureBaseTestToonShadowMetaVertex(PureBaseTestToonShadowMetaAppData input) + { + PureBaseTestToonShadowMetaVaryings output; + output.position = UnityMetaVertexPosition(input.vertex, input.uv1, input.uv2, unity_LightmapST, unity_DynamicLightmapST); + output.uv = input.uv0 * _BaseTexture_ST.xy + _BaseTexture_ST.zw; + #ifdef EDITOR_VISUALIZATION + output.visualizationUv = 0; + output.lightCoordinate = 0; + if (unity_VisualizationMode == EDITORVIZ_TEXTURE) + output.visualizationUv = UnityMetaVizUV(unity_EditorViz_UVIndex, input.uv0, input.uv1, input.uv2, unity_EditorViz_Texture_ST); + else if (unity_VisualizationMode == EDITORVIZ_SHOWLIGHTMASK) + { + output.visualizationUv = input.uv1 * unity_LightmapST.xy + unity_LightmapST.zw; + output.lightCoordinate = mul(unity_EditorViz_WorldToLight, mul(unity_ObjectToWorld, input.vertex)); + } + #endif + return output; + } + + /// Returns albedo-only Meta data using the selected rendering-mode coverage contract. + float4 PureBaseTestToonShadowMetaFragment(PureBaseTestToonShadowMetaVaryings input) : SV_Target + { + half4 albedoAlpha = SCSample(_BaseTexture, sampler_BaseTexture, input.uv) * _BaseColor; + PureBaseApplyRenderingModeClip(albedoAlpha.a); + UnityMetaInput output; + UNITY_INITIALIZE_OUTPUT(UnityMetaInput, output); + output.Albedo = albedoAlpha.rgb; + output.SpecularColor = 0; + output.Emission = 0; + #ifdef EDITOR_VISUALIZATION + output.VizUV = input.visualizationUv; + output.LightCoord = input.lightCoordinate; + #endif + return UnityMetaFragment(output); + } + ENDHLSL + } + } + + CustomEditor "SCMaterialEditor" +} \ No newline at end of file diff --git a/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow.scshader.meta b/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow.scshader.meta new file mode 100644 index 00000000..82edac5b --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow.scshader.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: 60a34438691c4e5469fdc6aabef6f53c +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 11500000, guid: 11c23ed6ad66fef4699c7e3c88c88784, type: 3} diff --git a/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow_properties.hlsl b/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow_properties.hlsl new file mode 100644 index 00000000..82a0eeb0 --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow_properties.hlsl @@ -0,0 +1,19 @@ +SC_Texture2D(_BaseTexture, "white", [SCMainTexture], "Base Texture", "") +SC_SamplerState(sampler_BaseTexture) +SC_ScaleOffset(_BaseTexture) +SC_color(_BaseColor, (1,1,1,1), [], "Base Color", "") +SC_Texture2D(_SharedMask, "white", [SCMask], "__SharedMask", "") +SC_Texture2DArray(_SharedGradients, "white", [SCGradients], "__SharedGradients", "") +SC_uint(_RenderingMode, 1, [PureBaseRenderingMode], "Rendering Mode", "") +SC_float(_Cutoff, 0.5, [PureBaseCutoff][SCRange(-0.001,1.001)], "Cutoff", "") +SC_float(_Cull, 2, [SCEnum(Off, 0, Front, 1, Back, 2)], "Cull", "") +SC_float(_StencilRef, 0, [SCRangeInt(0,255)], "Stencil Reference", "") +SC_float(_StencilReadMask, 255, [SCRangeInt(0,255)], "Stencil Read Mask", "") +SC_float(_StencilWriteMask, 255, [SCRangeInt(0,255)], "Stencil Write Mask", "") +SC_float(_StencilComp, 8, [SCEnum(UnityEngine.Rendering.CompareFunction)], "Stencil Comparison", "") +SC_float(_StencilPass, 0, [SCEnum(UnityEngine.Rendering.StencilOp)], "Stencil Pass", "") +SC_float(_StencilFail, 0, [SCEnum(UnityEngine.Rendering.StencilOp)], "Stencil Fail", "") +SC_float(_StencilZFail, 0, [SCEnum(UnityEngine.Rendering.StencilOp)], "Stencil Z Fail", "") +SC_Texture2D(_NormalMap, "bump", [], "Normal Map", "") +SC_SamplerState(sampler_NormalMap) +SC_float(_NormalScale, 1, [SCRange(0,2)], "Normal Scale", "") \ No newline at end of file diff --git a/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow_properties.hlsl.meta b/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow_properties.hlsl.meta new file mode 100644 index 00000000..95760bad --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow/PureBaseTestToonShadow_properties.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0ce2c1fdf60212047b8e9c680c10ed2f +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Fixtures/Hosts/ToonShadow/jp.penguin.purebase.tests.shadercore.toonshadow.scmodule b/Tests/Fixtures/Hosts/ToonShadow/jp.penguin.purebase.tests.shadercore.toonshadow.scmodule new file mode 100644 index 00000000..329f64eb --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow/jp.penguin.purebase.tests.shadercore.toonshadow.scmodule @@ -0,0 +1,4 @@ +{ + "name": "PureBase Test Toon Shadow", + "uniqueID": "jp.penguin.purebase.tests.shadercore.toonshadow" +} \ No newline at end of file diff --git a/Tests/Fixtures/Hosts/ToonShadow/jp.penguin.purebase.tests.shadercore.toonshadow.scmodule.meta b/Tests/Fixtures/Hosts/ToonShadow/jp.penguin.purebase.tests.shadercore.toonshadow.scmodule.meta new file mode 100644 index 00000000..642c0570 --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow/jp.penguin.purebase.tests.shadercore.toonshadow.scmodule.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ce24120bcbcdb124194288249b3929a7 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Fixtures/Hosts/ToonShadow/phase_light.hlsl b/Tests/Fixtures/Hosts/ToonShadow/phase_light.hlsl new file mode 100644 index 00000000..b86c4ace --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow/phase_light.hlsl @@ -0,0 +1,22 @@ +/* + * 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. + */ + +// Publishes the light-phase shadow visibility through the red diagnostic channel. + +/// Identifies the light-phase shadow visibility diagnostic source. +#define PUREBASE_TEST_TOON_SHADOW_SENTINEL_LIGHT 1 + +sd.add.r = sd.shadow; \ No newline at end of file diff --git a/Tests/Fixtures/Hosts/ToonShadow/phase_light.hlsl.meta b/Tests/Fixtures/Hosts/ToonShadow/phase_light.hlsl.meta new file mode 100644 index 00000000..35384bfc --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow/phase_light.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5c3a5ddc4c39238449924deb92ba5ba9 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Fixtures/Hosts/ToonShadow/phase_modifylight.hlsl b/Tests/Fixtures/Hosts/ToonShadow/phase_modifylight.hlsl new file mode 100644 index 00000000..cb09f665 --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow/phase_modifylight.hlsl @@ -0,0 +1,22 @@ +/* + * 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. + */ + +// Publishes the modifylight-phase shadow visibility through the green diagnostic channel. + +/// Identifies the modifylight-phase shadow visibility diagnostic source. +#define PUREBASE_TEST_TOON_SHADOW_SENTINEL_MODIFYLIGHT 1 + +sd.add.g = sd.shadow; \ No newline at end of file diff --git a/Tests/Fixtures/Hosts/ToonShadow/phase_modifylight.hlsl.meta b/Tests/Fixtures/Hosts/ToonShadow/phase_modifylight.hlsl.meta new file mode 100644 index 00000000..b4825fa0 --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow/phase_modifylight.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9ed862ec19502f148bb55fde02d84fb1 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Fixtures/Hosts/ToonShadow/phase_shade.hlsl b/Tests/Fixtures/Hosts/ToonShadow/phase_shade.hlsl new file mode 100644 index 00000000..9ad5f66d --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow/phase_shade.hlsl @@ -0,0 +1,26 @@ +/* + * 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. + */ + +// Publishes the shade-phase shadow visibility and resolves the final diagnostic RGB readout. + +/// Identifies the shade-phase shadow visibility diagnostic source. +#define PUREBASE_TEST_TOON_SHADOW_SENTINEL_SHADE 1 + +sd.add.b = sd.shadow; +half3 phaseShadowVisibility = sd.add; +sd.add = 0; +sd.postadd = 0; +sd.col.rgb = phaseShadowVisibility; \ No newline at end of file diff --git a/Tests/Fixtures/Hosts/ToonShadow/phase_shade.hlsl.meta b/Tests/Fixtures/Hosts/ToonShadow/phase_shade.hlsl.meta new file mode 100644 index 00000000..a557665c --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow/phase_shade.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d98d9895b0ce0c04c8393956bf1560cd +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Fixtures/Hosts/ToonShadow/sc_common.hlsl b/Tests/Fixtures/Hosts/ToonShadow/sc_common.hlsl new file mode 100644 index 00000000..819d6494 --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow/sc_common.hlsl @@ -0,0 +1,28 @@ +/* + * 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. + */ + +// Resolves Shader-Core's host-local common include through the product Toon support path. + +#ifndef PUREBASE_TEST_TOON_SHADOW_SC_COMMON_INCLUDED +#define PUREBASE_TEST_TOON_SHADOW_SC_COMMON_INCLUDED + +#ifndef PUREBASE_MODEL_INCLUDE +#define PUREBASE_MODEL_INCLUDE "Packages/jp.penguin.purebase/Shaders/Models/toon.hlsl" +#endif + +#include "Packages/jp.penguin.purebase/Shaders/sc_common.hlsl" + +#endif \ No newline at end of file diff --git a/Tests/Fixtures/Hosts/ToonShadow/sc_common.hlsl.meta b/Tests/Fixtures/Hosts/ToonShadow/sc_common.hlsl.meta new file mode 100644 index 00000000..f5fb1d5e --- /dev/null +++ b/Tests/Fixtures/Hosts/ToonShadow/sc_common.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 2502f1316c999404f89f6dc710ccf177 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Regeneration/Editor/PureBaseValidationLightingSettingsGenerator.cs b/Tests/Regeneration/Editor/PureBaseValidationLightingSettingsGenerator.cs index d74d9639..a5260e3f 100644 --- a/Tests/Regeneration/Editor/PureBaseValidationLightingSettingsGenerator.cs +++ b/Tests/Regeneration/Editor/PureBaseValidationLightingSettingsGenerator.cs @@ -71,6 +71,20 @@ public static class PureBaseValidationLightingSettingsGenerator /// private const string FixtureRootName = "PureBase Validation Fixture"; + /// + /// Stores the enabled camera name reserved for generated validation fixture content. + /// + private const string FixtureCameraName = "PureBase Validation Camera"; + + /// Stores the root camera name used by the reviewed BIRP baseline. + private const string LegacyBaselineCameraName = "Validation Camera"; + + /// Stores the reviewed baseline camera world position. + private static readonly Vector3 LegacyBaselineCameraPosition = new Vector3(0.0f, 4.0f, -16.0f); + + /// Stores the reviewed baseline camera vertical field of view. + private const float LegacyBaselineCameraFieldOfView = 50.0f; + /// /// Stores the fixed product shader names used by the persisted validation materials. /// @@ -512,7 +526,7 @@ private static void SetStaticLightingFlags(GameObject gameObject) /// The generated fixture root transform. private static void CreateSceneCamera(Transform parent) { - GameObject cameraObject = new GameObject("PureBase Validation Camera"); + GameObject cameraObject = new GameObject(FixtureCameraName); cameraObject.transform.SetParent(parent, false); cameraObject.transform.position = new Vector3(0.0f, 4.8f, -12.0f); cameraObject.transform.LookAt(new Vector3(0.0f, 0.8f, 0.0f)); @@ -627,16 +641,65 @@ private static void Validate(Scene validationScene, LightingSettings lightingSet } } - bool hasCamera = false; - bool hasBakedDirectionalLight = false; - bool hasStaticRenderer = false; + GameObject baselineCameraRoot = null; foreach (GameObject root in validationScene.GetRootGameObjects()) { - foreach (Camera camera in root.GetComponentsInChildren(true)) + if (!string.Equals(root.name, LegacyBaselineCameraName, StringComparison.Ordinal)) + { + continue; + } + + if (baselineCameraRoot != null) { - hasCamera |= camera.enabled; + throw new InvalidOperationException( + $"The validation scene contains multiple root '{LegacyBaselineCameraName}' cameras." + ); } + baselineCameraRoot = root; + } + + if (baselineCameraRoot == null) + { + throw new InvalidOperationException( + $"The validation scene is missing the root '{LegacyBaselineCameraName}' camera." + ); + } + + Camera baselineCamera = baselineCameraRoot.GetComponent(); + if (baselineCamera == null) + { + throw new InvalidOperationException( + $"The root '{LegacyBaselineCameraName}' object has no Camera component." + ); + } + + if (baselineCameraRoot.GetComponents().Length != 1) + { + throw new InvalidOperationException( + $"The root baseline camera '{LegacyBaselineCameraName}' must have exactly one Camera component." + ); + } + + if ( + !baselineCamera.enabled + || baselineCamera.transform.parent != null + || baselineCamera.transform.position != LegacyBaselineCameraPosition + || !Mathf.Approximately( + baselineCamera.fieldOfView, + LegacyBaselineCameraFieldOfView + ) + ) + { + throw new InvalidOperationException( + $"The root baseline camera '{LegacyBaselineCameraName}' does not match the reviewed BIRP baseline contract." + ); + } + + bool hasBakedDirectionalLight = false; + bool hasStaticRenderer = false; + foreach (GameObject root in validationScene.GetRootGameObjects()) + { foreach (Light light in root.GetComponentsInChildren(true)) { hasBakedDirectionalLight |= @@ -654,10 +717,10 @@ private static void Validate(Scene validationScene, LightingSettings lightingSet } } - if (!hasCamera || !hasBakedDirectionalLight || !hasStaticRenderer) + if (!hasBakedDirectionalLight || !hasStaticRenderer) { throw new InvalidOperationException( - "The validation scene is missing its enabled camera, baked directional light, or static renderers." + "The validation scene is missing its baked directional light or static renderers." ); } @@ -753,7 +816,9 @@ public GenerationDependencyScope(GenerationDependencies previousDependencies) public void Dispose() { if (disposed) + { return; + } testGenerationDependencies = previousDependencies; disposed = true; } diff --git a/Tests/Run-PureBaseRegression.ps1 b/Tests/Run-PureBaseRegression.ps1 index c5d63cf4..c64978c2 100644 --- a/Tests/Run-PureBaseRegression.ps1 +++ b/Tests/Run-PureBaseRegression.ps1 @@ -26,6 +26,7 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $DailyTestAssembly = 'PureBase.Tests.Daily' +$StrictDailyBaselineTestCase = 'PureBase.Tests.Daily.PureBaseValidationSceneRegressionTests.CanonicalSceneMatchesCommittedBirpBaseline' $InitializerExecutionMethod = 'PureBase.Tests.Regeneration.ShaderCoreTestStateInitializer.InitializeForBatchMode' $ProjectSettingsRelativePath = 'ProjectSettings/jp.lilxyzw.shadercore.asset' @@ -294,6 +295,16 @@ function Test-NUnitResult { throw "Daily NUnit evidence must contain only '$DailyTestAssembly'. Found: $($assemblyNames -join ', ')." } + $strictTestCases = @($results.SelectNodes("//test-case[@fullname='$StrictDailyBaselineTestCase']")) + if ($strictTestCases.Count -ne 1) { + throw "Daily NUnit evidence must contain exactly one strict baseline testcase '$StrictDailyBaselineTestCase'. Found: $($strictTestCases.Count)." + } + + $strictTestResult = $strictTestCases[0].GetAttribute('result') + if ($strictTestResult -ne 'Passed') { + throw "Daily NUnit evidence must report strict baseline testcase '$StrictDailyBaselineTestCase' as Passed. Found result: '$strictTestResult'." + } + Write-Host "Daily NUnit summary: assembly=$($assemblyNames[0]) total=$total passed=$($testRun.GetAttribute('passed')) failed=$($testRun.GetAttribute('failed')) skipped=$($testRun.GetAttribute('skipped')) inconclusive=$($testRun.GetAttribute('inconclusive'))" return $testRun.GetAttribute('result') -eq 'Passed' -and $total -gt 0 } @@ -422,6 +433,44 @@ function Assert-SmokeContract { if (-not $invalidNUnitResultRejected) { throw 'NUnit result validation must reject evidence containing an unexpected assembly suite.' } + + $strictNUnitEvidence = @( + [pscustomobject]@{ Name = 'Passed'; TestCases = ""; Accepted = $true } + [pscustomobject]@{ Name = 'Missing'; TestCases = ''; Accepted = $false } + [pscustomobject]@{ Name = 'Duplicated'; TestCases = ""; Accepted = $false } + [pscustomobject]@{ Name = 'Skipped'; TestCases = ""; Accepted = $false } + [pscustomobject]@{ Name = 'Inconclusive'; TestCases = ""; Accepted = $false } + [pscustomobject]@{ Name = 'Failed'; TestCases = ""; Accepted = $false } + ) + foreach ($strictNUnitEvidenceCase in $strictNUnitEvidence) { + $strictNUnitResultPath = Join-Path $artifactRoot ("Smoke.Strict$($strictNUnitEvidenceCase.Name)-$PID-$([guid]::NewGuid().ToString('N')).NUnit.xml") + try { + [System.IO.File]::WriteAllText($strictNUnitResultPath, @" + + + $($strictNUnitEvidenceCase.TestCases) + + +"@) + + $strictNUnitResultAccepted = $false + try { + $strictNUnitResultAccepted = Test-NUnitResult -ResultsPath $strictNUnitResultPath + } + catch { + $strictNUnitResultAccepted = $false + } + + if ($strictNUnitResultAccepted -ne $strictNUnitEvidenceCase.Accepted) { + throw "NUnit result validation acceptance mismatch for strict testcase evidence '$($strictNUnitEvidenceCase.Name)'." + } + } + finally { + if (Test-Path -LiteralPath $strictNUnitResultPath -PathType Leaf) { + [System.IO.File]::Delete($strictNUnitResultPath) + } + } + } } finally { if (Test-Path -LiteralPath $invalidNUnitResultPath -PathType Leaf) {