diff --git a/Docs/pure-base-shader-contract.md b/Docs/pure-base-shader-contract.md
index e280258..0461b2b 100644
--- a/Docs/pure-base-shader-contract.md
+++ b/Docs/pure-base-shader-contract.md
@@ -53,6 +53,18 @@ Every product shader source retains exactly four passes:
The effective tags, queue, blend state, depth writing, and pass enablement are selected by the rendering-mode ABI below. The `ForwardAdd` additive blend state in Opaque and Cutout is an additional direct-light pass, not transparent blending.
+### Stencil pass policy
+
+The camera Stencil policy is bounded to the forward passes:
+
+- `ForwardBase` applies all seven public Stencil settings.
+- `ForwardAdd` compares the Stencil value left by `ForwardBase` using only `_StencilRef`, `_StencilReadMask`, and `_StencilComp`. It fixes `WriteMask` to `0` and `Pass`, `Fail`, and `ZFail` to `Keep`, so additional lights cannot mutate Stencil.
+- `ShadowCaster` and `Meta` do not apply the camera Stencil policy.
+
+The defaults are `Always` with `Keep` for all outcomes (`0, 255, 255, 8, 0, 0, 0`). They are a no-op Stencil state: a legacy material without saved Stencil fields continues to draw and does not mutate Stencil. A `ForwardBase` operation such as `Replace`, `Incr`, `Decr`, or `Invert` can intentionally change the value observed by `ForwardAdd`; the additional-light pass then accepts or rejects independently based on that post-`ForwardBase` value. It does not preserve a comparison against the pre-`ForwardBase` value.
+
+The Stencil ABI adds no Stencil-specific keyword, shader variant, pass, or package dependency. Rendering-mode changes and Resync synchronize rendering-mode state while preserving all user Stencil values.
+
## Rendering-mode ABI
`_RenderingMode` is a ShaderLab `Integer` backed by `SC_uint` with these values:
@@ -71,7 +83,7 @@ The explicit editor action is `PureBaseMaterialRenderingMode.Apply(Material)`. T
## Public Property ABI
-All four shaders expose exactly these common properties:
+All four shaders expose exactly these common visible properties, in declaration order:
| Property | Applies to |
| --- | --- |
@@ -79,9 +91,16 @@ All four shaders expose exactly these common properties:
| `_BaseColor` | All shaders |
| `_SharedMask` | All shaders |
| `_SharedGradients` | All shaders |
+| `_RenderingMode` | All shaders |
| `_Cutoff` | All shaders |
| `_Cull` | All shaders |
-| `_RenderingMode` | All shaders |
+| `_StencilRef` | All shaders |
+| `_StencilReadMask` | All shaders |
+| `_StencilWriteMask` | All shaders |
+| `_StencilComp` | All shaders |
+| `_StencilPass` | All shaders |
+| `_StencilFail` | All shaders |
+| `_StencilZFail` | All shaders |
The model-specific properties are:
@@ -96,6 +115,22 @@ PBR and Hybrid use byte-identical property declarations. `_Roughness` clamps fro
The forbidden release-boundary property names `_Emission`, `_Rim`, `_MatCap`, and `_ClearCoat` are not part of this ABI.
+### Stencil ABI
+
+All four product shaders expose the following public Stencil properties. The declarations use the Float-compatible `SC_float` ABI, including the enum-backed values.
+
+| Property | Public UI contract | Default |
+| --- | --- | ---: |
+| `_StencilRef` | `SC_float`; integer UI range `0-255` | `0` |
+| `_StencilReadMask` | `SC_float`; integer UI range `0-255` | `255` |
+| `_StencilWriteMask` | `SC_float`; integer UI range `0-255` | `255` |
+| `_StencilComp` | `SC_float`; `UnityEngine.Rendering.CompareFunction` | `8` (`Always`) |
+| `_StencilPass` | `SC_float`; `UnityEngine.Rendering.StencilOp` | `0` (`Keep`) |
+| `_StencilFail` | `SC_float`; `UnityEngine.Rendering.StencilOp` | `0` (`Keep`) |
+| `_StencilZFail` | `SC_float`; `UnityEngine.Rendering.StencilOp` | `0` (`Keep`) |
+
+The three mask/reference properties use the `0-255` UI range. The comparison and operation properties use Unity's `CompareFunction` and `StencilOp` enums respectively.
+
## Shader-Core Phase ABI
The standard insertion points are shared by the product hosts in this order:
diff --git a/Docs/technical-information.ja.md b/Docs/technical-information.ja.md
index 3433aa9..43f9285 100644
--- a/Docs/technical-information.ja.md
+++ b/Docs/technical-information.ja.md
@@ -75,6 +75,26 @@ Pure Base は Shader-Core を動かすための最小構成の土台です。多
`PureBase/PBR` と `PureBase/Hybrid` は、法線マップ用の項目に加えて `_Metallic` と `_Roughness` を公開します。両者の公開項目定義は完全に同一です。粗さは `0.002` から `1` の範囲に制限されます。
+### ステンシル ABI
+
+4つの製品シェーダーは、Float互換の `SC_float` ABI を通じて、次のステンシル項目を公開します。列挙型に対応する項目も `SC_float` として宣言されます。
+
+| 項目 | UI の仕様 | 初期値 |
+| --- | --- | ---: |
+| `_StencilRef` | 整数範囲 `0-255` | `0` |
+| `_StencilReadMask` | 整数範囲 `0-255` | `255` |
+| `_StencilWriteMask` | 整数範囲 `0-255` | `255` |
+| `_StencilComp` | `UnityEngine.Rendering.CompareFunction` | `8`(`Always`) |
+| `_StencilPass` | `UnityEngine.Rendering.StencilOp` | `0`(`Keep`) |
+| `_StencilFail` | `UnityEngine.Rendering.StencilOp` | `0`(`Keep`) |
+| `_StencilZFail` | `UnityEngine.Rendering.StencilOp` | `0`(`Keep`) |
+
+`ForwardBase` は7項目すべてを適用します。`ForwardAdd` は `ForwardBase` 後のステンシル値を `_StencilRef`、`_StencilReadMask`、`_StencilComp` だけで比較します。`WriteMask` は `0`、`Pass`、`Fail`、`ZFail` は `Keep` に固定されるため、追加ライトがステンシルを書き換えることはありません。`ShadowCaster` と `Meta` ではカメラ用のステンシル方針を適用しません。
+
+初期値の `Always` と `Keep` の組み合わせは、ステンシルに対する何もしない状態です。保存済みのステンシル項目を持たない旧形式のマテリアルも描画を継続し、ステンシルを書き換えません。`ForwardBase` で `Replace`、`Incr`、`Decr`、`Invert` などを指定すると、`ForwardAdd` が変更後の値を比較するため、追加ライトは独立して通過または拒否されることがあります。`ForwardBase` 前の値との比較を維持する仕様ではありません。
+
+ステンシルの追加によって、ステンシル専用のキーワード、シェーダーバリアント、パス、パッケージ依存関係は増えません。描画モードの変更と Resync は描画モードの状態を同期しますが、ユーザーが設定した7つのステンシル項目は保持します。
+
## Shader-Core との連携
共通の処理差し込み位置は、次の順で実行されます。
diff --git a/Docs/technical-information.md b/Docs/technical-information.md
index 49daedd..d310b3f 100644
--- a/Docs/technical-information.md
+++ b/Docs/technical-information.md
@@ -75,6 +75,26 @@ All four shaders expose these common properties:
`PureBase/PBR` and `PureBase/Hybrid` expose the same normal-map properties plus `_Metallic` and `_Roughness`. Their public property declarations are byte-identical. Roughness is clamped from `0.002` to `1`.
+### Stencil ABI
+
+All four product shaders expose these public Stencil properties through the Float-compatible `SC_float` ABI:
+
+| Property | UI contract | Default |
+| --- | --- | ---: |
+| `_StencilRef` | Integer range `0-255` | `0` |
+| `_StencilReadMask` | Integer range `0-255` | `255` |
+| `_StencilWriteMask` | Integer range `0-255` | `255` |
+| `_StencilComp` | `UnityEngine.Rendering.CompareFunction` | `8` (`Always`) |
+| `_StencilPass` | `UnityEngine.Rendering.StencilOp` | `0` (`Keep`) |
+| `_StencilFail` | `UnityEngine.Rendering.StencilOp` | `0` (`Keep`) |
+| `_StencilZFail` | `UnityEngine.Rendering.StencilOp` | `0` (`Keep`) |
+
+`ForwardBase` applies all seven settings. `ForwardAdd` compares the post-`ForwardBase` Stencil value using `_StencilRef`, `_StencilReadMask`, and `_StencilComp` only; it fixes `WriteMask` to `0` and `Pass`, `Fail`, and `ZFail` to `Keep`, so additional lights do not mutate Stencil. `ShadowCaster` and `Meta` do not apply the camera Stencil policy.
+
+The default `Always` plus `Keep` state is a no-op: a legacy material without saved Stencil fields continues to draw and does not mutate Stencil. If `ForwardBase` uses `Replace`, `Incr`, `Decr`, `Invert`, or another mutating operation, `ForwardAdd` may compare the changed value and therefore accept or reject independently. The policy intentionally does not preserve a comparison against the pre-`ForwardBase` value.
+
+Stencil adds no Stencil-specific keyword, variant, pass, or package dependency. Rendering-mode changes and Resync preserve user values for all seven Stencil properties.
+
## Shader-Core integration
The shared standard phase ABI is executed in this order:
diff --git a/Shaders/PureBaseHybrid.scshader b/Shaders/PureBaseHybrid.scshader
index c37d8d2..23090f6 100644
--- a/Shaders/PureBaseHybrid.scshader
+++ b/Shaders/PureBaseHybrid.scshader
@@ -41,6 +41,16 @@ Shader "PureBase/Hybrid"
{
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
@@ -61,6 +71,16 @@ Shader "PureBase/Hybrid"
{
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
diff --git a/Shaders/PureBaseHybrid_properties.hlsl b/Shaders/PureBaseHybrid_properties.hlsl
index afdebda..a0ed28b 100644
--- a/Shaders/PureBaseHybrid_properties.hlsl
+++ b/Shaders/PureBaseHybrid_properties.hlsl
@@ -7,6 +7,13 @@ 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", "")
diff --git a/Shaders/PureBasePBR.scshader b/Shaders/PureBasePBR.scshader
index 26f429a..174d0b2 100644
--- a/Shaders/PureBasePBR.scshader
+++ b/Shaders/PureBasePBR.scshader
@@ -41,6 +41,16 @@ Shader "PureBase/PBR"
{
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
@@ -61,6 +71,16 @@ Shader "PureBase/PBR"
{
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
diff --git a/Shaders/PureBasePBR_properties.hlsl b/Shaders/PureBasePBR_properties.hlsl
index afdebda..a0ed28b 100644
--- a/Shaders/PureBasePBR_properties.hlsl
+++ b/Shaders/PureBasePBR_properties.hlsl
@@ -7,6 +7,13 @@ 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", "")
diff --git a/Shaders/PureBaseToon.scshader b/Shaders/PureBaseToon.scshader
index 5104984..7021fd2 100644
--- a/Shaders/PureBaseToon.scshader
+++ b/Shaders/PureBaseToon.scshader
@@ -41,6 +41,16 @@ Shader "PureBase/Toon"
{
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
@@ -58,6 +68,16 @@ Shader "PureBase/Toon"
{
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
diff --git a/Shaders/PureBaseToon_properties.hlsl b/Shaders/PureBaseToon_properties.hlsl
index 57866e8..22ab646 100644
--- a/Shaders/PureBaseToon_properties.hlsl
+++ b/Shaders/PureBaseToon_properties.hlsl
@@ -7,6 +7,13 @@ 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", "")
diff --git a/Shaders/PureBaseUnlit.scshader b/Shaders/PureBaseUnlit.scshader
index b950b45..c31b93d 100644
--- a/Shaders/PureBaseUnlit.scshader
+++ b/Shaders/PureBaseUnlit.scshader
@@ -41,6 +41,16 @@ Shader "PureBase/Unlit"
{
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
@@ -57,6 +67,16 @@ Shader "PureBase/Unlit"
{
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
diff --git a/Shaders/PureBaseUnlit_properties.hlsl b/Shaders/PureBaseUnlit_properties.hlsl
index a11d752..a159a8f 100644
--- a/Shaders/PureBaseUnlit_properties.hlsl
+++ b/Shaders/PureBaseUnlit_properties.hlsl
@@ -7,3 +7,10 @@ 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", "")
diff --git a/Tests/Daily/Editor/PureBaseD24S8StencilTarget.renderTexture b/Tests/Daily/Editor/PureBaseD24S8StencilTarget.renderTexture
new file mode 100644
index 0000000..195962a
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseD24S8StencilTarget.renderTexture
@@ -0,0 +1,40 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!84 &8400000
+RenderTexture:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: PureBaseD24S8StencilTarget
+ m_ImageContentsHash:
+ serializedVersion: 2
+ Hash: 00000000000000000000000000000000
+ m_ForcedFallbackFormat: 4
+ m_DownscaleFallback: 0
+ m_IsAlphaChannelOptional: 0
+ serializedVersion: 5
+ m_Width: 256
+ m_Height: 256
+ m_AntiAliasing: 1
+ m_MipCount: -1
+ m_DepthStencilFormat: 92
+ m_ColorFormat: 8
+ m_MipMap: 0
+ m_GenerateMips: 1
+ m_SRGB: 0
+ m_UseDynamicScale: 0
+ m_BindMS: 0
+ m_EnableCompatibleFormat: 0
+ m_EnableRandomWrite: 0
+ m_TextureSettings:
+ serializedVersion: 2
+ m_FilterMode: 1
+ m_Aniso: 0
+ m_MipBias: 0
+ m_WrapU: 1
+ m_WrapV: 1
+ m_WrapW: 1
+ m_Dimension: 2
+ m_VolumeDepth: 1
+ m_ShadowSamplingMode: 2
diff --git a/Tests/Daily/Editor/PureBaseD24S8StencilTarget.renderTexture.meta b/Tests/Daily/Editor/PureBaseD24S8StencilTarget.renderTexture.meta
new file mode 100644
index 0000000..a3c0a67
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseD24S8StencilTarget.renderTexture.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 2b9448f3d12004b44ad58a72994affd3
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 8400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.ProductContracts.cs b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.ProductContracts.cs
index fe0ea93..dd3597e 100644
--- a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.ProductContracts.cs
+++ b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.ProductContracts.cs
@@ -64,14 +64,20 @@ public sealed partial class PureBaseRenderingModeContractTests
new ProductContract(
"PureBase/Unlit",
"Packages/jp.penguin.purebase/Shaders/PureBaseUnlit_properties.hlsl",
- new[] { "_BaseTexture", "_BaseColor", "_SharedMask", "_SharedGradients", "_RenderingMode", "_Cutoff", "_Cull" }
+ new[]
+ {
+ "_BaseTexture", "_BaseColor", "_SharedMask", "_SharedGradients", "_RenderingMode", "_Cutoff", "_Cull",
+ "_StencilRef", "_StencilReadMask", "_StencilWriteMask", "_StencilComp", "_StencilPass", "_StencilFail", "_StencilZFail",
+ }
),
new ProductContract(
"PureBase/Toon",
"Packages/jp.penguin.purebase/Shaders/PureBaseToon_properties.hlsl",
new[]
{
- "_BaseTexture", "_BaseColor", "_SharedMask", "_SharedGradients", "_RenderingMode", "_Cutoff", "_Cull", "_NormalMap", "_NormalScale",
+ "_BaseTexture", "_BaseColor", "_SharedMask", "_SharedGradients", "_RenderingMode", "_Cutoff", "_Cull",
+ "_StencilRef", "_StencilReadMask", "_StencilWriteMask", "_StencilComp", "_StencilPass", "_StencilFail", "_StencilZFail",
+ "_NormalMap", "_NormalScale",
}
),
new ProductContract(
@@ -79,7 +85,9 @@ public sealed partial class PureBaseRenderingModeContractTests
"Packages/jp.penguin.purebase/Shaders/PureBasePBR_properties.hlsl",
new[]
{
- "_BaseTexture", "_BaseColor", "_SharedMask", "_SharedGradients", "_RenderingMode", "_Cutoff", "_Cull", "_NormalMap", "_NormalScale", "_Metallic", "_Roughness",
+ "_BaseTexture", "_BaseColor", "_SharedMask", "_SharedGradients", "_RenderingMode", "_Cutoff", "_Cull",
+ "_StencilRef", "_StencilReadMask", "_StencilWriteMask", "_StencilComp", "_StencilPass", "_StencilFail", "_StencilZFail",
+ "_NormalMap", "_NormalScale", "_Metallic", "_Roughness",
}
),
new ProductContract(
@@ -87,7 +95,9 @@ public sealed partial class PureBaseRenderingModeContractTests
"Packages/jp.penguin.purebase/Shaders/PureBaseHybrid_properties.hlsl",
new[]
{
- "_BaseTexture", "_BaseColor", "_SharedMask", "_SharedGradients", "_RenderingMode", "_Cutoff", "_Cull", "_NormalMap", "_NormalScale", "_Metallic", "_Roughness",
+ "_BaseTexture", "_BaseColor", "_SharedMask", "_SharedGradients", "_RenderingMode", "_Cutoff", "_Cull",
+ "_StencilRef", "_StencilReadMask", "_StencilWriteMask", "_StencilComp", "_StencilPass", "_StencilFail", "_StencilZFail",
+ "_NormalMap", "_NormalScale", "_Metallic", "_Roughness",
}
),
};
@@ -161,6 +171,18 @@ public sealed partial class PureBaseRenderingModeContractTests
),
};
+ /// Lists the common visible Stencil ABI, imported defaults, required drawer attributes, and test overrides.
+ private static readonly StencilPropertyContract[] StencilProperties =
+ {
+ new StencilPropertyContract("_StencilRef", 0.0f, 37.0f, "SCRangeInt(0,255)", @"SCRangeInt\s*\(\s*0\s*,\s*255\s*\)", @"0(?:\.0+)?"),
+ new StencilPropertyContract("_StencilReadMask", 255.0f, 203.0f, "SCRangeInt(0,255)", @"SCRangeInt\s*\(\s*0\s*,\s*255\s*\)", @"255(?:\.0+)?"),
+ new StencilPropertyContract("_StencilWriteMask", 255.0f, 85.0f, "SCRangeInt(0,255)", @"SCRangeInt\s*\(\s*0\s*,\s*255\s*\)", @"255(?:\.0+)?"),
+ new StencilPropertyContract("_StencilComp", 8.0f, 3.0f, "SCEnum(UnityEngine.Rendering.CompareFunction)", @"SCEnum\s*\(\s*UnityEngine\.Rendering\.CompareFunction\s*\)", @"8(?:\.0+)?"),
+ new StencilPropertyContract("_StencilPass", 0.0f, 2.0f, "SCEnum(UnityEngine.Rendering.StencilOp)", @"SCEnum\s*\(\s*UnityEngine\.Rendering\.StencilOp\s*\)", @"0(?:\.0+)?"),
+ new StencilPropertyContract("_StencilFail", 0.0f, 5.0f, "SCEnum(UnityEngine.Rendering.StencilOp)", @"SCEnum\s*\(\s*UnityEngine\.Rendering\.StencilOp\s*\)", @"0(?:\.0+)?"),
+ new StencilPropertyContract("_StencilZFail", 0.0f, 4.0f, "SCEnum(UnityEngine.Rendering.StencilOp)", @"SCEnum\s*\(\s*UnityEngine\.Rendering\.StencilOp\s*\)", @"0(?:\.0+)?"),
+ };
+
/// Requires the complete shader ABI, static Cutout defaults, pass ABI, and local-keyword declaration.
[Test]
public void ProductShadersExposeRenderingModeAndCutoutCompatibleStaticDefaults()
@@ -188,6 +210,36 @@ private static void AssertProductShaderAbi(ProductContract product, Shader shade
Assert.That(cutoffIndex, Is.GreaterThanOrEqualTo(0), $"Product shader '{product.shaderName}' must expose _Cutoff.");
CollectionAssert.Contains(shader.GetPropertyAttributes(cutoffIndex), "PureBaseCutoff", $"Product shader '{product.shaderName}' must use the Pure-Base Cutoff drawer.");
Assert.That(Regex.IsMatch(File.ReadAllText(product.propertySourcePath), CutoffPropertySourcePattern), Is.True, $"Product property source '{product.propertySourcePath}' must declare _Cutoff with the PureBaseCutoff drawer and SCRange(-0.001,1.001).");
+ AssertStencilPropertyAbi(product, shader);
+ }
+
+ /// Asserts the exact common Stencil property ABI in imported metadata and source order.
+ /// The product shader contract.
+ /// The imported product shader.
+ private static void AssertStencilPropertyAbi(ProductContract product, Shader shader)
+ {
+ string source = File.ReadAllText(product.propertySourcePath);
+ int previousShaderIndex = -1;
+ int previousSourceIndex = -1;
+ foreach (StencilPropertyContract property in StencilProperties)
+ {
+ int shaderIndex = shader.FindPropertyIndex(property.name);
+ Assert.That(shaderIndex, Is.GreaterThanOrEqualTo(0), $"Product shader '{product.shaderName}' must expose {property.name}.");
+ Assert.That(shaderIndex, Is.GreaterThan(previousShaderIndex), $"Product shader '{product.shaderName}' must preserve the common Stencil property order.");
+ Assert.That(shader.GetPropertyType(shaderIndex), Is.EqualTo(ShaderPropertyType.Float), $"Product shader '{product.shaderName}' property '{property.name}' must be a Float.");
+ Assert.That(shader.GetPropertyDefaultFloatValue(shaderIndex), Is.EqualTo(property.defaultValue), $"Product shader '{product.shaderName}' property '{property.name}' default value.");
+ CollectionAssert.AreEqual(new[] { property.attribute }, shader.GetPropertyAttributes(shaderIndex), $"Product shader '{product.shaderName}' property '{property.name}' must expose exactly its permitted drawer attribute.");
+
+ string declarationPattern =
+ @"SC_float\s*\(\s*" + Regex.Escape(property.name) + @"\s*,\s*" + property.sourceDefaultPattern +
+ @"\s*,\s*\[\s*" + property.sourceAttributePattern + @"\s*\]\s*,\s*""[^""\r\n]*""\s*,\s*""[^""\r\n]*""\s*\)";
+ Match declaration = Regex.Match(source, declarationPattern);
+ Assert.That(declaration.Success, Is.True, $"Product property source '{product.propertySourcePath}' must declare {property.name} with SC_float, its default, and exactly its permitted {property.attribute} drawer attribute.");
+ Assert.That(declaration.Index, Is.GreaterThan(previousSourceIndex), $"Product property source '{product.propertySourcePath}' must preserve the common Stencil property order.");
+
+ previousShaderIndex = shaderIndex;
+ previousSourceIndex = declaration.Index;
+ }
}
/// Asserts the static Cutout defaults, pass ABI, and keyword declarations for one product shader.
@@ -279,6 +331,44 @@ public void ExplicitModeNormalizationMatchesTheCompleteFourByThreeStateTable()
}
}
+ /// Requires every normalizer mode and read-only resync to preserve non-default Stencil overrides.
+ [Test]
+ public void ExplicitModeNormalizationPreservesStencilOverridesAcrossProducts()
+ {
+ MethodInfo apply = RequireApplyMethod();
+ MethodInfo refreshSelection = RequireDrawerSelectionRefreshMethod();
+ foreach (ProductContract product in Products)
+ {
+ var material = CreateMaterial(RequireProductShader(product.shaderName));
+ foreach (StencilPropertyContract property in StencilProperties)
+ {
+ Assert.That(material.shader.FindPropertyIndex(property.name), Is.GreaterThanOrEqualTo(0), product.shaderName + " must expose " + property.name + " before normalizer retention is tested.");
+ material.SetFloat(property.name, property.overrideValue);
+ }
+
+ foreach (ModeContract mode in Modes)
+ {
+ material.SetInteger("_RenderingMode", mode.value);
+ InvokeApply(apply, material);
+ AssertStencilOverrides(material, product.shaderName + " " + mode.name + " after Apply");
+ InvokeDrawerSelectionRefresh(refreshSelection, new[] { material });
+ AssertStencilOverrides(material, product.shaderName + " " + mode.name + " after Resync");
+ }
+ }
+ }
+
+ /// Asserts the non-default Stencil values retained by Apply and read-only Inspector resync.
+ /// The normalized material.
+ /// The operation context used in diagnostics.
+ private static void AssertStencilOverrides(Material material, string context)
+ {
+ foreach (StencilPropertyContract property in StencilProperties)
+ {
+ Assert.That(material.HasProperty(property.name), Is.True, context + " must retain " + property.name + ".");
+ Assert.That(material.GetFloat(property.name), Is.EqualTo(property.overrideValue), context + " property " + property.name + ".");
+ }
+ }
+
/// Requires the public enum and method shape through reflection so missing production code remains a test failure.
[Test]
public void PublicRenderingModeApiIsDiscoverableWithoutATestAssemblyDependency()
@@ -307,5 +397,44 @@ public void PublicRenderingModeApiIsDiscoverableWithoutATestAssemblyDependency()
Assert.That(RequireApplyMethod(), Is.Not.Null);
}
+ /// Stores one common Stencil property metadata contract and its non-default retention value.
+ private sealed class StencilPropertyContract
+ {
+ /// Initializes one Stencil property contract.
+ /// The stable public property name.
+ /// The imported shader default.
+ /// The non-default value used by the normalizer test.
+ /// The exact imported drawer attribute.
+ /// The whitespace-tolerant source attribute pattern.
+ /// The whitespace-tolerant source default pattern.
+ public StencilPropertyContract(string name, float defaultValue, float overrideValue, string attribute, string sourceAttributePattern, string sourceDefaultPattern)
+ {
+ this.name = name;
+ this.defaultValue = defaultValue;
+ this.overrideValue = overrideValue;
+ this.attribute = attribute;
+ this.sourceAttributePattern = sourceAttributePattern;
+ this.sourceDefaultPattern = sourceDefaultPattern;
+ }
+
+ /// Stores the stable public property name.
+ public readonly string name;
+
+ /// Stores the imported shader default.
+ public readonly float defaultValue;
+
+ /// Stores the non-default normalizer retention value.
+ public readonly float overrideValue;
+
+ /// Stores the exact imported drawer attribute.
+ public readonly string attribute;
+
+ /// Stores the source drawer attribute pattern.
+ public readonly string sourceAttributePattern;
+
+ /// Stores the source default value pattern.
+ public readonly string sourceDefaultPattern;
+ }
+
}
}
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.D24S8StencilFixture.cs b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.D24S8StencilFixture.cs
new file mode 100644
index 0000000..8d25696
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.D24S8StencilFixture.cs
@@ -0,0 +1,694 @@
+/*
+ * 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 the isolated D24S8 fixture used by the Stencil rendering tests.
+
+using System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+using UnityEngine.Experimental.Rendering;
+using UnityEngine.Rendering;
+using UnityEngine.SceneManagement;
+
+namespace PureBase.Tests.Daily
+{
+ public sealed partial class PureBaseRenderingModeRenderingTests
+ {
+ /// Owns an isolated D3D11 D24S8 camera, readback target, mesh, material, command-buffer, and light fixture.
+ private sealed class D24S8StencilFixture : IDisposable
+ {
+ /// Identifies the isolated rendering layer used by the temporary fixture.
+ private const int FixtureLayer = 31;
+
+ /// Identifies the imported RenderTexture asset that prevents compatible-format promotion.
+ private const string RenderTextureAssetPath = "Packages/jp.penguin.purebase/Tests/Daily/Editor/PureBaseD24S8StencilTarget.renderTexture";
+
+ /// Stores the isolated preview scene that prevents unrelated scene content from affecting readback.
+ private Scene scene;
+
+ /// Stores the command buffer used to explicitly clear color, depth, and Stencil before every observation.
+ private CommandBuffer commandBuffer;
+
+ /// Stores the temporary camera GameObject.
+ private GameObject cameraObject;
+
+ /// Stores the imported D24S8 render target asset whose GPU resource is owned only while this fixture runs.
+ private RenderTexture renderTexture;
+
+ /// Stores the temporary linear CPU readback texture.
+ private Texture2D texture;
+
+ /// Stores the temporary rear quad GameObject used by single draws and Stencil writers.
+ private GameObject writerObject;
+
+ /// Stores the temporary front quad GameObject used by Stencil readers.
+ private GameObject readerObject;
+
+ /// Stores the temporary directional light GameObjects in creation order.
+ private readonly List lightObjects = new List();
+
+ /// Stores the temporary materials in creation order.
+ private readonly List materials = new List();
+
+ /// Stores the pixel-light budget before the Toon ForwardAdd observation changes it.
+ private int pixelLightCount;
+
+ /// Tracks whether the pixel-light budget must be restored during partial fixture cleanup.
+ private bool pixelLightCountCaptured;
+
+ /// Stores the ambient-light mode before the fixture removes environment light from black writer observations.
+ private AmbientMode ambientMode;
+
+ /// Stores the ambient color before the fixture removes environment light from black writer observations.
+ private Color ambientLight;
+
+ /// Stores the reflection intensity before the fixture removes environment light from black writer observations.
+ private float reflectionIntensity;
+
+ /// Stores the BIRP sun before the fixture assigns its own main directional light.
+ private Light sun;
+
+ /// Tracks whether the modified global render settings must be restored during partial fixture cleanup.
+ private bool renderSettingsCaptured;
+
+ /// Gets format and device diagnostics for every product-behavior assertion.
+ public string FormatDescription
+ {
+ get
+ {
+ return "Device=" + SystemInfo.graphicsDeviceType +
+ " AssetPath=" + RenderTextureAssetPath +
+ " RequestedColor=" + (renderTexture == null ? "" : renderTexture.descriptor.graphicsFormat.ToString()) +
+ " RequestedDepthStencil=" + GraphicsFormat.D24_UNorm_S8_UInt +
+ " ActualColor=" + (renderTexture == null ? "" : renderTexture.graphicsFormat.ToString()) +
+ " ActualDepthStencil=" + (renderTexture == null ? "" : renderTexture.depthStencilFormat.ToString()) +
+ " StencilBits=" + (renderTexture == null ? "" : GetStencilBitCount(renderTexture.depthStencilFormat).ToString()) +
+ " RequestedRenderingPath=" + (cameraObject == null ? "" : cameraObject.GetComponent().renderingPath.ToString()) +
+ " ActualRenderingPath=" + (cameraObject == null ? "" : cameraObject.GetComponent().actualRenderingPath.ToString()) +
+ " PixelLightCount=" + QualitySettings.pixelLightCount +
+ " IsCreated=" + (renderTexture != null && renderTexture.IsCreated());
+ }
+ }
+
+ /// Allocates an isolated D3D11 fixture around the imported D24S8 asset and verifies its exact attachment format.
+ public void Initialize()
+ {
+ pixelLightCount = QualitySettings.pixelLightCount;
+ pixelLightCountCaptured = true;
+ QualitySettings.pixelLightCount = Math.Max(2, pixelLightCount);
+ ambientMode = RenderSettings.ambientMode;
+ ambientLight = RenderSettings.ambientLight;
+ reflectionIntensity = RenderSettings.reflectionIntensity;
+ sun = RenderSettings.sun;
+ renderSettingsCaptured = true;
+ RenderSettings.ambientMode = AmbientMode.Flat;
+ RenderSettings.ambientLight = Color.black;
+ RenderSettings.reflectionIntensity = 0.0f;
+ Assert.That(SystemInfo.graphicsDeviceType, Is.EqualTo(GraphicsDeviceType.Direct3D11), "The D24S8 Stencil fixture requires D3D11 and must not silently run on " + SystemInfo.graphicsDeviceType + ".");
+ Assert.That(SystemInfo.IsFormatSupported(GraphicsFormat.D24_UNorm_S8_UInt, FormatUsage.Render), Is.True, "D3D11 must support D24_UNorm_S8_UInt as a render attachment for the Stencil fixture.");
+ Assert.That(SystemInfo.IsFormatSupported(GraphicsFormat.R8G8B8A8_UNorm, FormatUsage.Render), Is.True, "D3D11 must support the fixture color attachment format.");
+
+ renderTexture = LoadAndCreateD24S8RenderTextureAsset();
+
+ scene = EditorSceneManager.NewPreviewScene();
+ commandBuffer = new CommandBuffer { name = "PureBase D24S8 Stencil Clear" };
+
+ cameraObject = new GameObject("PureBaseD24S8StencilCamera");
+ SceneManager.MoveGameObjectToScene(cameraObject, scene);
+ texture = new Texture2D(RenderSize, RenderSize, TextureFormat.RGBA32, false, true);
+ writerObject = GameObject.CreatePrimitive(PrimitiveType.Quad);
+ SceneManager.MoveGameObjectToScene(writerObject, scene);
+ readerObject = GameObject.CreatePrimitive(PrimitiveType.Quad);
+ SceneManager.MoveGameObjectToScene(readerObject, scene);
+
+ ConfigureCamera();
+ ConfigureQuad();
+ SetLightCount(1);
+ }
+
+ /// Renders one configured product material after an explicit color, depth, and Stencil clear.
+ /// The product shader under observation.
+ /// The material base color.
+ /// The initial Stencil byte.
+ /// The explicit material Stencil state.
+ /// The Pure-Base rendering mode to apply.
+ /// The center pixel after the product draw.
+ public Color RenderSingle(Shader shader, Color baseColor, byte clearStencil, StencilState stencilState, int renderingMode)
+ {
+ Material material = CreateProductMaterial(shader, baseColor, stencilState, renderingMode, "single draw");
+ ClearTarget(clearStencil);
+ RenderMaterial(material);
+ return ReadCenterPixel(renderTexture, texture);
+ }
+
+ /// Renders a Stencil writer and reader consecutively without clearing their shared depth-stencil attachment between draws.
+ /// The product shader under observation.
+ /// The initial Stencil byte.
+ /// The writer material base color.
+ /// The writer material Stencil state.
+ /// The reader material base color.
+ /// The reader material Stencil state.
+ /// The Pure-Base rendering mode to apply to both materials.
+ /// Receives the center pixel from the same writer without a reader.
+ /// The center pixel after the reader draw.
+ public Color RenderWriterThenReader(Shader shader, byte clearStencil, Color writerColor, StencilState writerState, Color readerColor, StencilState readerState, int renderingMode, out Color writerOnly)
+ {
+ Material writer = CreateProductMaterial(shader, writerColor, writerState, renderingMode, "Stencil writer");
+ Material reader = CreateProductMaterial(shader, readerColor, readerState, renderingMode, "Stencil reader");
+ Material control = CreateProductMaterial(
+ shader,
+ readerColor,
+ new StencilState(0, 255, 0, CompareFunction.Always, StencilOp.Keep, StencilOp.Keep, StencilOp.Keep),
+ renderingMode,
+ "Always+Keep Stencil reader control"
+ );
+
+ RenderStencilSequence(clearStencil, writer, RearDepth, control, FrontDepth);
+ Color controlPixel = ReadCenterPixel(renderTexture, texture);
+ AssertFinite(controlPixel, shader.name + " Always+Keep reader control " + FormatDescription);
+ Assert.That(RgbMagnitude(controlPixel), Is.GreaterThan(0.05f), shader.name + " Always+Keep reader control after the writer must render before Equal/NotEqual is observed. " + FormatDescription + " Pixel=" + controlPixel);
+
+ ClearTarget(clearStencil);
+ RenderMaterial(writer);
+ writerOnly = ReadCenterPixel(renderTexture, texture);
+ RenderStencilSequence(clearStencil, writer, RearDepth, reader, FrontDepth);
+ return ReadCenterPixel(renderTexture, texture);
+ }
+
+ /// Renders Toon with a controlled number of directional lights to observe its ForwardBase and ForwardAdd composition.
+ /// The Toon product shader.
+ /// The initial Stencil byte.
+ /// The material Stencil state.
+ /// The number of directional lights to render.
+ /// The center pixel after the Toon composite.
+ public Color RenderToonComposite(Shader shader, byte clearStencil, StencilState stencilState, int lightCount)
+ {
+ SetLightCount(lightCount);
+ Material material = CreateProductMaterial(shader, new Color(0.8f, 0.6f, 0.4f, 0.5f), stencilState, 2, "Toon ForwardAdd composite");
+ AssertToonForwardAddPreconditions(lightCount, material);
+ ClearTarget(clearStencil);
+ RenderMaterial(material);
+ return ReadCenterPixel(renderTexture, texture);
+ }
+
+ /// Releases every temporary resource in reverse creation order, including partial initialization after an exception.
+ public void Dispose()
+ {
+ try
+ {
+ DestroyMaterials();
+ DestroyLights();
+ DestroyReader();
+ DestroyWriter();
+ DestroyTexture();
+ DetachCameraTarget();
+ ReleaseRenderTexture();
+ DestroyCamera();
+ ReleaseCommandBuffer();
+ CloseScene();
+ }
+ finally
+ {
+ RestorePixelLightCount();
+ RestoreRenderSettings();
+ }
+ }
+
+ /// Destroys temporary materials in reverse creation order.
+ private void DestroyMaterials()
+ {
+ for (int index = materials.Count - 1; index >= 0; index--)
+ {
+ UnityEngine.Object.DestroyImmediate(materials[index]);
+ }
+
+ materials.Clear();
+ }
+
+ /// Destroys the temporary front reader quad.
+ private void DestroyReader()
+ {
+ if (readerObject != null)
+ {
+ UnityEngine.Object.DestroyImmediate(readerObject);
+ }
+ }
+
+ /// Destroys the temporary rear writer quad.
+ private void DestroyWriter()
+ {
+ if (writerObject != null)
+ {
+ UnityEngine.Object.DestroyImmediate(writerObject);
+ }
+ }
+
+ /// Destroys the temporary CPU readback texture.
+ private void DestroyTexture()
+ {
+ if (texture != null)
+ {
+ UnityEngine.Object.DestroyImmediate(texture);
+ }
+ }
+
+ /// Detaches the camera from the owned render target before either resource is released.
+ private void DetachCameraTarget()
+ {
+ Camera camera = cameraObject == null ? null : cameraObject.GetComponent();
+ if (camera != null)
+ {
+ camera.targetTexture = null;
+ }
+ }
+
+ /// Releases the imported render target GPU resource without destroying its asset.
+ private void ReleaseRenderTexture()
+ {
+ if (renderTexture != null)
+ {
+ renderTexture.Release();
+ }
+ }
+
+ /// Destroys the temporary camera GameObject.
+ private void DestroyCamera()
+ {
+ if (cameraObject != null)
+ {
+ UnityEngine.Object.DestroyImmediate(cameraObject);
+ }
+ }
+
+ /// Releases the temporary command buffer.
+ private void ReleaseCommandBuffer()
+ {
+ if (commandBuffer != null)
+ {
+ commandBuffer.Release();
+ }
+ }
+
+ /// Closes the isolated preview scene after all scene-owned objects are destroyed.
+ private void CloseScene()
+ {
+ if (scene.IsValid() && scene.isLoaded)
+ {
+ EditorSceneManager.ClosePreviewScene(scene);
+ }
+ }
+
+ /// Restores the pixel-light budget captured before initialization.
+ private void RestorePixelLightCount()
+ {
+ if (pixelLightCountCaptured)
+ {
+ QualitySettings.pixelLightCount = pixelLightCount;
+ }
+ }
+
+ /// Restores render settings captured before initialization.
+ private void RestoreRenderSettings()
+ {
+ if (renderSettingsCaptured)
+ {
+ RenderSettings.ambientMode = ambientMode;
+ RenderSettings.ambientLight = ambientLight;
+ RenderSettings.reflectionIntensity = reflectionIntensity;
+ RenderSettings.sun = sun;
+ }
+ }
+
+ /// Loads and validates the required non-fallback D24S8 asset before allocating its GPU resource.
+ /// The exact tracked D24S8 asset with a created GPU resource.
+ private static RenderTexture LoadAndCreateD24S8RenderTextureAsset()
+ {
+ RenderTexture target = AssetDatabase.LoadAssetAtPath(RenderTextureAssetPath);
+ Assert.That(target, Is.Not.Null, "The D24S8 fixture asset must exist at '" + RenderTextureAssetPath + "'. This is fixture configuration, not product behavior.");
+ AssertCompatibleFormatFallbackIsDisabled(target);
+
+ RenderTextureDescriptor descriptor = target.descriptor;
+ Assert.That(descriptor.graphicsFormat, Is.EqualTo(GraphicsFormat.R8G8B8A8_UNorm), "The D24S8 fixture asset descriptor must request R8G8B8A8_UNorm color without fallback. " + DescribeTarget(target));
+ Assert.That(descriptor.depthStencilFormat, Is.EqualTo(GraphicsFormat.D24_UNorm_S8_UInt), "The D24S8 fixture asset descriptor must request D24_UNorm_S8_UInt depth-stencil without fallback. " + DescribeTarget(target));
+ Assert.That(target.antiAliasing, Is.EqualTo(1), "The D24S8 fixture asset must use MSAA 1. " + DescribeTarget(target));
+ Assert.That(descriptor.msaaSamples, Is.EqualTo(1), "The D24S8 fixture descriptor must use MSAA 1. " + DescribeTarget(target));
+ Assert.That(target.sRGB, Is.False, "The D24S8 fixture asset must be linear and non-sRGB. " + DescribeTarget(target));
+ Assert.That(descriptor.sRGB, Is.False, "The D24S8 fixture descriptor must be linear and non-sRGB. " + DescribeTarget(target));
+ Assert.That(target.useMipMap, Is.False, "The D24S8 fixture asset must not use mipmaps. " + DescribeTarget(target));
+ Assert.That(descriptor.useMipMap, Is.False, "The D24S8 fixture descriptor must not use mipmaps. " + DescribeTarget(target));
+ Assert.That(target.useDynamicScale, Is.False, "The D24S8 fixture asset must not use dynamic scaling. " + DescribeTarget(target));
+ Assert.That(descriptor.useDynamicScale, Is.False, "The D24S8 fixture descriptor must not use dynamic scaling. " + DescribeTarget(target));
+ Assert.That(target.enableRandomWrite, Is.False, "The D24S8 fixture asset must not enable random writes. " + DescribeTarget(target));
+ Assert.That(descriptor.enableRandomWrite, Is.False, "The D24S8 fixture descriptor must not enable random writes. " + DescribeTarget(target));
+
+ try
+ {
+ Assert.That(target.Create(), Is.True, "The configured D24S8 fixture asset must create its GPU resource without compatible-format fallback. " + DescribeTarget(target));
+ Assert.That(target.IsCreated(), Is.True, "The configured D24S8 fixture asset GPU resource must be created. " + DescribeTarget(target));
+ Assert.That(target.graphicsFormat, Is.EqualTo(GraphicsFormat.R8G8B8A8_UNorm), "The created D24S8 fixture asset must allocate exact R8G8B8A8_UNorm color. " + DescribeTarget(target));
+ Assert.That(target.depthStencilFormat, Is.EqualTo(GraphicsFormat.D24_UNorm_S8_UInt), "The created D24S8 fixture asset must allocate exact D24_UNorm_S8_UInt depth-stencil. " + DescribeTarget(target));
+ Assert.That(RenderTexture.SupportsStencil(target), Is.True, "The created D24S8 fixture asset must provide a Stencil attachment. " + DescribeTarget(target));
+ Assert.That(GetStencilBitCount(target.depthStencilFormat), Is.EqualTo(8), "The created D24S8 fixture asset must provide exactly eight Stencil bits. " + DescribeTarget(target));
+ return target;
+ }
+ catch
+ {
+ target.Release();
+ throw;
+ }
+ }
+
+ /// Loads and conditionally creates the shared D24S8 asset for the active-scene Toon scope.
+ /// Receives whether this call created the GPU resource.
+ /// The exact tracked D24S8 asset with a created GPU resource.
+ public static RenderTexture LoadAndCreateD24S8RenderTextureAssetForToonScope(out bool createdRenderTextureResource)
+ {
+ RenderTexture target = AssetDatabase.LoadAssetAtPath(RenderTextureAssetPath);
+ Assert.That(target, Is.Not.Null, "The D24S8 fixture asset must exist at '" + RenderTextureAssetPath + "'. This is fixture configuration, not product behavior.");
+ AssertCompatibleFormatFallbackIsDisabled(target);
+ bool initiallyCreated = target.IsCreated();
+ createdRenderTextureResource = false;
+
+ RenderTextureDescriptor descriptor = target.descriptor;
+ Assert.That(descriptor.graphicsFormat, Is.EqualTo(GraphicsFormat.R8G8B8A8_UNorm), "The D24S8 fixture asset descriptor must request R8G8B8A8_UNorm color without fallback. " + DescribeTarget(target));
+ Assert.That(descriptor.depthStencilFormat, Is.EqualTo(GraphicsFormat.D24_UNorm_S8_UInt), "The D24S8 fixture asset descriptor must request D24_UNorm_S8_UInt depth-stencil without fallback. " + DescribeTarget(target));
+ Assert.That(target.antiAliasing, Is.EqualTo(1), "The D24S8 fixture asset must use MSAA 1. " + DescribeTarget(target));
+ Assert.That(descriptor.msaaSamples, Is.EqualTo(1), "The D24S8 fixture descriptor must use MSAA 1. " + DescribeTarget(target));
+ Assert.That(target.sRGB, Is.False, "The D24S8 fixture asset must be linear and non-sRGB. " + DescribeTarget(target));
+ Assert.That(descriptor.sRGB, Is.False, "The D24S8 fixture descriptor must be linear and non-sRGB. " + DescribeTarget(target));
+ Assert.That(target.useMipMap, Is.False, "The D24S8 fixture asset must not use mipmaps. " + DescribeTarget(target));
+ Assert.That(descriptor.useMipMap, Is.False, "The D24S8 fixture descriptor must not use mipmaps. " + DescribeTarget(target));
+ Assert.That(target.useDynamicScale, Is.False, "The D24S8 fixture asset must not use dynamic scaling. " + DescribeTarget(target));
+ Assert.That(descriptor.useDynamicScale, Is.False, "The D24S8 fixture descriptor must not use dynamic scaling. " + DescribeTarget(target));
+ Assert.That(target.enableRandomWrite, Is.False, "The D24S8 fixture asset must not enable random writes. " + DescribeTarget(target));
+ Assert.That(descriptor.enableRandomWrite, Is.False, "The D24S8 fixture descriptor must not enable random writes. " + DescribeTarget(target));
+
+ try
+ {
+ if (!initiallyCreated)
+ {
+ Assert.That(target.Create(), Is.True, "The configured D24S8 fixture asset must create its GPU resource without compatible-format fallback. " + DescribeTarget(target));
+ createdRenderTextureResource = true;
+ }
+
+ Assert.That(target.IsCreated(), Is.True, "The configured D24S8 fixture asset GPU resource must be created. " + DescribeTarget(target));
+ Assert.That(target.graphicsFormat, Is.EqualTo(GraphicsFormat.R8G8B8A8_UNorm), "The created D24S8 fixture asset must allocate exact R8G8B8A8_UNorm color. " + DescribeTarget(target));
+ Assert.That(target.depthStencilFormat, Is.EqualTo(GraphicsFormat.D24_UNorm_S8_UInt), "The created D24S8 fixture asset must allocate exact D24_UNorm_S8_UInt depth-stencil. " + DescribeTarget(target));
+ Assert.That(RenderTexture.SupportsStencil(target), Is.True, "The created D24S8 fixture asset must provide a Stencil attachment. " + DescribeTarget(target));
+ Assert.That(GetStencilBitCount(target.depthStencilFormat), Is.EqualTo(8), "The created D24S8 fixture asset must provide exactly eight Stencil bits. " + DescribeTarget(target));
+ return target;
+ }
+ catch
+ {
+ if (createdRenderTextureResource)
+ {
+ target.Release();
+ }
+
+ throw;
+ }
+ }
+
+ /// Uses the RenderTexture Inspector serialization to require disabled compatible-format fallback without modifying the asset.
+ /// The imported RenderTexture asset.
+ private static void AssertCompatibleFormatFallbackIsDisabled(RenderTexture target)
+ {
+ using (var serializedTarget = new SerializedObject(target))
+ {
+ serializedTarget.Update();
+ SerializedProperty compatibleFallback = serializedTarget.FindProperty("m_EnableCompatibleFormat");
+ Assert.That(compatibleFallback, Is.Not.Null, "The D24S8 fixture asset must expose m_EnableCompatibleFormat through SerializedObject inspection. " + DescribeTarget(target));
+ Assert.That(compatibleFallback.boolValue, Is.False, "The D24S8 fixture asset must disable compatible-format fallback. " + DescribeTarget(target));
+ }
+ }
+
+ /// Returns the Stencil bit count implied by a verified exact D24S8 allocation format.
+ /// The actual RenderTexture depth-stencil attachment format.
+ /// Eight for exact D24S8; otherwise zero so fixture configuration failures remain explicit.
+ private static int GetStencilBitCount(GraphicsFormat depthStencilFormat)
+ {
+ return depthStencilFormat == GraphicsFormat.D24_UNorm_S8_UInt ? 8 : 0;
+ }
+
+ /// Formats target diagnostics for fixture configuration and allocation failures.
+ /// The target being inspected.
+ /// Inspectable asset, descriptor, and allocation details.
+ public static string DescribeTarget(RenderTexture target)
+ {
+ if (target == null)
+ {
+ return "AssetPath=" + RenderTextureAssetPath + " Target=";
+ }
+
+ RenderTextureDescriptor descriptor = target.descriptor;
+ return "AssetPath=" + RenderTextureAssetPath +
+ " DescriptorColor=" + descriptor.graphicsFormat +
+ " DescriptorDepthStencil=" + descriptor.depthStencilFormat +
+ " DescriptorMSAA=" + descriptor.msaaSamples +
+ " DescriptorSRGB=" + descriptor.sRGB +
+ " DescriptorMipMap=" + descriptor.useMipMap +
+ " DescriptorDynamicScale=" + descriptor.useDynamicScale +
+ " DescriptorRandomWrite=" + descriptor.enableRandomWrite +
+ " ActualColor=" + target.graphicsFormat +
+ " ActualDepthStencil=" + target.depthStencilFormat +
+ " ActualMSAA=" + target.antiAliasing +
+ " ActualSRGB=" + target.sRGB +
+ " ActualMipMap=" + target.useMipMap +
+ " ActualDynamicScale=" + target.useDynamicScale +
+ " ActualRandomWrite=" + target.enableRandomWrite +
+ " StencilBits=" + GetStencilBitCount(target.depthStencilFormat) +
+ " IsCreated=" + target.IsCreated();
+ }
+
+ /// Configures the fixture camera to render only its isolated preview scene into the D24S8 target.
+ private void ConfigureCamera()
+ {
+ Camera camera = cameraObject.AddComponent();
+ camera.enabled = false;
+ camera.orthographic = true;
+ camera.orthographicSize = 0.5f;
+ camera.renderingPath = RenderingPath.Forward;
+ camera.cullingMask = 1 << FixtureLayer;
+ camera.overrideSceneCullingMask = EditorSceneManager.GetSceneCullingMask(scene);
+ camera.clearFlags = CameraClearFlags.Nothing;
+ camera.transform.position = new Vector3(0.0f, 0.0f, -2.0f);
+ camera.targetTexture = renderTexture;
+ }
+
+ /// Configures the full-coverage rear writer and front reader quads rendered by every product observation.
+ private void ConfigureQuad()
+ {
+ ConfigureQuad(writerObject, RearDepth);
+ ConfigureQuad(readerObject, FrontDepth);
+ }
+
+ /// Configures one full-coverage quad at its deterministic camera-relative depth.
+ /// The quad receiving fixture layer and visibility state.
+ /// The camera-relative depth used for the ordered draw.
+ private static void ConfigureQuad(GameObject quadObject, float depth)
+ {
+ quadObject.layer = FixtureLayer;
+ quadObject.transform.position = new Vector3(0.0f, 0.0f, depth);
+ quadObject.GetComponent().enabled = false;
+ }
+
+ /// Creates the requested number of directional lights for a ForwardAdd observation.
+ /// The required number of lights.
+ private void SetLightCount(int lightCount)
+ {
+ Assert.That(lightCount, Is.GreaterThanOrEqualTo(1), "The Stencil fixture must have at least one directional light.");
+ DestroyLights();
+ int cullingMask = 1 << FixtureLayer;
+ for (int index = 0; index < lightCount; index++)
+ {
+ var lightObject = new GameObject("PureBaseD24S8StencilLight" + index);
+ lightObjects.Add(lightObject);
+ SceneManager.MoveGameObjectToScene(lightObject, scene);
+ lightObject.layer = FixtureLayer;
+ Light light = lightObject.AddComponent();
+ light.type = LightType.Directional;
+ light.renderMode = LightRenderMode.ForcePixel;
+ light.color = Color.white;
+ light.intensity = 1.0f;
+ light.cullingMask = cullingMask;
+ lightObject.transform.rotation = Quaternion.Euler(30.0f, index == 0 ? -30.0f : 30.0f, 0.0f);
+ }
+
+ RenderSettings.sun = lightObjects[0].GetComponent();
+ }
+
+ /// Requires the explicit BIRP Forward setup that makes a second directional light observable in ForwardAdd.
+ /// The requested number of isolated directional lights.
+ /// The Toon material with deterministic normal input.
+ private void AssertToonForwardAddPreconditions(int lightCount, Material material)
+ {
+ Camera camera = cameraObject.GetComponent();
+ Assert.That(camera.renderingPath, Is.EqualTo(RenderingPath.Forward), "The Toon Stencil fixture must force the BIRP Forward camera path before observing ForwardAdd.");
+ Assert.That(QualitySettings.pixelLightCount, Is.GreaterThanOrEqualTo(2), "The Toon Stencil fixture must allow at least two pixel lights before observing ForwardAdd.");
+ Assert.That(lightCount, Is.GreaterThanOrEqualTo(1), "The Toon Stencil fixture requires at least one directional light.");
+ Assert.That(lightObjects.Count, Is.EqualTo(lightCount), "The Toon Stencil fixture must create exactly the requested number of directional lights.");
+ foreach (GameObject lightObject in lightObjects)
+ {
+ Light light = lightObject.GetComponent();
+ Assert.That(light.type, Is.EqualTo(LightType.Directional), "The Toon Stencil fixture requires directional pixel lights.");
+ Assert.That(light.renderMode, Is.EqualTo(LightRenderMode.ForcePixel), "The Toon Stencil fixture must force each directional light to the pixel-light path.");
+ Assert.That(light.intensity, Is.GreaterThan(0.0f), "The Toon Stencil fixture requires a nonzero directional-light intensity.");
+ }
+
+ Assert.That(material.HasProperty("_NormalMap"), Is.True, "The Toon Stencil fixture requires the public normal-map input before observing ForwardAdd.");
+ Assert.That(material.GetTexture("_NormalMap"), Is.EqualTo(Texture2D.normalTexture), "The Toon Stencil fixture must use the neutral normal map before observing ForwardAdd.");
+ Assert.That(material.GetFloat("_NormalScale"), Is.EqualTo(1.0f), "The Toon Stencil fixture must use unit normal-map scale before observing ForwardAdd.");
+ }
+
+ /// Destroys temporary directional lights in reverse creation order.
+ private void DestroyLights()
+ {
+ for (int index = lightObjects.Count - 1; index >= 0; index--)
+ {
+ UnityEngine.Object.DestroyImmediate(lightObjects[index]);
+ }
+
+ lightObjects.Clear();
+ }
+
+ /// Creates one owned product material and applies the requested mode and explicit Stencil values.
+ /// The product shader.
+ /// The material base color.
+ /// The required Stencil state.
+ /// The Pure-Base rendering mode.
+ /// The scenario used to distinguish missing product behavior from fixture failure.
+ /// The configured material.
+ private Material CreateProductMaterial(Shader shader, Color baseColor, StencilState stencilState, int renderingMode, string scenario)
+ {
+ Assert.That(shader, Is.Not.Null, "The product shader is required for the D24S8 Stencil " + scenario + " fixture.");
+ var material = new Material(shader);
+ materials.Add(material);
+ Assert.That(material.HasProperty("_BaseColor"), Is.True, "Product shader '" + shader.name + "' must expose _BaseColor for D24S8 Stencil " + scenario + ". " + FormatDescription);
+ Assert.That(material.HasProperty("_Cutoff"), Is.True, "Product shader '" + shader.name + "' must expose _Cutoff for D24S8 Stencil " + scenario + ". " + FormatDescription);
+ material.SetTexture("_BaseTexture", Texture2D.whiteTexture);
+ material.SetColor("_BaseColor", baseColor);
+ material.SetFloat("_Cutoff", 0.5f);
+ if (material.HasProperty("_NormalMap"))
+ {
+ material.SetTexture("_NormalMap", Texture2D.normalTexture);
+ material.SetFloat("_NormalScale", 1.0f);
+ }
+
+ ConfigureMode(material, renderingMode);
+ ConfigureStencil(material, stencilState, scenario);
+ return material;
+ }
+
+ /// Requires the product ABI and assigns every explicit Stencil state property.
+ /// The material receiving Stencil values.
+ /// The required Stencil values.
+ /// The scenario used in failure diagnostics.
+ private void ConfigureStencil(Material material, StencilState stencilState, string scenario)
+ {
+ RequireStencilProperty(material, "_StencilRef", scenario);
+ RequireStencilProperty(material, "_StencilReadMask", scenario);
+ RequireStencilProperty(material, "_StencilWriteMask", scenario);
+ RequireStencilProperty(material, "_StencilComp", scenario);
+ RequireStencilProperty(material, "_StencilPass", scenario);
+ RequireStencilProperty(material, "_StencilFail", scenario);
+ RequireStencilProperty(material, "_StencilZFail", scenario);
+ material.SetFloat("_StencilRef", stencilState.referenceValue);
+ material.SetFloat("_StencilReadMask", stencilState.readMask);
+ material.SetFloat("_StencilWriteMask", stencilState.writeMask);
+ material.SetFloat("_StencilComp", (float)stencilState.comparison);
+ material.SetFloat("_StencilPass", (float)stencilState.passOperation);
+ material.SetFloat("_StencilFail", (float)stencilState.failOperation);
+ material.SetFloat("_StencilZFail", (float)stencilState.depthFailOperation);
+ }
+
+ /// Fails as missing product behavior when the requested visible Stencil property is absent.
+ /// The product material.
+ /// The required public Stencil property.
+ /// The scenario used in failure diagnostics.
+ private void RequireStencilProperty(Material material, string propertyName, string scenario)
+ {
+ Assert.That(material.HasProperty(propertyName), Is.True, "Product shader '" + material.shader.name + "' is missing Stencil ABI property '" + propertyName + "' for " + scenario + ". This is product behavior, not fixture format failure. " + FormatDescription);
+ }
+
+ /// Clears color, depth, and the exact requested Stencil byte through the owned command buffer.
+ /// The Stencil value to install before rendering.
+ private void ClearTarget(byte clearStencil)
+ {
+ commandBuffer.Clear();
+ commandBuffer.SetRenderTarget(renderTexture);
+ commandBuffer.ClearRenderTarget(RTClearFlags.All, Color.clear, 1.0f, clearStencil);
+ Graphics.ExecuteCommandBuffer(commandBuffer);
+ }
+
+ /// Renders one material through the camera without clearing the existing depth-stencil attachment.
+ /// The material to render.
+ private void RenderMaterial(Material material)
+ {
+ Renderer renderer = writerObject.GetComponent();
+ renderer.sharedMaterial = material;
+ renderer.enabled = true;
+ try
+ {
+ cameraObject.GetComponent().Render();
+ }
+ finally
+ {
+ renderer.enabled = false;
+ }
+ }
+
+ /// Draws a rear Stencil writer before a front reader through ordered opaque render queues in one camera render.
+ /// The Stencil value installed before both draws.
+ /// The material drawn behind the reader.
+ /// The camera-relative depth of the writer.
+ /// The material drawn in front of the writer.
+ /// The camera-relative depth of the reader.
+ private void RenderStencilSequence(byte clearStencil, Material rearMaterial, float rearDepth, Material frontMaterial, float frontDepth)
+ {
+ ClearTarget(clearStencil);
+ Renderer writerRenderer = writerObject.GetComponent();
+ Renderer readerRenderer = readerObject.GetComponent();
+ writerObject.transform.position = new Vector3(0.0f, 0.0f, rearDepth);
+ readerObject.transform.position = new Vector3(0.0f, 0.0f, frontDepth);
+ writerRenderer.sharedMaterial = rearMaterial;
+ readerRenderer.sharedMaterial = frontMaterial;
+ rearMaterial.renderQueue = (int)RenderQueue.Geometry - 1;
+ frontMaterial.renderQueue = (int)RenderQueue.Geometry;
+ writerRenderer.enabled = true;
+ readerRenderer.enabled = true;
+ Camera camera = cameraObject.GetComponent();
+ try
+ {
+ camera.Render();
+ }
+ finally
+ {
+ readerRenderer.enabled = false;
+ writerRenderer.enabled = false;
+ }
+ }
+
+ /// Defines the camera-relative depth of the front reader quad.
+ private const float FrontDepth = 0.0f;
+
+ /// Defines the camera-relative depth of the rear writer quad.
+ private const float RearDepth = 0.1f;
+ }
+ }
+}
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.D24S8StencilFixture.cs.meta b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.D24S8StencilFixture.cs.meta
new file mode 100644
index 0000000..16054ed
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.D24S8StencilFixture.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d12a1fe7d0112c44c95eeefec934e0b5
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs
index b3c6edd..1b63b39 100644
--- a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs
+++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs
@@ -17,6 +17,7 @@
// Defines source-order contracts for the rendering-mode BIRP integration.
using System;
+using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using NUnit.Framework;
@@ -39,6 +40,30 @@ public sealed partial class PureBaseRenderingModeRenderingTests
/// Identifies the rendering-mode keyword whose output alpha preserves coverage.
private const string TransparentRenderingModeKeyword = "PUREBASE_RENDERING_TRANSPARENT";
+ /// Identifies the sole allowed generated rendering-mode variant declaration.
+ private const string ExpectedRenderingModeVariantDeclaration = "#pragma shader_feature_local _ PUREBASE_RENDERING_OPAQUE PUREBASE_RENDERING_TRANSPARENT";
+
+ /// Identifies the built-in Scene View variant retained by generated product sources.
+ private const string ExpectedEditorVisualizationVariantDeclaration = "#pragma shader_feature EDITOR_VISUALIZATION";
+
+ /// Lists the product shaders whose generated BIRP source shares the Stencil pass policy.
+ private static readonly string[] ProductShaderNames =
+ {
+ "PureBase/Unlit",
+ "PureBase/Toon",
+ "PureBase/PBR",
+ "PureBase/Hybrid",
+ };
+
+ /// Lists the exact pass ABI retained by every product generated source.
+ private static readonly string[] ExpectedPassNames =
+ {
+ "ForwardBase",
+ "ForwardAdd",
+ "ShadowCaster",
+ "Meta",
+ };
+
/// Identifies the release-only postpixel alpha probe source.
private const string PostPixelProbePath = "Packages/jp.penguin.purebase/Tests/Release/Modules/Standard/PostPixel/phase_postpixel.hlsl";
@@ -72,6 +97,192 @@ public void BirpHostPreservesModeAlphaFogPostPixelAndForwardAddSourceOrder()
StringAssert.Contains("sd.col.a = half(0.25)", File.ReadAllText(PostPixelProbePath));
}
+ /// Requires pass-bounded Stencil policy while preserving the existing pass and rendering-mode keyword ABI.
+ [Test]
+ public void ProductGeneratedSourcesExposeStencilPassContractsWithoutNewVariantsOrPasses()
+ {
+ foreach (string shaderName in ProductShaderNames)
+ {
+ string source = LoadProductSource(shaderName);
+ AssertExpectedPassNames(source, shaderName);
+ AssertRenderingModeKeywordContracts(source, shaderName);
+ AssertNoStencilKeywordsOrPasses(source, shaderName);
+
+ string forwardBasePrefix = ExtractRenderStatePrefix(ExtractNamedPass(source, "ForwardBase"), shaderName, "ForwardBase");
+ string forwardAddPrefix = ExtractRenderStatePrefix(ExtractNamedPass(source, "ForwardAdd"), shaderName, "ForwardAdd");
+ string shadowCasterPrefix = ExtractRenderStatePrefix(ExtractNamedPass(source, "ShadowCaster"), shaderName, "ShadowCaster");
+ string metaPrefix = ExtractRenderStatePrefix(ExtractNamedPass(source, "Meta"), shaderName, "Meta");
+
+ AssertForwardBaseStencilBlock(forwardBasePrefix, shaderName);
+ AssertForwardAddStencilBlock(forwardAddPrefix, shaderName);
+ AssertNoStencilRenderState(shadowCasterPrefix, shaderName, "ShadowCaster");
+ AssertNoStencilRenderState(metaPrefix, shaderName, "Meta");
+ }
+ }
+
+ /// Asserts that generated source has exactly the established four named passes.
+ /// The generated shader source.
+ /// The product shader name used in diagnostics.
+ private static void AssertExpectedPassNames(string source, string shaderName)
+ {
+ var passNames = new List();
+ foreach (Match match in Regex.Matches(source, @"\bName\s+""(?[^""]+)"""))
+ {
+ passNames.Add(match.Groups["name"].Value);
+ }
+
+ CollectionAssert.AreEqual(ExpectedPassNames, passNames, "Product shader '" + shaderName + "' must retain exactly the established pass order.");
+ }
+
+ /// Asserts the exact rendering-mode variant declaration remains the complete allowed variant set.
+ /// The generated shader source.
+ /// The product shader name used in diagnostics.
+ private static void AssertRenderingModeKeywordContracts(string source, string shaderName)
+ {
+ var variantDeclarations = new List();
+ foreach (Match declaration in Regex.Matches(source, @"^\s*#pragma\s+(?shader_feature(?:_local)?|multi_compile(?:_local)?)\s+(?[^\r\n]+?)\s*$", RegexOptions.Multiline))
+ {
+ variantDeclarations.Add(Regex.Replace("#pragma " + declaration.Groups["directive"].Value + " " + declaration.Groups["keywords"].Value, @"\s+", " "));
+ }
+
+ CollectionAssert.AreEqual(
+ new[] { ExpectedRenderingModeVariantDeclaration, ExpectedEditorVisualizationVariantDeclaration },
+ variantDeclarations,
+ "Product shader '" + shaderName + "' must retain exactly the established rendering-mode variant declaration without additional variants."
+ );
+ }
+
+ /// Rejects Stencil-specific keyword declarations and named passes without inspecting valid HLSL declarations.
+ /// The generated shader source.
+ /// The product shader name used in diagnostics.
+ private static void AssertNoStencilKeywordsOrPasses(string source, string shaderName)
+ {
+ Assert.That(
+ Regex.IsMatch(source, @"^\s*#pragma\s+[^\r\n]*(?:stencil|_stencil)[^\r\n]*$", RegexOptions.Multiline | RegexOptions.IgnoreCase),
+ Is.False,
+ "Product shader '" + shaderName + "' must not declare a Stencil keyword, shader_feature, or multi_compile variant."
+ );
+ }
+
+ /// Extracts one named Pass from its Name marker through the immediate next named Pass.
+ /// The generated shader source.
+ /// The required Pass name.
+ /// The source section belonging only to the requested Pass.
+ private static string ExtractNamedPass(string source, string passName)
+ {
+ MatchCollection names = Regex.Matches(source, @"\bName\s+""(?[^""]+)""");
+ for (int index = 0; index < names.Count; index++)
+ {
+ Match name = names[index];
+ if (!string.Equals(name.Groups["name"].Value, passName, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ int end = index + 1 < names.Count ? names[index + 1].Index : source.Length;
+ return source.Substring(name.Index, end - name.Index);
+ }
+
+ Assert.Fail("Generated source did not contain Pass '" + passName + "'.");
+ return null;
+ }
+
+ /// Limits render-state assertions to the ShaderLab prefix before the Pass HLSLPROGRAM.
+ /// The source section for one named Pass.
+ /// The product shader name used in diagnostics.
+ /// The Pass name used in diagnostics.
+ /// The ShaderLab render-state prefix.
+ private static string ExtractRenderStatePrefix(string passSource, string shaderName, string passName)
+ {
+ int hlslProgram = passSource.IndexOf("HLSLPROGRAM", StringComparison.OrdinalIgnoreCase);
+ Assert.That(hlslProgram, Is.GreaterThanOrEqualTo(0), "Product shader '" + shaderName + "' Pass '" + passName + "' must contain HLSLPROGRAM.");
+ return passSource.Substring(0, hlslProgram);
+ }
+
+ /// Asserts that ForwardBase uses the complete seven-property Stencil block.
+ /// The ForwardBase ShaderLab render-state prefix.
+ /// The product shader name used in diagnostics.
+ private static void AssertForwardBaseStencilBlock(string prefix, string shaderName)
+ {
+ string body = RequireStencilBody(prefix, shaderName, "ForwardBase");
+ const string refDirective = @"\bRef\s*\[\s*_StencilRef\s*\]";
+ const string readMaskDirective = @"\bReadMask\s*\[\s*_StencilReadMask\s*\]";
+ const string writeMaskDirective = @"\bWriteMask\s*\[\s*_StencilWriteMask\s*\]";
+ const string compDirective = @"\bComp\s*\[\s*_StencilComp\s*\]";
+ const string passDirective = @"\bPass\s*\[\s*_StencilPass\s*\]";
+ const string failDirective = @"\bFail\s*\[\s*_StencilFail\s*\]";
+ const string zFailDirective = @"\bZFail\s*\[\s*_StencilZFail\s*\]";
+
+ AssertStencilDirectiveExactlyOnce(body, refDirective, shaderName, "ForwardBase", "Ref [_StencilRef]");
+ AssertStencilDirectiveExactlyOnce(body, readMaskDirective, shaderName, "ForwardBase", "ReadMask [_StencilReadMask]");
+ AssertStencilDirectiveExactlyOnce(body, writeMaskDirective, shaderName, "ForwardBase", "WriteMask [_StencilWriteMask]");
+ AssertStencilDirectiveExactlyOnce(body, compDirective, shaderName, "ForwardBase", "Comp [_StencilComp]");
+ AssertStencilDirectiveExactlyOnce(body, passDirective, shaderName, "ForwardBase", "Pass [_StencilPass]");
+ AssertStencilDirectiveExactlyOnce(body, failDirective, shaderName, "ForwardBase", "Fail [_StencilFail]");
+ AssertStencilDirectiveExactlyOnce(body, zFailDirective, shaderName, "ForwardBase", "ZFail [_StencilZFail]");
+
+ string unrecognizedState = Regex.Replace(body, refDirective + "|" + readMaskDirective + "|" + writeMaskDirective + "|" + compDirective + "|" + passDirective + "|" + failDirective + "|" + zFailDirective, string.Empty);
+ Assert.That(string.IsNullOrWhiteSpace(unrecognizedState), Is.True, "Product shader '" + shaderName + "' ForwardBase must contain only the fixed shared Stencil state directives.");
+ }
+
+ /// Asserts that ForwardAdd compares the shared Stencil value without writing or repeating operations.
+ /// The ForwardAdd ShaderLab render-state prefix.
+ /// The product shader name used in diagnostics.
+ private static void AssertForwardAddStencilBlock(string prefix, string shaderName)
+ {
+ string body = RequireStencilBody(prefix, shaderName, "ForwardAdd");
+ const string refDirective = @"\bRef\s*\[\s*_StencilRef\s*\]";
+ const string readMaskDirective = @"\bReadMask\s*\[\s*_StencilReadMask\s*\]";
+ const string compDirective = @"\bComp\s*\[\s*_StencilComp\s*\]";
+ const string writeMaskDirective = @"\bWriteMask\s+0(?:\.0+)?\b";
+ const string passDirective = @"\bPass\s+Keep\b";
+ const string failDirective = @"\bFail\s+Keep\b";
+ const string zFailDirective = @"\bZFail\s+Keep\b";
+
+ AssertStencilDirectiveExactlyOnce(body, refDirective, shaderName, "ForwardAdd", "Ref [_StencilRef]");
+ AssertStencilDirectiveExactlyOnce(body, readMaskDirective, shaderName, "ForwardAdd", "ReadMask [_StencilReadMask]");
+ AssertStencilDirectiveExactlyOnce(body, compDirective, shaderName, "ForwardAdd", "Comp [_StencilComp]");
+ AssertStencilDirectiveExactlyOnce(body, writeMaskDirective, shaderName, "ForwardAdd", "WriteMask 0");
+ AssertStencilDirectiveExactlyOnce(body, passDirective, shaderName, "ForwardAdd", "Pass Keep");
+ AssertStencilDirectiveExactlyOnce(body, failDirective, shaderName, "ForwardAdd", "Fail Keep");
+ AssertStencilDirectiveExactlyOnce(body, zFailDirective, shaderName, "ForwardAdd", "ZFail Keep");
+
+ string unrecognizedState = Regex.Replace(body, refDirective + "|" + readMaskDirective + "|" + compDirective + "|" + writeMaskDirective + "|" + passDirective + "|" + failDirective + "|" + zFailDirective, string.Empty);
+ Assert.That(string.IsNullOrWhiteSpace(unrecognizedState), Is.True, "Product shader '" + shaderName + "' ForwardAdd must contain only the fixed compare-only Stencil state directives.");
+ }
+
+ /// Requires a single ShaderLab Stencil body in a screen-rendering Pass prefix.
+ /// The ShaderLab render-state prefix.
+ /// The product shader name used in diagnostics.
+ /// The Pass name used in diagnostics.
+ /// The contents of the Stencil block.
+ private static string RequireStencilBody(string prefix, string shaderName, string passName)
+ {
+ MatchCollection blocks = Regex.Matches(prefix, @"\bStencil\s*\{(?[^{}]*)\}", RegexOptions.Singleline);
+ Assert.That(blocks.Count, Is.EqualTo(1), "Product shader '" + shaderName + "' Pass '" + passName + "' must contain exactly one bounded Stencil block.");
+ return blocks[0].Groups["body"].Value;
+ }
+
+ /// Asserts one fixed Stencil directive and rejects duplicate or alternate state.
+ /// The bounded Stencil block body.
+ /// The exact directive pattern.
+ /// The product shader name used in diagnostics.
+ /// The Pass name used in diagnostics.
+ /// The human-readable directive description.
+ private static void AssertStencilDirectiveExactlyOnce(string body, string pattern, string shaderName, string passName, string description)
+ {
+ Assert.That(Regex.Matches(body, pattern).Count, Is.EqualTo(1), "Product shader '" + shaderName + "' " + passName + " must contain exactly one " + description + " directive.");
+ }
+
+ /// Rejects Stencil blocks and property directives from ShadowCaster and Meta render-state prefixes.
+ /// The ShaderLab render-state prefix.
+ /// The product shader name used in diagnostics.
+ /// The Pass name used in diagnostics.
+ private static void AssertNoStencilRenderState(string prefix, string shaderName, string passName)
+ {
+ Assert.That(Regex.IsMatch(prefix, @"\bStencil\b|_Stencil", RegexOptions.IgnoreCase), Is.False, "Product shader '" + shaderName + "' Pass '" + passName + "' must not apply Stencil before HLSLPROGRAM.");
+ }
+
/// Loads one generated product source subasset without modifying its import state.
/// The product shader name.
/// The generated source text.
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.Stencil.cs b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.Stencil.cs
new file mode 100644
index 0000000..0e275c9
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.Stencil.cs
@@ -0,0 +1,246 @@
+/*
+ * 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 explicit D24S8 runtime observations for product shader Stencil pass behavior.
+
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.Experimental.Rendering;
+using UnityEngine.Rendering;
+
+namespace PureBase.Tests.Daily
+{
+ public sealed partial class PureBaseRenderingModeRenderingTests
+ {
+ /// Requires default Always and Keep Stencil state to render independently of the cleared Stencil value.
+ [Test]
+ public void D24S8StencilDefaultsRenderIndependentlyOfClearStencilAcrossProductShaders()
+ {
+ foreach (string shaderName in ProductShaderNames)
+ {
+ var fixture = new D24S8StencilFixture();
+ try
+ {
+ fixture.Initialize();
+ Shader shader = RequireProductShader(shaderName);
+ Color zeroClear = fixture.RenderSingle(
+ shader,
+ new Color(0.8f, 0.6f, 0.4f, 1.0f),
+ 0,
+ new StencilState(37, 255, 255, CompareFunction.Always, StencilOp.Keep, StencilOp.Keep, StencilOp.Keep),
+ 0
+ );
+ Color nonzeroClear = fixture.RenderSingle(
+ shader,
+ new Color(0.8f, 0.6f, 0.4f, 1.0f),
+ 203,
+ new StencilState(37, 255, 255, CompareFunction.Always, StencilOp.Keep, StencilOp.Keep, StencilOp.Keep),
+ 0
+ );
+
+ AssertFinite(zeroClear, shaderName + " default-Always clear-0 " + fixture.FormatDescription);
+ AssertFinite(nonzeroClear, shaderName + " default-Always clear-203 " + fixture.FormatDescription);
+ Assert.That(RgbMagnitude(zeroClear), Is.GreaterThan(0.05f), shaderName + " default Always+Keep must draw over clear stencil 0. " + fixture.FormatDescription + " Pixel=" + zeroClear);
+ Assert.That(RgbMagnitude(nonzeroClear), Is.GreaterThan(0.05f), shaderName + " default Always+Keep must draw over clear stencil 203. " + fixture.FormatDescription + " Pixel=" + nonzeroClear);
+ Assert.That(RgbMagnitude(zeroClear - nonzeroClear), Is.LessThan(0.02f), shaderName + " default Always+Keep must not depend on the cleared stencil value. " + fixture.FormatDescription + " Clear0=" + zeroClear + " Clear203=" + nonzeroClear);
+ }
+ finally
+ {
+ fixture.Dispose();
+ }
+ }
+ }
+
+ /// Requires ForwardBase Replace to make matching Equal readers pass and mismatched readers reject across product shaders.
+ [Test]
+ public void D24S8StencilForwardBaseReplaceControlsEqualAndMismatchedReadersAcrossProductShaders()
+ {
+ var writerState = new StencilState(60, 255, 255, CompareFunction.Always, StencilOp.Replace, StencilOp.Keep, StencilOp.Keep);
+ var matchingReaderState = new StencilState(60, 255, 0, CompareFunction.Equal, StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
+ var mismatchedReaderState = new StencilState(61, 255, 0, CompareFunction.Equal, StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
+ foreach (string shaderName in ProductShaderNames)
+ {
+ var fixture = new D24S8StencilFixture();
+ try
+ {
+ fixture.Initialize();
+ Shader shader = RequireProductShader(shaderName);
+ Color matchingWriter;
+ Color matching = fixture.RenderWriterThenReader(
+ shader,
+ 0,
+ Color.black,
+ writerState,
+ new Color(0.7f, 0.8f, 0.9f, 1.0f),
+ matchingReaderState,
+ 0,
+ out matchingWriter
+ );
+ Color mismatchedWriter;
+ Color mismatched = fixture.RenderWriterThenReader(
+ shader,
+ 0,
+ Color.black,
+ writerState,
+ new Color(0.7f, 0.8f, 0.9f, 1.0f),
+ mismatchedReaderState,
+ 0,
+ out mismatchedWriter
+ );
+
+ AssertFinite(matching, shaderName + " Replace/Equal matching reader " + fixture.FormatDescription);
+ AssertFinite(mismatched, shaderName + " Replace/Equal mismatched reader " + fixture.FormatDescription);
+ Assert.That(RgbMagnitude(matching), Is.GreaterThan(0.05f), shaderName + " Replace writer followed by Equal reader with ref 60 must render. " + fixture.FormatDescription + " Pixel=" + matching);
+ Assert.That(RgbMagnitude(mismatched - mismatchedWriter), Is.LessThan(RgbMagnitude(matching - matchingWriter) * 0.2f), shaderName + " Replace writer followed by Equal reader with ref 61 must reject without adding reader color beyond the black-writer baseline. " + fixture.FormatDescription + " Matching=" + matching + " MatchingWriter=" + matchingWriter + " Mismatched=" + mismatched + " MismatchedWriter=" + mismatchedWriter);
+ }
+ finally
+ {
+ fixture.Dispose();
+ }
+ }
+ }
+
+ /// Requires partial Stencil WriteMask and ReadMask behavior to preserve and compare only the selected low bits.
+ [Test]
+ public void D24S8StencilHonorsPartialReadAndWriteMasksAcrossProductShaders()
+ {
+ var writerState = new StencilState(0x12, 255, 0x0f, CompareFunction.Always, StencilOp.Replace, StencilOp.Keep, StencilOp.Keep);
+ var matchingReaderState = new StencilState(0xf2, 0x0f, 0, CompareFunction.Equal, StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
+ var mismatchedReaderState = new StencilState(0xf1, 0x0f, 0, CompareFunction.Equal, StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
+ foreach (string shaderName in ProductShaderNames)
+ {
+ var fixture = new D24S8StencilFixture();
+ try
+ {
+ fixture.Initialize();
+ Shader shader = RequireProductShader(shaderName);
+ Color matchingWriter;
+ Color matching = fixture.RenderWriterThenReader(
+ shader,
+ 0xa0,
+ Color.black,
+ writerState,
+ new Color(0.9f, 0.7f, 0.5f, 1.0f),
+ matchingReaderState,
+ 0,
+ out matchingWriter
+ );
+ Color mismatchedWriter;
+ Color mismatched = fixture.RenderWriterThenReader(
+ shader,
+ 0xa0,
+ Color.black,
+ writerState,
+ new Color(0.9f, 0.7f, 0.5f, 1.0f),
+ mismatchedReaderState,
+ 0,
+ out mismatchedWriter
+ );
+
+ AssertFinite(matching, shaderName + " partial-mask matching reader " + fixture.FormatDescription);
+ AssertFinite(mismatched, shaderName + " partial-mask mismatched reader " + fixture.FormatDescription);
+ Assert.That(RgbMagnitude(matching), Is.GreaterThan(0.05f), shaderName + " WriteMask 0x0f must write low bits that ReadMask 0x0f can match. " + fixture.FormatDescription + " Pixel=" + matching);
+ Assert.That(RgbMagnitude(mismatched - mismatchedWriter), Is.LessThan(RgbMagnitude(matching - matchingWriter) * 0.2f), shaderName + " ReadMask 0x0f must reject a mismatched low-bit reference without adding reader color beyond the black-writer baseline. " + fixture.FormatDescription + " Matching=" + matching + " MatchingWriter=" + matchingWriter + " Mismatched=" + mismatched + " MismatchedWriter=" + mismatchedWriter);
+ }
+ finally
+ {
+ fixture.Dispose();
+ }
+ }
+ }
+
+ /// Requires Toon ForwardAdd to retain Equal+Keep contribution and reject add contribution after NotEqual+Replace mutates ForwardBase stencil.
+ [Test]
+ public void D24S8StencilToonForwardAddRecomparesPostBaseStencilWithoutWriting()
+ {
+ Shader toon = RequireProductShader("PureBase/Toon");
+ var equalKeep = new StencilState(0, 255, 255, CompareFunction.Equal, StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
+ var notEqualReplace = new StencilState(1, 255, 255, CompareFunction.NotEqual, StencilOp.Replace, StencilOp.Keep, StencilOp.Keep);
+ var fixture = new ToonForwardAddScope();
+ try
+ {
+ fixture.Initialize();
+ var alwaysKeep = new StencilState(0, 255, 255, CompareFunction.Always, StencilOp.Keep, StencilOp.Keep, StencilOp.Keep);
+ Color alwaysKeepOneLight = fixture.RenderToonComposite(toon, 0, alwaysKeep, 1);
+ AssertFinite(alwaysKeepOneLight, "Toon Always+Keep one-light " + fixture.FormatDescription);
+ Assert.That(RgbMagnitude(alwaysKeepOneLight), Is.GreaterThan(0.05f), "Toon Always+Keep transparent one-light control must render before testing ForwardAdd Stencil recompare. " + fixture.FormatDescription + " Pixel=" + alwaysKeepOneLight);
+ Color equalKeepOneLight = fixture.RenderToonComposite(toon, 0, equalKeep, 1);
+ Color equalKeepTwoLights = fixture.RenderToonComposite(toon, 0, equalKeep, 2);
+ float retainedAddDelta = RgbMagnitude(equalKeepTwoLights - equalKeepOneLight);
+ AssertFinite(equalKeepOneLight, "Toon Equal+Keep one-light " + fixture.FormatDescription);
+ AssertFinite(equalKeepTwoLights, "Toon Equal+Keep two-light " + fixture.FormatDescription);
+ Assert.That(retainedAddDelta, Is.GreaterThan(0.01f), "Toon two-light control must expose a measurable ForwardAdd contribution before testing Stencil recompare. " + fixture.FormatDescription + " OneLight=" + equalKeepOneLight + " TwoLights=" + equalKeepTwoLights + " Delta=" + retainedAddDelta);
+ Color notEqualReplaceOneLight = fixture.RenderToonComposite(toon, 0, notEqualReplace, 1);
+ Color notEqualReplaceTwoLights = fixture.RenderToonComposite(toon, 0, notEqualReplace, 2);
+ float rejectedAddDelta = RgbMagnitude(notEqualReplaceTwoLights - notEqualReplaceOneLight);
+
+ AssertFinite(notEqualReplaceOneLight, "Toon NotEqual+Replace one-light " + fixture.FormatDescription);
+ AssertFinite(notEqualReplaceTwoLights, "Toon NotEqual+Replace two-light " + fixture.FormatDescription);
+ Assert.That(rejectedAddDelta, Is.LessThan(retainedAddDelta * 0.2f), "Toon NotEqual+Replace must make ForwardAdd recompare the post-ForwardBase value and reject its add contribution. " + fixture.FormatDescription + " OneLight=" + notEqualReplaceOneLight + " TwoLights=" + notEqualReplaceTwoLights + " RetainedDelta=" + retainedAddDelta + " RejectedDelta=" + rejectedAddDelta);
+ TestContext.Progress.WriteLine("Toon D24S8 controls: AlwaysKeepOneLight=" + alwaysKeepOneLight + " EqualKeepOneLight=" + equalKeepOneLight + " EqualKeepTwoLights=" + equalKeepTwoLights + " NotEqualReplaceOneLight=" + notEqualReplaceOneLight + " NotEqualReplaceTwoLights=" + notEqualReplaceTwoLights + " RetainedAddDelta=" + retainedAddDelta + " RejectedAddDelta=" + rejectedAddDelta);
+ }
+ finally
+ {
+ fixture.Dispose();
+ }
+ }
+
+ /// Stores all user-controlled Stencil state values for one material draw.
+ private sealed class StencilState
+ {
+ /// Initializes one complete material Stencil state.
+ /// The Stencil reference value.
+ /// The Stencil read mask.
+ /// The Stencil write mask.
+ /// The Stencil comparison function.
+ /// The operation when Stencil and depth tests pass.
+ /// The operation when the Stencil test fails.
+ /// The operation when the depth test fails.
+ public StencilState(byte referenceValue, byte readMask, byte writeMask, CompareFunction comparison, StencilOp passOperation, StencilOp failOperation, StencilOp depthFailOperation)
+ {
+ this.referenceValue = referenceValue;
+ this.readMask = readMask;
+ this.writeMask = writeMask;
+ this.comparison = comparison;
+ this.passOperation = passOperation;
+ this.failOperation = failOperation;
+ this.depthFailOperation = depthFailOperation;
+ }
+
+ /// Stores the reference value used by Stencil comparison and writes.
+ public readonly byte referenceValue;
+
+ /// Stores the mask applied before Stencil comparison.
+ public readonly byte readMask;
+
+ /// Stores the mask applied to a Stencil write operation.
+ public readonly byte writeMask;
+
+ /// Stores the Stencil comparison function.
+ public readonly CompareFunction comparison;
+
+ /// Stores the Stencil operation when tests pass.
+ public readonly StencilOp passOperation;
+
+ /// Stores the Stencil operation when the Stencil test fails.
+ public readonly StencilOp failOperation;
+
+ /// Stores the Stencil operation when the depth test fails.
+ public readonly StencilOp depthFailOperation;
+ }
+
+ }
+}
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.Stencil.cs.meta b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.Stencil.cs.meta
new file mode 100644
index 0000000..e8dfa8c
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.Stencil.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ad4ba72a1f9400a46a04c445e31a9c3e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.ToonForwardAddScope.cs b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.ToonForwardAddScope.cs
new file mode 100644
index 0000000..d86fd8e
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.ToonForwardAddScope.cs
@@ -0,0 +1,486 @@
+/*
+ * 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 the isolated Toon ForwardAdd rendering scope used by Stencil tests.
+
+using System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+using UnityEngine.Rendering;
+using UnityEngine.SceneManagement;
+
+namespace PureBase.Tests.Daily
+{
+ public sealed partial class PureBaseRenderingModeRenderingTests
+ {
+ /// Owns the active-scene-only Toon ForwardAdd observation while restoring all shared editor state.
+ private sealed class ToonForwardAddScope : IDisposable
+ {
+ /// Stores every loaded scene and its initial dirty state.
+ private sealed class SceneState
+ {
+ /// Initializes one captured loaded-scene state.
+ /// The loaded scene.
+ /// Whether the scene was dirty before the scope began.
+ public SceneState(Scene scene, bool isDirty)
+ {
+ this.scene = scene;
+ this.isDirty = isDirty;
+ }
+
+ /// Stores the observed loaded scene.
+ public readonly Scene scene;
+
+ /// Stores the scene dirty state captured before temporary object creation.
+ public readonly bool isDirty;
+ }
+
+ /// Stores temporary directional lights in creation order.
+ private readonly List lightObjects = new List();
+
+ /// Stores temporary product materials in creation order.
+ private readonly List materials = new List();
+
+ /// Stores the loaded-scene states captured before temporary object creation.
+ private readonly List sceneStates = new List();
+
+ /// Stores the active scene before this scope begins.
+ private Scene activeScene;
+
+ /// Stores the loaded scene count before this scope begins.
+ private int sceneCount;
+
+ /// Stores the pixel-light budget before the scope raises it for ForwardAdd.
+ private int pixelLightCount;
+
+ /// Stores the active render target before readback.
+ private RenderTexture activeRenderTexture;
+
+ /// Stores the dynamically selected layer excluded from all existing scene objects.
+ private int fixtureLayer;
+
+ /// Stores the imported D24S8 asset without changing its serialization or hide flags.
+ private RenderTexture renderTexture;
+
+ /// Tracks whether this scope created the D24S8 asset GPU resource.
+ private bool createdRenderTextureResource;
+
+ /// Stores the temporary command buffer used for explicit D24S8 clears.
+ private CommandBuffer commandBuffer;
+
+ /// Stores the temporary active-scene camera.
+ private GameObject cameraObject;
+
+ /// Stores the temporary active-scene quad.
+ private GameObject quadObject;
+
+ /// Stores the temporary CPU readback texture.
+ private Texture2D texture;
+
+ /// Tracks whether cleanup already restored the captured state.
+ private bool disposed;
+
+ /// Gets device, attachment, and camera diagnostics for Toon ForwardAdd assertions.
+ public string FormatDescription
+ {
+ get
+ {
+ Camera camera = cameraObject == null ? null : cameraObject.GetComponent();
+ return D24S8StencilFixture.DescribeTarget(renderTexture) +
+ " RequestedRenderingPath=" + (camera == null ? "" : camera.renderingPath.ToString()) +
+ " ActualRenderingPath=" + (camera == null ? "" : camera.actualRenderingPath.ToString()) +
+ " PixelLightCount=" + QualitySettings.pixelLightCount +
+ " FixtureLayer=" + fixtureLayer;
+ }
+ }
+
+ /// Captures shared state and allocates only HideAndDontSave active-scene render objects.
+ public void Initialize()
+ {
+ CaptureSharedState();
+ fixtureLayer = FindUnusedLayer();
+ renderTexture = D24S8StencilFixture.LoadAndCreateD24S8RenderTextureAssetForToonScope(out createdRenderTextureResource);
+ commandBuffer = new CommandBuffer { name = "PureBase Toon ForwardAdd D24S8 Clear" };
+ cameraObject = CreateHiddenObject("PureBaseToonForwardAddCamera");
+ texture = new Texture2D(RenderSize, RenderSize, TextureFormat.RGBA32, false, true) { hideFlags = HideFlags.HideAndDontSave };
+ quadObject = GameObject.CreatePrimitive(PrimitiveType.Quad);
+ quadObject.hideFlags = HideFlags.HideAndDontSave;
+ quadObject.layer = fixtureLayer;
+ quadObject.GetComponent().enabled = false;
+
+ Camera camera = cameraObject.AddComponent();
+ camera.enabled = false;
+ camera.orthographic = true;
+ camera.orthographicSize = 0.5f;
+ camera.nearClipPlane = 0.1f;
+ camera.farClipPlane = 10.0f;
+ camera.renderingPath = RenderingPath.Forward;
+ camera.cullingMask = 1 << fixtureLayer;
+ camera.clearFlags = CameraClearFlags.Nothing;
+ camera.transform.position = new Vector3(0.0f, 0.0f, -2.0f);
+ camera.targetTexture = renderTexture;
+ Assert.That(camera.renderingPath, Is.EqualTo(RenderingPath.Forward), "The Toon D24S8 scope must request the BIRP Forward camera path.");
+ }
+
+ /// Renders one transparent Toon material with the requested isolated directional-light count.
+ /// The Toon product shader.
+ /// The initial Stencil byte.
+ /// The explicit Stencil configuration.
+ /// The number of ForcePixel directional lights.
+ /// The rendered center pixel.
+ public Color RenderToonComposite(Shader shader, byte clearStencil, StencilState stencilState, int lightCount)
+ {
+ SetLightCount(lightCount);
+ Material material = CreateToonMaterial(shader, stencilState);
+ ClearTarget(clearStencil);
+ Renderer renderer = quadObject.GetComponent();
+ renderer.sharedMaterial = material;
+ renderer.enabled = true;
+ Camera camera = cameraObject.GetComponent();
+ try
+ {
+ camera.Render();
+ Assert.That(camera.actualRenderingPath, Is.EqualTo(RenderingPath.Forward), "The Toon D24S8 scope must actually use the BIRP Forward camera path. " + FormatDescription);
+ return ReadCenterPixel(renderTexture, texture);
+ }
+ finally
+ {
+ renderer.enabled = false;
+ }
+ }
+
+ /// Releases all temporary objects and restores the captured global and scene state.
+ public void Dispose()
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+ try
+ {
+ DestroyTemporaryObjects();
+ AssertTemporaryObjectsDestroyed();
+ }
+ finally
+ {
+ RestoreEditorState();
+ }
+ }
+
+ /// Destroys temporary resources in the same order used by the scope cleanup.
+ private void DestroyTemporaryObjects()
+ {
+ DestroyMaterials();
+ DestroyLights();
+ DestroyQuad();
+ DestroyTexture();
+ DetachCameraTarget();
+ ReleaseRenderTexture();
+ DestroyCamera();
+ ReleaseCommandBuffer();
+ }
+
+ /// Destroys temporary materials in reverse creation order.
+ private void DestroyMaterials()
+ {
+ for (int index = materials.Count - 1; index >= 0; index--)
+ {
+ UnityEngine.Object.DestroyImmediate(materials[index]);
+ }
+
+ materials.Clear();
+ }
+
+ /// Destroys the temporary Toon quad.
+ private void DestroyQuad()
+ {
+ if (quadObject != null)
+ {
+ UnityEngine.Object.DestroyImmediate(quadObject);
+ }
+ }
+
+ /// Destroys the temporary CPU readback texture.
+ private void DestroyTexture()
+ {
+ if (texture != null)
+ {
+ UnityEngine.Object.DestroyImmediate(texture);
+ }
+ }
+
+ /// Detaches the temporary camera from the shared render target before release.
+ private void DetachCameraTarget()
+ {
+ Camera camera = cameraObject == null ? null : cameraObject.GetComponent();
+ if (camera != null)
+ {
+ camera.targetTexture = null;
+ }
+ }
+
+ /// Releases the D24S8 GPU resource only when this scope created it.
+ private void ReleaseRenderTexture()
+ {
+ if (createdRenderTextureResource && renderTexture != null)
+ {
+ renderTexture.Release();
+ }
+ }
+
+ /// Destroys the temporary active-scene camera.
+ private void DestroyCamera()
+ {
+ if (cameraObject != null)
+ {
+ UnityEngine.Object.DestroyImmediate(cameraObject);
+ }
+ }
+
+ /// Releases the temporary command buffer.
+ private void ReleaseCommandBuffer()
+ {
+ if (commandBuffer != null)
+ {
+ commandBuffer.Release();
+ }
+ }
+
+ /// Verifies that every temporary Unity object was destroyed.
+ private void AssertTemporaryObjectsDestroyed()
+ {
+ Assert.That(quadObject == null, Is.True, "The Toon D24S8 scope must destroy its temporary quad.");
+ Assert.That(cameraObject == null, Is.True, "The Toon D24S8 scope must destroy its temporary camera.");
+ foreach (GameObject lightObject in lightObjects)
+ {
+ Assert.That(lightObject == null, Is.True, "The Toon D24S8 scope must destroy every temporary directional light.");
+ }
+ }
+
+ /// Restores the captured render target, quality, active scene, and scene dirty state.
+ private void RestoreEditorState()
+ {
+ RenderTexture.active = activeRenderTexture;
+ QualitySettings.pixelLightCount = pixelLightCount;
+ RestoreSceneState();
+ }
+
+ /// Captures every global state this scope may observe or change.
+ private void CaptureSharedState()
+ {
+ activeScene = SceneManager.GetActiveScene();
+ sceneCount = SceneManager.sceneCount;
+ pixelLightCount = QualitySettings.pixelLightCount;
+ activeRenderTexture = RenderTexture.active;
+ for (int index = 0; index < sceneCount; index++)
+ {
+ Scene scene = SceneManager.GetSceneAt(index);
+ if (scene.IsValid() && scene.isLoaded)
+ {
+ sceneStates.Add(new SceneState(scene, scene.isDirty));
+ }
+ }
+ }
+
+ /// Restores the active scene and verifies every loaded-scene dirty state remained unchanged.
+ private void RestoreSceneState()
+ {
+ Assert.That(SceneManager.sceneCount, Is.EqualTo(sceneCount), "The Toon D24S8 scope must not add or remove loaded scenes.");
+ if (activeScene.IsValid() && activeScene.isLoaded)
+ {
+ if (SceneManager.GetActiveScene() != activeScene)
+ {
+ Assert.That(SceneManager.SetActiveScene(activeScene), Is.True, "The Toon D24S8 scope must restore the original active scene.");
+ }
+ Assert.That(SceneManager.GetActiveScene(), Is.EqualTo(activeScene), "The Toon D24S8 scope must preserve the original active scene.");
+ }
+
+ foreach (SceneState state in sceneStates)
+ {
+ Assert.That(state.scene.isLoaded, Is.True, "The Toon D24S8 scope must keep every initially loaded scene loaded.");
+ if (state.isDirty)
+ {
+ Assert.That(EditorSceneManager.MarkSceneDirty(state.scene), Is.True, "The Toon D24S8 scope must restore initially dirty scene state.");
+ }
+ Assert.That(state.scene.isDirty, Is.EqualTo(state.isDirty), "The Toon D24S8 scope must preserve clean and dirty scene states.");
+ }
+ }
+
+ /// Selects a layer not used by any current scene object so existing scene rendering stays excluded.
+ /// An unused user layer.
+ private static int FindUnusedLayer()
+ {
+ for (int layer = 31; layer >= 8; layer--)
+ {
+ if (!IsLayerInUse(layer))
+ {
+ return layer;
+ }
+ }
+
+ Assert.Fail("The Toon D24S8 scope requires one unused user layer to exclude existing scene rendering.");
+ return 0;
+ }
+
+ /// Returns whether any loaded-scene object uses the candidate layer.
+ /// The candidate layer.
+ /// True when an existing scene object uses the layer.
+ private static bool IsLayerInUse(int layer)
+ {
+ for (int sceneIndex = 0; sceneIndex < SceneManager.sceneCount; sceneIndex++)
+ {
+ Scene scene = SceneManager.GetSceneAt(sceneIndex);
+ if (!scene.IsValid() || !scene.isLoaded)
+ {
+ continue;
+ }
+ foreach (GameObject rootObject in scene.GetRootGameObjects())
+ {
+ if (UsesLayer(rootObject.transform, layer))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /// Returns whether a transform or descendant uses the candidate layer.
+ /// The transform to inspect.
+ /// The candidate layer.
+ /// True when the transform hierarchy uses the layer.
+ private static bool UsesLayer(Transform transform, int layer)
+ {
+ if (transform.gameObject.layer == layer)
+ {
+ return true;
+ }
+ for (int index = 0; index < transform.childCount; index++)
+ {
+ if (UsesLayer(transform.GetChild(index), layer))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /// Creates a hidden temporary active-scene GameObject on the dedicated layer.
+ /// The diagnostic object name.
+ /// The caller-owned temporary GameObject.
+ private GameObject CreateHiddenObject(string name)
+ {
+ var gameObject = new GameObject(name);
+ gameObject.hideFlags = HideFlags.HideAndDontSave;
+ gameObject.layer = fixtureLayer;
+ return gameObject;
+ }
+
+ /// Creates the requested ForcePixel directional lights with deterministic front-facing rotations.
+ /// The required light count.
+ private void SetLightCount(int lightCount)
+ {
+ Assert.That(lightCount, Is.GreaterThanOrEqualTo(1), "The Toon D24S8 scope requires at least one directional light.");
+ DestroyLights();
+ QualitySettings.pixelLightCount = Math.Max(2, pixelLightCount);
+ int cullingMask = 1 << fixtureLayer;
+ for (int index = 0; index < lightCount; index++)
+ {
+ GameObject lightObject = CreateHiddenObject("PureBaseToonForwardAddLight" + index);
+ lightObjects.Add(lightObject);
+ Light light = lightObject.AddComponent();
+ light.type = LightType.Directional;
+ light.renderMode = LightRenderMode.ForcePixel;
+ light.color = Color.white;
+ light.intensity = 1.0f;
+ light.cullingMask = cullingMask;
+ lightObject.transform.rotation = Quaternion.Euler(30.0f, index == 0 ? -30.0f : 30.0f, 0.0f);
+ }
+
+ Assert.That(QualitySettings.pixelLightCount, Is.GreaterThanOrEqualTo(2), "The Toon D24S8 scope must allow at least two pixel lights.");
+ }
+
+ /// Destroys all temporary directional lights in reverse creation order.
+ private void DestroyLights()
+ {
+ for (int index = lightObjects.Count - 1; index >= 0; index--)
+ {
+ UnityEngine.Object.DestroyImmediate(lightObjects[index]);
+ }
+
+ lightObjects.Clear();
+ }
+
+ /// Creates one hidden Toon material with deterministic normal input and explicit Stencil state.
+ /// The Toon product shader.
+ /// The required Stencil configuration.
+ /// The caller-owned configured material.
+ private Material CreateToonMaterial(Shader shader, StencilState stencilState)
+ {
+ Assert.That(shader, Is.Not.Null, "The Toon shader is required for the D24S8 ForwardAdd scope.");
+ var material = new Material(shader) { hideFlags = HideFlags.HideAndDontSave };
+ materials.Add(material);
+ Assert.That(material.HasProperty("_BaseColor"), Is.True, "Toon must expose _BaseColor for the D24S8 ForwardAdd scope.");
+ Assert.That(material.HasProperty("_Cutoff"), Is.True, "Toon must expose _Cutoff for the D24S8 ForwardAdd scope.");
+ Assert.That(material.HasProperty("_NormalMap"), Is.True, "Toon must expose _NormalMap for the D24S8 ForwardAdd scope.");
+ material.SetTexture("_BaseTexture", Texture2D.whiteTexture);
+ material.SetColor("_BaseColor", new Color(0.8f, 0.6f, 0.4f, 0.5f));
+ material.SetFloat("_Cutoff", 0.5f);
+ material.SetTexture("_NormalMap", Texture2D.normalTexture);
+ material.SetFloat("_NormalScale", 1.0f);
+ ConfigureMode(material, 2);
+ ConfigureStencil(material, stencilState);
+ return material;
+ }
+
+ /// Applies the complete public Stencil ABI to one Toon material.
+ /// The material receiving Stencil values.
+ /// The requested Stencil configuration.
+ private void ConfigureStencil(Material material, StencilState stencilState)
+ {
+ string[] properties = { "_StencilRef", "_StencilReadMask", "_StencilWriteMask", "_StencilComp", "_StencilPass", "_StencilFail", "_StencilZFail" };
+ foreach (string property in properties)
+ {
+ Assert.That(material.HasProperty(property), Is.True, "Toon is missing Stencil ABI property '" + property + "' for the D24S8 ForwardAdd scope. " + FormatDescription);
+ }
+ material.SetFloat("_StencilRef", stencilState.referenceValue);
+ material.SetFloat("_StencilReadMask", stencilState.readMask);
+ material.SetFloat("_StencilWriteMask", stencilState.writeMask);
+ material.SetFloat("_StencilComp", (float)stencilState.comparison);
+ material.SetFloat("_StencilPass", (float)stencilState.passOperation);
+ material.SetFloat("_StencilFail", (float)stencilState.failOperation);
+ material.SetFloat("_StencilZFail", (float)stencilState.depthFailOperation);
+ }
+
+ /// Clears color, depth, and the exact Stencil byte through the temporary command buffer.
+ /// The Stencil value to install before rendering.
+ private void ClearTarget(byte clearStencil)
+ {
+ commandBuffer.Clear();
+ commandBuffer.SetRenderTarget(renderTexture);
+ commandBuffer.ClearRenderTarget(RTClearFlags.All, new Color(0.0f, 0.0f, 0.0f, 0.6f), 1.0f, clearStencil);
+ Graphics.ExecuteCommandBuffer(commandBuffer);
+ }
+ }
+ }
+}
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.ToonForwardAddScope.cs.meta b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.ToonForwardAddScope.cs.meta
new file mode 100644
index 0000000..fc7856a
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.ToonForwardAddScope.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 09d600c6a60469b48a42de05f2dba215
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Release/ConsumerProject/Assets/Editor/PureBaseConsumerReleaseTests.cs b/Tests/Release/ConsumerProject/Assets/Editor/PureBaseConsumerReleaseTests.cs
index 9aa2b0d..1e39431 100644
--- a/Tests/Release/ConsumerProject/Assets/Editor/PureBaseConsumerReleaseTests.cs
+++ b/Tests/Release/ConsumerProject/Assets/Editor/PureBaseConsumerReleaseTests.cs
@@ -41,6 +41,18 @@ public sealed class PureBaseConsumerModuleFreeImportTests
"PureBase/Hybrid",
};
+ /// Defines the common visible Stencil ABI metadata and default values.
+ private static readonly StencilPropertyContract[] StencilProperties =
+ {
+ new StencilPropertyContract("_StencilRef", 0.0f, "SCRangeInt(0,255)"),
+ new StencilPropertyContract("_StencilReadMask", 255.0f, "SCRangeInt(0,255)"),
+ new StencilPropertyContract("_StencilWriteMask", 255.0f, "SCRangeInt(0,255)"),
+ new StencilPropertyContract("_StencilComp", 8.0f, "SCEnum(UnityEngine.Rendering.CompareFunction)"),
+ new StencilPropertyContract("_StencilPass", 0.0f, "SCEnum(UnityEngine.Rendering.StencilOp)"),
+ new StencilPropertyContract("_StencilFail", 0.0f, "SCEnum(UnityEngine.Rendering.StencilOp)"),
+ new StencilPropertyContract("_StencilZFail", 0.0f, "SCEnum(UnityEngine.Rendering.StencilOp)"),
+ };
+
/// Imports all runner-configured module-free products and checks their public and generated contracts.
[Test]
public void ModuleFreeProductsCompileWithConfiguredPassPropertyAndSourceContracts()
@@ -74,11 +86,12 @@ public void ModuleFreeProductsCompileWithConfiguredPassPropertyAndSourceContract
ConsumerValidationSupport.GetPassNames(shader),
$"Module-free consumer run '{contract.runLabel}' changed pass layout for '{product.shaderName}'."
);
- CollectionAssert.AreEquivalent(
+ CollectionAssert.AreEqual(
product.expectedVisiblePropertyNames,
ConsumerValidationSupport.GetVisiblePropertyNames(shader),
- $"Module-free consumer run '{contract.runLabel}' changed visible properties for '{product.shaderName}'."
+ $"Module-free consumer run '{contract.runLabel}' changed visible property order for '{product.shaderName}'."
);
+ AssertStencilPropertyMetadata(contract, product, shader);
string source = ConsumerValidationSupport.LoadGeneratedSource(
product,
contract.runLabel
@@ -131,6 +144,44 @@ internal static void AssertRequiredProductSet(ConsumerValidationContract contrac
);
}
+ /// Checks the common visible Stencil ABI against imported shader metadata.
+ /// The runner-provided module-free contract.
+ /// The imported product contract.
+ /// The cold-imported product shader.
+ private static void AssertStencilPropertyMetadata(
+ ConsumerValidationContract contract,
+ ConsumerProductContract product,
+ Shader shader
+ )
+ {
+ int previousPropertyIndex = -1;
+ foreach (StencilPropertyContract property in StencilProperties)
+ {
+ int propertyIndex = shader.FindPropertyIndex(property.name);
+ Assert.That(
+ propertyIndex,
+ Is.GreaterThan(previousPropertyIndex),
+ $"Module-free consumer run '{contract.runLabel}' product '{product.shaderName}' must expose Stencil property '{property.name}' in the runner-provided visible property order."
+ );
+ Assert.That(
+ shader.GetPropertyType(propertyIndex),
+ Is.EqualTo(ShaderPropertyType.Float),
+ $"Module-free consumer run '{contract.runLabel}' product '{product.shaderName}' Stencil property '{property.name}' must be a Float."
+ );
+ CollectionAssert.AreEqual(
+ new[] { property.attribute },
+ shader.GetPropertyAttributes(propertyIndex),
+ $"Module-free consumer run '{contract.runLabel}' product '{product.shaderName}' Stencil property '{property.name}' must expose exactly its required drawer attribute."
+ );
+ Assert.That(
+ shader.GetPropertyDefaultFloatValue(propertyIndex),
+ Is.EqualTo(property.defaultValue),
+ $"Module-free consumer run '{contract.runLabel}' product '{product.shaderName}' Stencil property '{property.name}' default value."
+ );
+ previousPropertyIndex = propertyIndex;
+ }
+ }
+
/// Checks non-pass-specific required and forbidden source fragments.
/// The runner-provided contract.
/// The inspected product.
@@ -357,6 +408,30 @@ internal static string[] EmptyWhenNull(string[] values)
{
return values ?? Array.Empty();
}
+
+ /// Describes one imported common Stencil property contract.
+ private sealed class StencilPropertyContract
+ {
+ /// Initializes one common Stencil property contract.
+ /// The visible shader property name.
+ /// The imported Float default value.
+ /// The exact imported drawer attribute.
+ public StencilPropertyContract(string name, float defaultValue, string attribute)
+ {
+ this.name = name;
+ this.defaultValue = defaultValue;
+ this.attribute = attribute;
+ }
+
+ /// Gets the visible shader property name.
+ public string name { get; }
+
+ /// Gets the imported Float default value.
+ public float defaultValue { get; }
+
+ /// Gets the exact imported drawer attribute.
+ public string attribute { get; }
+ }
}
/// Records standard-morph generated-source pass counts without requiring one expected count shape.
diff --git a/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1 b/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1
index 79a9e8a..f80541e 100644
--- a/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1
+++ b/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1
@@ -16,28 +16,88 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
-$runnerPath = Join-Path $PSScriptRoot 'Run-PureBaseReleaseValidation.ps1'
-$runnerSource = Get-Content -LiteralPath $runnerPath -Raw
-$entryPointIndex = $runnerSource.IndexOf("`n`$packageRoot = Get-PackageGitRoot")
-$libraryStartIndex = $runnerSource.IndexOf('Set-StrictMode -Version Latest')
-if ($entryPointIndex -lt 0 -or $libraryStartIndex -lt 0) {
- throw 'The runner entry point could not be isolated for the runner-only harness.'
-}
-$libraryPath = Join-Path ([System.IO.Path]::GetTempPath()) ('PureBaseReleaseRunner-' + [guid]::NewGuid().ToString('N') + '.ps1')
-[System.IO.File]::WriteAllText($libraryPath, $runnerSource.Substring($libraryStartIndex, $entryPointIndex - $libraryStartIndex), (New-Object System.Text.UTF8Encoding($false)))
-. $libraryPath
+Describe 'Release validation runner contracts' {
+ BeforeAll {
+ $script:runnerPath = Join-Path $PSScriptRoot 'Run-PureBaseReleaseValidation.ps1'
+ $runnerSource = Get-Content -LiteralPath $script:runnerPath -Raw
+ $entryPointIndex = $runnerSource.IndexOf("`n`$packageRoot = Get-PackageGitRoot")
+ $libraryStartIndex = $runnerSource.IndexOf('Set-StrictMode -Version Latest')
+ if ($entryPointIndex -lt 0 -or $libraryStartIndex -lt 0) {
+ throw 'The runner entry point could not be isolated for the runner-only harness.'
+ }
+ $script:libraryPath = Join-Path ([System.IO.Path]::GetTempPath()) ('PureBaseReleaseRunner-' + [guid]::NewGuid().ToString('N') + '.ps1')
+ [System.IO.File]::WriteAllText($script:libraryPath, $runnerSource.Substring($libraryStartIndex, $entryPointIndex - $libraryStartIndex), (New-Object System.Text.UTF8Encoding($false)))
+ . $script:libraryPath
+
+ function Assert-Harness {
+ param(
+ [Parameter(Mandatory = $true)][bool]$Condition,
+ [Parameter(Mandatory = $true)][string]$Message
+ )
-function Assert-Harness {
- param(
- [Parameter(Mandatory = $true)][bool]$Condition,
- [Parameter(Mandatory = $true)][string]$Message
- )
+ if (-not $Condition) {
+ throw $Message
+ }
+ }
+ }
- if (-not $Condition) {
- throw $Message
+ AfterAll {
+ if ((Test-Path -LiteralPath 'variable:script:libraryPath') -and (Test-Path -LiteralPath $script:libraryPath)) {
+ Remove-Item -LiteralPath $script:libraryPath -Force -ErrorAction SilentlyContinue
+ }
}
-}
+ It 'emits the shared Stencil ABI and pass policies for every product' {
+ $stencilProperties = @('_StencilRef', '_StencilReadMask', '_StencilWriteMask', '_StencilComp', '_StencilPass', '_StencilFail', '_StencilZFail')
+ $expectedVisibleProperties = [ordered]@{
+ 'PureBase/Unlit' = @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_RenderingMode', '_Cutoff', '_Cull') + $stencilProperties
+ 'PureBase/Toon' = @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_RenderingMode', '_Cutoff', '_Cull') + $stencilProperties + @('_NormalMap', '_NormalScale')
+ 'PureBase/PBR' = @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_RenderingMode', '_Cutoff', '_Cull') + $stencilProperties + @('_NormalMap', '_NormalScale', '_Metallic', '_Roughness')
+ 'PureBase/Hybrid' = @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_RenderingMode', '_Cutoff', '_Cull') + $stencilProperties + @('_NormalMap', '_NormalScale', '_Metallic', '_Roughness')
+ }
+ $expectedPassContracts = [ordered]@{
+ 'ForwardBase' = [ordered]@{
+ requiredFragments = @('ZWrite [_ZWrite]', 'Blend [_SrcBlend] [_DstBlend]', 'Ref [_StencilRef]', 'ReadMask [_StencilReadMask]', 'WriteMask [_StencilWriteMask]', 'Comp [_StencilComp]', 'Pass [_StencilPass]', 'Fail [_StencilFail]', 'ZFail [_StencilZFail]')
+ forbiddenFragments = @()
+ }
+ 'ForwardAdd' = [ordered]@{
+ requiredFragments = @('ZWrite Off', 'Blend [_AddSrcBlend] [_AddDstBlend]', 'ColorMask RGB', 'Ref [_StencilRef]', 'ReadMask [_StencilReadMask]', 'Comp [_StencilComp]', 'WriteMask 0', 'Pass Keep', 'Fail Keep', 'ZFail Keep')
+ forbiddenFragments = @('WriteMask [_StencilWriteMask]', 'Pass [_StencilPass]', 'Fail [_StencilFail]', 'ZFail [_StencilZFail]')
+ }
+ 'ShadowCaster' = [ordered]@{
+ requiredFragments = @()
+ forbiddenFragments = @('Ref [_StencilRef]', 'ReadMask [_StencilReadMask]', 'WriteMask [_StencilWriteMask]', 'Comp [_StencilComp]', 'Pass [_StencilPass]', 'Fail [_StencilFail]', 'ZFail [_StencilZFail]')
+ }
+ 'Meta' = [ordered]@{
+ requiredFragments = @()
+ forbiddenFragments = @('Ref [_StencilRef]', 'ReadMask [_StencilReadMask]', 'WriteMask [_StencilWriteMask]', 'Comp [_StencilComp]', 'Pass [_StencilPass]', 'Fail [_StencilFail]', 'ZFail [_StencilZFail]')
+ }
+ }
+ $expectedPassNames = @($expectedPassContracts.Keys)
+
+ foreach ($shaderName in $expectedVisibleProperties.Keys) {
+ $product = New-ProductContract -ShaderName $shaderName
+ $product.shaderName | Should -BeExactly $shaderName
+ (@($product.expectedVisiblePropertyNames) -join "`n") | Should -BeExactly ($expectedVisibleProperties[$shaderName] -join "`n")
+ (@($product.expectedPassNames) -join "`n") | Should -BeExactly ($expectedPassNames -join "`n")
+ @($product.passContracts).Count | Should -Be $expectedPassNames.Count
+
+ for ($passIndex = 0; $passIndex -lt $expectedPassNames.Count; $passIndex++) {
+ $passName = $expectedPassNames[$passIndex]
+ $actualPassContract = $product.passContracts[$passIndex]
+ $expectedPassContract = $expectedPassContracts[$passName]
+ $expectedNextPassName = if ($passIndex + 1 -lt $expectedPassNames.Count) { $expectedPassNames[$passIndex + 1] } else { '' }
+
+ $actualPassContract.passName | Should -BeExactly $passName
+ $actualPassContract.nextPassName | Should -BeExactly $expectedNextPassName
+ (@($actualPassContract.requiredFragments) -join "`n") | Should -BeExactly ($expectedPassContract.requiredFragments -join "`n")
+ (@($actualPassContract.forbiddenFragments) -join "`n") | Should -BeExactly ($expectedPassContract.forbiddenFragments -join "`n")
+ $actualPassContract.selectedSentinelCount | Should -Be 0
+ }
+ }
+ }
+
+ It 'executes the Unity-free immutable manifest harness' {
$preStagedPureBaseMetaPaths = @(
'_LocalPackages/jp.penguin.purebase/CHANGELOG.meta',
'_LocalPackages/jp.penguin.purebase/Editor.meta',
@@ -1185,10 +1245,11 @@ try {
Write-Host 'Runner-only immutable manifest harness passed.'
}
finally {
- Remove-Item -LiteralPath $libraryPath -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $fakeUnityPath -Force -ErrorAction SilentlyContinue
}
$runnerSource = Get-Content -LiteralPath $runnerPath -Raw
Assert-Harness -Condition ($runnerSource -match '\$packageJson\.version') -Message 'Release validation must derive the archive name from package.json.version.'
Assert-Harness -Condition ($runnerSource -notmatch 'jp\.penguin\.purebase-0\.1\.0\.zip') -Message 'Release validation must not use a fixed 0.1.0 archive name.'
+ }
+}
diff --git a/Tests/Release/Run-PureBaseReleaseValidation.ps1 b/Tests/Release/Run-PureBaseReleaseValidation.ps1
index c0ee039..f9928d1 100644
--- a/Tests/Release/Run-PureBaseReleaseValidation.ps1
+++ b/Tests/Release/Run-PureBaseReleaseValidation.ps1
@@ -1638,8 +1638,26 @@ function New-ProductContract {
$nextPassName = if ($passIndex + 1 -lt $ProductPasses.Count) { $ProductPasses[$passIndex + 1] } else { '' }
$selectedSentinelCount = if ($null -eq $PassSentinelCounts) { 0 } else { [int]$PassSentinelCounts[$passName] }
$requiredFragments = switch ($passName) {
- 'ForwardBase' { @('ZWrite [_ZWrite]', 'Blend [_SrcBlend] [_DstBlend]') }
- 'ForwardAdd' { @('ZWrite Off', 'Blend [_AddSrcBlend] [_AddDstBlend]', 'ColorMask RGB') }
+ 'ForwardBase' {
+ @(
+ 'ZWrite [_ZWrite]', 'Blend [_SrcBlend] [_DstBlend]',
+ 'Ref [_StencilRef]', 'ReadMask [_StencilReadMask]', 'WriteMask [_StencilWriteMask]', 'Comp [_StencilComp]',
+ 'Pass [_StencilPass]', 'Fail [_StencilFail]', 'ZFail [_StencilZFail]'
+ )
+ }
+ 'ForwardAdd' {
+ @(
+ 'ZWrite Off', 'Blend [_AddSrcBlend] [_AddDstBlend]', 'ColorMask RGB',
+ 'Ref [_StencilRef]', 'ReadMask [_StencilReadMask]', 'Comp [_StencilComp]',
+ 'WriteMask 0', 'Pass Keep', 'Fail Keep', 'ZFail Keep'
+ )
+ }
+ default { @() }
+ }
+ $forbiddenFragments = switch ($passName) {
+ 'ForwardAdd' { @('WriteMask [_StencilWriteMask]', 'Pass [_StencilPass]', 'Fail [_StencilFail]', 'ZFail [_StencilZFail]') }
+ 'ShadowCaster' { @('Ref [_StencilRef]', 'ReadMask [_StencilReadMask]', 'WriteMask [_StencilWriteMask]', 'Comp [_StencilComp]', 'Pass [_StencilPass]', 'Fail [_StencilFail]', 'ZFail [_StencilZFail]') }
+ 'Meta' { @('Ref [_StencilRef]', 'ReadMask [_StencilReadMask]', 'WriteMask [_StencilWriteMask]', 'Comp [_StencilComp]', 'Pass [_StencilPass]', 'Fail [_StencilFail]', 'ZFail [_StencilZFail]') }
default { @() }
}
if ($selectedSentinelCount -lt 0) {
@@ -1655,15 +1673,15 @@ function New-ProductContract {
passName = $passName
nextPassName = $nextPassName
requiredFragments = $requiredFragments
- forbiddenFragments = @()
+ forbiddenFragments = $forbiddenFragments
selectedSentinelCount = $selectedSentinelCount
}
}
$expectedVisiblePropertyNames = switch ($ShaderName) {
- 'PureBase/Unlit' { @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_RenderingMode', '_Cutoff', '_Cull') }
- 'PureBase/Toon' { @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_RenderingMode', '_Cutoff', '_Cull', '_NormalMap', '_NormalScale') }
- 'PureBase/PBR' { @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_RenderingMode', '_Cutoff', '_Cull', '_NormalMap', '_NormalScale', '_Metallic', '_Roughness') }
- 'PureBase/Hybrid' { @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_RenderingMode', '_Cutoff', '_Cull', '_NormalMap', '_NormalScale', '_Metallic', '_Roughness') }
+ 'PureBase/Unlit' { @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_RenderingMode', '_Cutoff', '_Cull', '_StencilRef', '_StencilReadMask', '_StencilWriteMask', '_StencilComp', '_StencilPass', '_StencilFail', '_StencilZFail') }
+ 'PureBase/Toon' { @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_RenderingMode', '_Cutoff', '_Cull', '_StencilRef', '_StencilReadMask', '_StencilWriteMask', '_StencilComp', '_StencilPass', '_StencilFail', '_StencilZFail', '_NormalMap', '_NormalScale') }
+ 'PureBase/PBR' { @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_RenderingMode', '_Cutoff', '_Cull', '_StencilRef', '_StencilReadMask', '_StencilWriteMask', '_StencilComp', '_StencilPass', '_StencilFail', '_StencilZFail', '_NormalMap', '_NormalScale', '_Metallic', '_Roughness') }
+ 'PureBase/Hybrid' { @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_RenderingMode', '_Cutoff', '_Cull', '_StencilRef', '_StencilReadMask', '_StencilWriteMask', '_StencilComp', '_StencilPass', '_StencilFail', '_StencilZFail', '_NormalMap', '_NormalScale', '_Metallic', '_Roughness') }
default { throw "Unsupported PureBase product '$ShaderName'." }
}
return [ordered]@{