diff --git a/.gitattributes b/.gitattributes
index c5bea8ef..e3ac49d5 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -18,3 +18,4 @@
*.exr binary
*.png binary
Tests/Fixtures/Scenes/PureBaseValidation/LightingData.asset binary
+Tests/Fixtures/Scenes/PureBaseValidation/OwnerLightingData.asset binary
diff --git a/.github/scripts/New-PureBaseCiProject.ps1 b/.github/scripts/New-PureBaseCiProject.ps1
index 37983815..c08d659c 100644
--- a/.github/scripts/New-PureBaseCiProject.ps1
+++ b/.github/scripts/New-PureBaseCiProject.ps1
@@ -41,6 +41,28 @@ if ([string]$shaderCoreJson.name -ne 'jp.lilxyzw.shadercore' -or [string]$shader
throw "The CI workspace requires jp.lilxyzw.shadercore exactly 0.1.9."
}
+$ownerLightingDataRelativePath = 'Tests/Fixtures/Scenes/PureBaseValidation/OwnerLightingData.asset'
+$ownerLightingDataAssetPath = Join-Path $packageRoot $ownerLightingDataRelativePath
+if (-not (Test-Path -LiteralPath $ownerLightingDataAssetPath -PathType Leaf)) {
+ throw "Owner LightingData fixture is missing: '$ownerLightingDataRelativePath'."
+}
+
+$ownerLightingDataMetaRelativePath = "$ownerLightingDataRelativePath.meta"
+$ownerLightingDataMetaPath = Join-Path $packageRoot $ownerLightingDataMetaRelativePath
+if (-not (Test-Path -LiteralPath $ownerLightingDataMetaPath -PathType Leaf)) {
+ throw "Owner LightingData metadata is missing: '$ownerLightingDataMetaRelativePath'."
+}
+
+$ownerLightingDataGuidLines = @([regex]::Matches((Get-Content -LiteralPath $ownerLightingDataMetaPath -Raw), '(?m)^guid:\s*(\S+)\s*$'))
+if ($ownerLightingDataGuidLines.Count -ne 1) {
+ throw "Owner LightingData metadata must contain exactly one GUID: '$ownerLightingDataMetaRelativePath'."
+}
+
+$ownerLightingDataGuid = $ownerLightingDataGuidLines[0].Groups[1].Value
+if ($ownerLightingDataGuid -notmatch '^[0-9a-fA-F]{32}$') {
+ throw "Owner LightingData metadata contains a malformed GUID: '$ownerLightingDataMetaRelativePath'."
+}
+
$assetsRoot = Join-Path $projectRootFullPath 'Assets'
$projectSettingsRoot = Join-Path $projectRootFullPath 'ProjectSettings'
$packagesRoot = Join-Path $projectRootFullPath 'Packages'
@@ -79,7 +101,7 @@ $manifestText = ($manifest | ConvertTo-Json -Depth 4) + "`n"
[System.Text.UTF8Encoding]::new($false)
)
-$ownerSceneText = @'
+$ownerSceneText = @"
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!29 &1
@@ -179,7 +201,7 @@ LightmapSettings:
m_ExportTrainingData: 0
m_TrainingDataDestination: TrainingData
m_LightProbeSampleCountMultiplier: 4
- m_LightingDataAsset: {fileID: 0}
+ m_LightingDataAsset: {fileID: 112000000, guid: $ownerLightingDataGuid, type: 2}
m_LightingSettings: {fileID: 0}
--- !u!196 &4
NavMeshSettings:
@@ -209,7 +231,7 @@ NavMeshSettings:
SceneRoots:
m_ObjectHideFlags: 0
m_Roots: []
-'@
+"@
[System.IO.File]::WriteAllText(
(Join-Path $assetsRoot 'Pure-Base.unity'),
$ownerSceneText.Replace("`r`n", "`n") + "`n",
diff --git a/.github/tests/New-PureBaseCiProject.Tests.ps1 b/.github/tests/New-PureBaseCiProject.Tests.ps1
index f6fd51fa..86692ad3 100644
--- a/.github/tests/New-PureBaseCiProject.Tests.ps1
+++ b/.github/tests/New-PureBaseCiProject.Tests.ps1
@@ -34,7 +34,11 @@ Describe 'Pure-Base CI Unity project generation' {
$pureBaseRoot = Join-Path $projectRoot 'Packages/jp.penguin.purebase'
$shaderCoreRoot = Join-Path $projectRoot 'Packages/jp.lilxyzw.shadercore'
$consumerSettings = Join-Path $pureBaseRoot 'Tests/Release/ConsumerProject/ProjectSettings'
- New-Item -ItemType Directory -Path $pureBaseRoot,$shaderCoreRoot,$consumerSettings -Force | Out-Null
+ $ownerLightingDataDirectory = Join-Path $pureBaseRoot 'Tests/Fixtures/Scenes/PureBaseValidation'
+ $ownerLightingDataAssetPath = Join-Path $ownerLightingDataDirectory 'OwnerLightingData.asset'
+ $ownerLightingDataMetaPath = "$ownerLightingDataAssetPath.meta"
+ $ownerLightingDataGuid = [guid]::NewGuid().ToString('N')
+ New-Item -ItemType Directory -Path $pureBaseRoot,$shaderCoreRoot,$consumerSettings,$ownerLightingDataDirectory -Force | Out-Null
[IO.File]::WriteAllText(
(Join-Path $pureBaseRoot 'package.json'),
'{"name":"jp.penguin.purebase","version":"0.1.0"}',
@@ -75,6 +79,16 @@ QualitySettings:
$qualitySettingsFixture + "`n",
[Text.UTF8Encoding]::new($false)
)
+ [IO.File]::WriteAllText(
+ $ownerLightingDataAssetPath,
+ "Owner LightingData test fixture`n",
+ [Text.UTF8Encoding]::new($false)
+ )
+ [IO.File]::WriteAllText(
+ $ownerLightingDataMetaPath,
+ "fileFormatVersion: 2`nguid: $ownerLightingDataGuid`n",
+ [Text.UTF8Encoding]::new($false)
+ )
}
It 'keeps the tracked VRChat-project QualitySettings source fixture under the reviewed contract' {
@@ -109,6 +123,14 @@ QualitySettings:
$ownerScene = Get-Content -LiteralPath $ownerScenePath -Raw
Assert-CiProjectHarness -Condition ($ownerScene -match 'SceneRoots:') -Message 'Generated owner scene is not a serialized Unity scene.'
Assert-CiProjectHarness -Condition ($ownerScene -match 'm_Roots: \[\]') -Message 'Generated owner scene must remain empty.'
+ Assert-CiProjectHarness -Condition (Test-Path -LiteralPath $ownerLightingDataAssetPath -PathType Leaf) -Message 'Temporary package fixture is missing the owner LightingData asset.'
+ Assert-CiProjectHarness -Condition (Test-Path -LiteralPath $ownerLightingDataMetaPath -PathType Leaf) -Message 'Temporary package fixture is missing the owner LightingData metadata.'
+ $ownerLightingDataMeta = Get-Content -LiteralPath $ownerLightingDataMetaPath -Raw
+ $ownerLightingDataGuidMatch = [regex]::Match($ownerLightingDataMeta, '(?m)^guid:\s*([0-9a-f]{32})\s*$')
+ Assert-CiProjectHarness -Condition $ownerLightingDataGuidMatch.Success -Message 'Temporary owner LightingData metadata must contain a GUID.'
+ $ownerSceneLightingDataGuidMatch = [regex]::Match($ownerScene, '(?m)^\s*m_LightingDataAsset: \{fileID: 112000000, guid: ([0-9a-f]{32}), type: 2\}\s*$')
+ Assert-CiProjectHarness -Condition $ownerSceneLightingDataGuidMatch.Success -Message 'Generated owner scene must reference a LightingData asset.'
+ Assert-CiProjectHarness -Condition ($ownerSceneLightingDataGuidMatch.Groups[1].Value -eq $ownerLightingDataGuidMatch.Groups[1].Value) -Message 'Generated owner scene LightingData GUID must match the owner fixture metadata GUID.'
$qualitySettingsPath = Join-Path $projectRoot 'ProjectSettings/QualitySettings.asset'
Assert-CiProjectHarness -Condition (Test-Path -LiteralPath $qualitySettingsPath -PathType Leaf) -Message 'Generated CI project is missing the reviewed VRChat-project QualitySettings snapshot.'
@@ -133,4 +155,26 @@ QualitySettings:
catch { $failure = $_ }
Assert-CiProjectHarness -Condition ($null -ne $failure -and $failure.Exception.Message -like '*exactly 0.1.9*') -Message 'The CI project builder accepted an unexpected Shader-Core version.'
}
+
+ It 'rejects a missing owner LightingData fixture' {
+ Remove-Item -LiteralPath $ownerLightingDataAssetPath -Force
+
+ $failure = $null
+ try { & $projectBuilder -ProjectRoot $projectRoot }
+ catch { $failure = $_ }
+ Assert-CiProjectHarness -Condition ($null -ne $failure -and $failure.Exception.Message -like '*Owner LightingData fixture is missing*') -Message 'The CI project builder accepted a missing owner LightingData fixture.'
+ }
+
+ It 'rejects malformed owner LightingData metadata GUIDs' {
+ [IO.File]::WriteAllText(
+ $ownerLightingDataMetaPath,
+ "fileFormatVersion: 2`nguid: malformed`n",
+ [Text.UTF8Encoding]::new($false)
+ )
+
+ $failure = $null
+ try { & $projectBuilder -ProjectRoot $projectRoot }
+ catch { $failure = $_ }
+ Assert-CiProjectHarness -Condition ($null -ne $failure -and $failure.Exception.Message -like '*malformed GUID*') -Message 'The CI project builder accepted malformed owner LightingData metadata.'
+ }
}
diff --git a/CHANGELOG b/CHANGELOG
index 562707be..bca7b881 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,13 @@
+2026/08/08
+Ver. 0.2.0-beta.1
+https://github.com/Penguin-Repository/Pure-Base/releases#release-0.2.0-beta.1
+
+- Added the public `_RenderingMode` ABI with Opaque, Cutout, and Transparent states.
+- Added explicit editor synchronization through `PureBaseMaterialRenderingMode.Apply(Material)` and `Assets/PureBase/Resync Rendering Mode`.
+- Added documented render-state behavior for queue, blend, depth writing, coverage, and Transparent pass enablement.
+- Preserved four source pass declarations while allowing Transparent mode to disable `ShadowCaster` and `Meta`.
+- Updated the package version and release download identity to `0.2.0-beta.1`.
+
2026/08/06
Ver. 0.1.0
https://github.com/Penguin-Repository/Pure-Base/releases#release-0.1.0
diff --git a/Docs/pure-base-shader-contract.md b/Docs/pure-base-shader-contract.md
index d3c560a0..e2802585 100644
--- a/Docs/pure-base-shader-contract.md
+++ b/Docs/pure-base-shader-contract.md
@@ -25,7 +25,7 @@ This document defines the stable public contract of the Pure-Base shader package
- Integration test graphics API: D3D11, forced by the harness.
- Shader-Core dependency: exactly `jp.lilxyzw.shadercore` `0.1.9`.
- Pure-Base does not automatically allow future `0.1.x` releases. Shader-Core upstream has not declared compatibility across `0.x` releases, and importer, ProjectSettings, and method-shape contracts are sensitive.
-- Transparent material blending and URP are outside the supported contract.
+- Opaque, Cutout, and Transparent rendering modes are supported. URP is outside the supported contract.
## Stable Shader Paths
@@ -42,16 +42,32 @@ Each shader is independently usable without an optional module.
## Material and Pass Contract
-Every product shader has the fixed tags `RenderType=TransparentCutout` and `Queue=AlphaTest`. Each exposes exactly four passes:
+Every product shader source retains exactly four passes:
| Pass | Ownership and restrictions |
| --- | --- |
| `ForwardBase` | Builds the normal surface and lighting result. PBR and Hybrid own Unity Standard indirect GI and reflection-probe evaluation here. |
| `ForwardAdd` | Additional direct-light contribution only, with black fog semantics. PBR and Hybrid must not duplicate indirect GI or reflection-probe lighting here. |
-| `ShadowCaster` | Applies Cutout coverage after the Shader-Core `base` phase, so module changes to `sd.albedoAlpha.a` affect casting. |
-| `Meta` | Uses the host base-texture Cutout coverage for Meta/lightmap workflows. This dedicated pass does not execute the standard phase ABI. |
+| `ShadowCaster` | When enabled for Cutout, applies coverage after the Shader-Core `base` phase, so module changes to `sd.albedoAlpha.a` affect casting. |
+| `Meta` | Uses the host base-texture Cutout coverage for Meta/lightmap workflows when enabled. This dedicated pass does not execute the standard phase ABI. |
-The Cutout contract is not transparent blending support. The `ForwardAdd` additive blend state represents an additional direct-light pass, not a transparent material mode.
+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.
+
+## Rendering-mode ABI
+
+`_RenderingMode` is a ShaderLab `Integer` backed by `SC_uint` with these values:
+
+| Value | Mode | Contract |
+| ---: | --- | --- |
+| `0` | Opaque | Uses `RenderType=Opaque`, queue `2000`, blend `One Zero`, and `ZWrite 1`. Opaque rendering is uncut and unblended; lighting contributions remain enabled. |
+| `1` | Cutout (default) | Clears the material queue override to `-1`, resolving `RenderType=TransparentCutout` and the `AlphaTest` queue at `2450`. It uses no mode keyword, clips coverage, and keeps lighting contributions enabled. |
+| `2` | Transparent | Uses `RenderType=Transparent`, queue `3000`, base blend `SrcAlpha OneMinusSrcAlpha`, additional-light blend `SrcAlpha One`, and `ZWrite 0`. `ShadowCaster` and `Meta` are disabled. |
+
+Cutout is the keyword-free state. Opaque and Transparent use only local rendering-mode keywords. All source shaders retain their four pass declarations even when Transparent disables `ShadowCaster` and `Meta`.
+
+Coverage behavior is part of the public contract: Opaque is uncut and unblended, Cutout clips coverage, and Transparent alpha-blends without writing depth. The final alpha produced by `postpixel` controls the `ForwardBase` and `ForwardAdd` source alpha.
+
+The explicit editor action is `PureBaseMaterialRenderingMode.Apply(Material)`. The selected-material menu is `Assets/PureBase/Resync Rendering Mode`. Opening or refreshing the Inspector does not migrate or dirty a legacy material. Runtime switching is not guaranteed. An explicit mode change or Resync resets the standard queue and synchronizes derived state; a user custom queue remains until the next explicit mode edit or Resync.
## Public Property ABI
@@ -65,6 +81,7 @@ All four shaders expose exactly these common properties:
| `_SharedGradients` | All shaders |
| `_Cutoff` | All shaders |
| `_Cull` | All shaders |
+| `_RenderingMode` | All shaders |
The model-specific properties are:
@@ -85,9 +102,9 @@ The standard insertion points are shared by the product hosts in this order:
`morph` -> `postvertex` -> `base` -> `light` -> `customlight` -> `modifylight` -> `shade` -> `reflection` -> `add` -> `postpixel`
-External modules may target these standard phases. The `base` phase runs before Cutout coverage is finalized. The host saturates only `sd.albedoAlpha.a` before the alpha test; `sd.albedoAlpha.rgb` remains unclamped so HDR base color and module color adjustments are preserved. The host finalizes output alpha and applies fog before `postpixel`; no host color mutation occurs after `postpixel` before returning the fragment result.
+External modules may target these standard phases. The `base` phase runs before Cutout coverage is finalized. The host saturates only `sd.albedoAlpha.a` before the alpha test; `sd.albedoAlpha.rgb` remains unclamped so HDR base color and module color adjustments are preserved. The host finalizes output alpha and applies fog before `postpixel`; no host color mutation occurs after `postpixel` before returning the fragment result. The final alpha from `postpixel` is the source alpha for both `ForwardBase` and `ForwardAdd`.
-`Meta` is not a standard-phase execution path. Pass ownership remains fixed: `ForwardBase` builds the normal surface and lighting result, `ForwardAdd` is additional direct light only, `ShadowCaster` honors base-phase Cutout changes, and `Meta` retains host-owned Cutout coverage.
+`Meta` is not a standard-phase execution path. Pass ownership remains fixed: `ForwardBase` builds the normal surface and lighting result, `ForwardAdd` is additional direct light only, `ShadowCaster` honors base-phase Cutout changes when enabled, and `Meta` retains host-owned Cutout coverage when enabled.
## Model Semantics
diff --git a/Docs/technical-information.ja.md b/Docs/technical-information.ja.md
index 502f931c..3433aa96 100644
--- a/Docs/technical-information.ja.md
+++ b/Docs/technical-information.ja.md
@@ -29,7 +29,7 @@ Pure Base は Shader-Core を動かすための最小構成の土台です。多
- `jp.lilxyzw.shadercore` `0.1.9` が必要です。
- 将来の Shader-Core `0.1.x` を自動では許可しません。Shader-Core は `0.x` 間の互換性を保証しておらず、読み込み処理、プロジェクト設定、関数の形が変わる可能性があります。
- 検証では D3D11 を使用します。
-- 半透明の描画には対応していません。製品シェーダーは Cutout の描画状態を使用し、Forward と ShadowCaster の切り抜き判定は `base` 後のモジュール調整済み `sd.albedoAlpha.a` に従います。Meta はホスト管理のベーステクスチャの被覆を維持します。
+- Opaque、Cutout、Transparent の描画モードに対応しています。初期状態は Cutout です。URP には対応していません。
## シェーダー名
@@ -46,16 +46,30 @@ Pure Base は Shader-Core を動かすための最小構成の土台です。多
## 描画処理と公開項目
-すべてのシェーダーは `RenderType=TransparentCutout`、`AlphaTest` キュー、次の4つの描画処理を使用します。
+すべてのシェーダーのソースには、次の4つの描画処理が残ります。実際の描画状態は描画モードで決まり、Transparent では `ShadowCaster` と `Meta` が無効になります。
- `ForwardBase`
- `ForwardAdd`
- `ShadowCaster`
- `Meta`
+### 描画モード ABI
+
+`_RenderingMode` は `SC_uint` を基にした ShaderLab の `Integer` です。値は `Opaque=0`、`Cutout=1`(初期値)、`Transparent=2` です。
+
+| モード | 実際の描画状態 |
+| --- | --- |
+| Opaque | `RenderType=Opaque`、キュー `2000`、ブレンド `One Zero`、`ZWrite 1`。切り抜きとブレンドを行わず、ライティングの寄与を有効にします。 |
+| Cutout | 保存されているキューの上書きを `-1` に戻し、`RenderType=TransparentCutout` と `AlphaTest` キュー `2450` に解決します。モードキーワードを使わず、被覆を切り抜き、ライティングの寄与を有効にします。 |
+| Transparent | `RenderType=Transparent`、キュー `3000`、ベースのブレンド `SrcAlpha OneMinusSrcAlpha`、追加ライトのブレンド `SrcAlpha One`、`ZWrite 0`。`ShadowCaster` と `Meta` は無効になります。 |
+
+キーワードを使わない状態が Cutout です。Opaque と Transparent ではローカルな描画モードキーワードだけを使用します。`postpixel` が最後に出力するアルファは、`ForwardBase` と `ForwardAdd` のソースアルファを決めます。
+
+エディターから明示的に適用する操作は `PureBaseMaterialRenderingMode.Apply(Material)` です。選択中のマテリアルには `Assets/PureBase/Resync Rendering Mode` を使えます。Inspector を開いたり更新したりするだけでは、旧形式のマテリアルを移行したり変更済みにしたりしません。実行時の切り替えは保証しません。モード変更または Resync を明示的に行うと標準キューをリセットして派生状態を同期します。ユーザーが設定したカスタムキューは、次にモードを明示的に編集または Resync するまで維持されます。
+
共通して公開する項目は次のとおりです。
-`_BaseTexture`, `_BaseColor`, `_SharedMask`, `_SharedGradients`, `_Cutoff`, `_Cull`
+`_RenderingMode`(`SC_uint` を基にした ShaderLab の `Integer`、`Opaque=0`、`Cutout=1`(初期値)、`Transparent=2`), `_BaseTexture`, `_BaseColor`, `_SharedMask`, `_SharedGradients`, `_Cutoff`, `_Cull`
`PureBase/Toon` は、追加で `_NormalMap` と `_NormalScale` を公開します。
@@ -71,8 +85,8 @@ Pure Base は Shader-Core を動かすための最小構成の土台です。多
- `ForwardBase` は通常の表面とライティング結果を担当します。
- `ForwardAdd` は追加ライトの直接光だけを加算します。
-- `ForwardBase`、`ForwardAdd`、`ShadowCaster` は、`base` 後のモジュール調整済み `sd.albedoAlpha.a` から切り抜き範囲を決定します。`Meta` はホスト管理のベーステクスチャの被覆を維持します。
-- `postpixel` は色を変更できる最後の差し込み位置です。モジュールは返却されるアルファを変更できますが、製品パスのブレンド状態とカラーマスクは固定されており、半透明描画にはなりません。
+- Cutout では、`ForwardBase`、`ForwardAdd`、有効な `ShadowCaster` が `base` 後のモジュール調整済み `sd.albedoAlpha.a` から被覆を決定します。Opaque は切り抜きを行わず、Transparent は深度を書き込まずにアルファブレンドし、`ShadowCaster` と `Meta` を無効にします。
+- `postpixel` は色を変更できる最後の差し込み位置です。モジュールが変更した最後のアルファは、両フォワードパスでソースアルファとして使われます。製品パスのカラーマスクは固定されています。
- PBR と Hybrid は、Unity 標準の間接光と反射プローブを `ForwardBase` で計算します。`ForwardAdd` では間接光を重複して計算しません。
リムライト、MatCap、デカール、細部用テクスチャ、発光、ディゾルブ、距離によるフェード、視差表現、髪向け反射、クリアコート、グリッター、特定環境専用の連携などは、別の Shader-Core モジュールで追加する想定です。Pure Base 本体には含めません。
@@ -81,6 +95,8 @@ Pure Base は Shader-Core を動かすための最小構成の土台です。多
`package.json` が、公開名と版番号を決める唯一の情報源です。
+現在のパッケージ版は `0.2.0-beta.1` です。
+
手動の `Release` ワークフローへ渡す `version` は、すでにパッケージへ記載されている版番号と一致するかを確認するためだけに使われます。版番号の書き換えやコミットは行いません。
公開は次の順で行います。
diff --git a/Docs/technical-information.md b/Docs/technical-information.md
index 99c5d2fa..49daedd7 100644
--- a/Docs/technical-information.md
+++ b/Docs/technical-information.md
@@ -29,7 +29,7 @@ Pure Base is a minimal Shader-Core host. It is not intended to become a feature-
- The package requires exactly `jp.lilxyzw.shadercore` `0.1.9`.
- Future `0.1.x` Shader-Core releases are not accepted automatically. Shader-Core does not declare compatibility across `0.x` releases, and importer, project-setting, and method-shape contracts may change.
- The integration harness forces D3D11 during test execution.
-- Transparent blending is not supported. Product shaders use Cutout render states; Forward and ShadowCaster coverage follows the module-adjusted `sd.albedoAlpha.a` after `base`, while Meta retains host-owned base-texture coverage.
+- Opaque, Cutout, and Transparent rendering modes are supported. Cutout is the default mode; URP is not supported.
## Stable shader paths
@@ -46,16 +46,30 @@ The complete, stable pass and property contract is defined in [Pure Base shader
## Render passes and public properties
-Every shader uses `RenderType=TransparentCutout`, the `AlphaTest` queue, and exactly four passes:
+Every shader source retains exactly four passes. The rendering mode selects the effective render state; Transparent disables the `ShadowCaster` and `Meta` passes without removing their source declarations:
- `ForwardBase`
- `ForwardAdd`
- `ShadowCaster`
- `Meta`
+### Rendering mode ABI
+
+`_RenderingMode` is a ShaderLab `Integer` backed by `SC_uint`. The values are `Opaque=0`, `Cutout=1` (default), and `Transparent=2`.
+
+| Mode | Effective state |
+| --- | --- |
+| Opaque | `RenderType=Opaque`, queue `2000`, blend `One Zero`, `ZWrite 1`; uncut and unblended with lighting contributions enabled. |
+| Cutout | Clears the serialized queue override to `-1`, resolves `RenderType=TransparentCutout` and `AlphaTest` queue `2450`; keyword-free, clips coverage, and keeps lighting contributions enabled. |
+| Transparent | `RenderType=Transparent`, queue `3000`, base blend `SrcAlpha OneMinusSrcAlpha`, additional-light blend `SrcAlpha One`, `ZWrite 0`; `ShadowCaster` and `Meta` are disabled. |
+
+Only local Opaque and Transparent keywords are used; Cutout is keyword-free. The final alpha from `postpixel` controls the `ForwardBase` and `ForwardAdd` source alpha.
+
+The explicit editor action is `PureBaseMaterialRenderingMode.Apply(Material)`. For selected materials, use `Assets/PureBase/Resync Rendering Mode`. Opening or refreshing the Inspector does not migrate or dirty a legacy material. Runtime switching is not guaranteed. An explicit mode change or Resync resets the standard queue and synchronizes derived state; a user custom queue remains until the next explicit mode edit or Resync.
+
All four shaders expose these common properties:
-`_BaseTexture`, `_BaseColor`, `_SharedMask`, `_SharedGradients`, `_Cutoff`, `_Cull`
+`_RenderingMode` (`Integer` backed by `SC_uint`; `Opaque=0`, `Cutout=1` (default), `Transparent=2`), `_BaseTexture`, `_BaseColor`, `_SharedMask`, `_SharedGradients`, `_Cutoff`, `_Cull`
`PureBase/Toon` additionally exposes `_NormalMap` and `_NormalScale`.
@@ -71,8 +85,8 @@ The shared standard phase ABI is executed in this order:
- `ForwardBase` owns the normal surface and lighting result.
- `ForwardAdd` contributes additional direct light only and uses black fog semantics.
-- `ForwardBase`, `ForwardAdd`, and `ShadowCaster` derive Cutout coverage from the module-adjusted `sd.albedoAlpha.a` after `base`. `Meta` retains host-owned base-texture coverage.
-- `postpixel` is the final color mutation point. Modules may change the returned alpha there, but the product pass blend and color-mask states remain fixed and do not provide transparent blending.
+- In Cutout, `ForwardBase`, `ForwardAdd`, and enabled `ShadowCaster` derive coverage from the module-adjusted `sd.albedoAlpha.a` after `base`. Opaque is uncut, while Transparent alpha-blends without depth writing and disables `ShadowCaster` and `Meta`.
+- `postpixel` is the final color mutation point. Modules may change the returned alpha there, and that final alpha is used as the source alpha by both forward passes. The product color-mask states remain fixed.
- PBR and Hybrid evaluate Unity Standard indirect GI and reflection probes in `ForwardBase`. Their `ForwardAdd` passes do not duplicate indirect lighting.
Optional visual features belong in separate Shader-Core modules. Pure Base does not include rim lighting, MatCap, decals, detail textures, emission, dissolve, distance fade, parallax, hair or anisotropic specular, clear coat, glitter, or platform-specific integrations.
@@ -81,6 +95,8 @@ Optional visual features belong in separate Shader-Core modules. Pure Base does
`package.json` is the sole release identity and version declaration.
+The current package release is `0.2.0-beta.1`.
+
The `version` input of the manual `Release` workflow verifies the exact version already present in the checked-out package. It does not write or commit a version.
The intended publication sequence is:
diff --git a/Editor/PureBase.Editor.asmdef b/Editor/PureBase.Editor.asmdef
index 93f1bd58..37dc19b6 100644
--- a/Editor/PureBase.Editor.asmdef
+++ b/Editor/PureBase.Editor.asmdef
@@ -1,7 +1,9 @@
{
"name": "PureBase.Editor",
"rootNamespace": "PureBase.Editor",
- "references": [],
+ "references": [
+ "jp.lilxyzw.shadercore"
+ ],
"includePlatforms": [
"Editor"
],
diff --git a/Editor/PureBaseCutoffElement.cs b/Editor/PureBaseCutoffElement.cs
new file mode 100644
index 00000000..c83bf60c
--- /dev/null
+++ b/Editor/PureBaseCutoffElement.cs
@@ -0,0 +1,104 @@
+/*
+ * 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.
+ */
+
+// Displays the existing Shader-Core Cutoff range control only for Cutout material selections.
+
+using System;
+using jp.lilxyzw.shadercore;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UIElements;
+using SCMaterialProperty = jp.lilxyzw.shadercore.MaterialProperty;
+
+namespace PureBase.Editor
+{
+ /// Wraps the Shader-Core Cutoff range drawer with read-only rendering-mode visibility.
+ internal static class PureBaseCutoffElement
+ {
+ /// Identifies the rendering-mode selector property.
+ private const string RenderingModePropertyName = "_RenderingMode";
+
+ /// Identifies the existing Cutoff range drawer and its stable bounds.
+ private const string CutoffRangeAttribute = "SCRange(-0.001,1.001)";
+
+ /// Registers the Cutoff drawer with Shader-Core when the Editor domain loads.
+ [InitializeOnLoadMethod]
+ private static void RegisterDrawer()
+ {
+ AttributeActions.AddDrawer("PureBaseCutoff", Draw);
+ }
+
+ /// Adds the existing Shader-Core range drawer with mode-controlled visibility.
+ /// The active Shader-Core material editor.
+ /// The Cutoff material property.
+ /// Unused drawer arguments.
+ /// The property container that owns the drawer UI.
+ private static void Draw(SCMaterialEditor editor, SCMaterialProperty property, string _, VisualElement container)
+ {
+ var rangeContainer = new VisualElement();
+ container.Add(rangeContainer);
+ editor.ShaderProperty(rangeContainer, property, new[] { CutoffRangeAttribute });
+
+ UpdateVisibility(rangeContainer, property.targets);
+ rangeContainer.RegisterCallback(_ => UpdateVisibility(rangeContainer, property.targets));
+ }
+
+ /// Updates visibility from current selected material values without modifying them.
+ /// The element that owns the existing range drawer.
+ /// The shared targets of the represented Cutoff property.
+ private static void UpdateVisibility(VisualElement container, UnityEngine.Object[] targets)
+ {
+ SelectionDisplayState displayState = GetSelectionDisplayState(targets);
+ container.style.display = displayState.IsVisible ? DisplayStyle.Flex : DisplayStyle.None;
+ }
+
+ /// Gets the read-only Cutoff drawer state for the supplied material selection.
+ /// The targets associated with the Cutoff material property.
+ /// The visibility state derived from supported Cutout targets.
+ internal static SelectionDisplayState GetSelectionDisplayState(UnityEngine.Object[] targets)
+ {
+ if (targets == null)
+ throw new ArgumentNullException(nameof(targets));
+
+ for (int index = 0; index < targets.Length; index++)
+ {
+ if (targets[index] is Material material
+ && PureBaseRenderingModeElement.IsPureBaseMaterial(material)
+ && material.HasProperty(RenderingModePropertyName)
+ && material.GetInteger(RenderingModePropertyName) == (int)PureBaseRenderingMode.Cutout)
+ {
+ return new SelectionDisplayState(true);
+ }
+ }
+
+ return new SelectionDisplayState(false);
+ }
+
+ /// Represents the read-only visibility of the Cutoff drawer for one material selection.
+ internal readonly struct SelectionDisplayState
+ {
+ /// Initializes a Cutoff drawer selection display state.
+ /// Whether at least one supported selected material is Cutout.
+ public SelectionDisplayState(bool isVisible)
+ {
+ IsVisible = isVisible;
+ }
+
+ /// Gets whether the Cutoff drawer is visible for the current selection.
+ public bool IsVisible { get; }
+ }
+ }
+}
diff --git a/Editor/PureBaseCutoffElement.cs.meta b/Editor/PureBaseCutoffElement.cs.meta
new file mode 100644
index 00000000..2038a463
--- /dev/null
+++ b/Editor/PureBaseCutoffElement.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4649013c6ea345b43ab0539921bf0d56
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/PureBaseRenderingMode.cs b/Editor/PureBaseRenderingMode.cs
new file mode 100644
index 00000000..8e44f7cc
--- /dev/null
+++ b/Editor/PureBaseRenderingMode.cs
@@ -0,0 +1,682 @@
+/*
+ * 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.
+ */
+
+// Synchronizes the derived rendering state for supported Pure-Base materials.
+
+using System;
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+using jp.lilxyzw.shadercore;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.Rendering;
+
+namespace PureBase.Editor
+{
+ /// Identifies the supported Pure-Base material rendering modes.
+ public enum PureBaseRenderingMode
+ {
+ /// Uses opaque blending and opaque contribution passes.
+ Opaque = 0,
+
+ /// Uses alpha-tested rendering with the shader-default queue.
+ Cutout = 1,
+
+ /// Uses alpha blending without depth writes or contribution passes.
+ Transparent = 2,
+ }
+
+ /// Explicitly synchronizes derived rendering state for supported Pure-Base materials.
+ public static class PureBaseMaterialRenderingMode
+ {
+ /// Identifies the rendering-mode selector property.
+ private const string RenderingModePropertyName = "_RenderingMode";
+
+ /// Identifies the source blend-factor property.
+ private const string SourceBlendPropertyName = "_SrcBlend";
+
+ /// Identifies the destination blend-factor property.
+ private const string DestinationBlendPropertyName = "_DstBlend";
+
+ /// Identifies the depth-write property.
+ private const string DepthWritePropertyName = "_ZWrite";
+
+ /// Identifies the additive source blend-factor property.
+ private const string AdditiveSourceBlendPropertyName = "_AddSrcBlend";
+
+ /// Identifies the additive destination blend-factor property.
+ private const string AdditiveDestinationBlendPropertyName = "_AddDstBlend";
+
+ /// Identifies the RenderType tag.
+ private const string RenderTypeTagName = "RenderType";
+
+ /// Identifies the Opaque local keyword.
+ private const string OpaqueKeyword = "PUREBASE_RENDERING_OPAQUE";
+
+ /// Identifies the Transparent local keyword.
+ private const string TransparentKeyword = "PUREBASE_RENDERING_TRANSPARENT";
+
+ /// Identifies the ShadowCaster shader pass.
+ private const string ShadowCasterPassName = "ShadowCaster";
+
+ /// Identifies the Meta shader pass.
+ private const string MetaPassName = "Meta";
+
+ /// Identifies the selected-material resynchronization command.
+ private const string ResyncMenuItemName = "Assets/PureBase/Resync Rendering Mode";
+
+ /// Identifies the Undo operation for selected-material resynchronization.
+ private const string ResyncUndoName = "Resync PureBase Rendering Mode";
+
+ /// Lists the only stable public shader names owned by Pure-Base.
+ private static readonly HashSet PureBaseShaderNames = new HashSet(StringComparer.Ordinal)
+ {
+ "PureBase/Unlit",
+ "PureBase/Toon",
+ "PureBase/PBR",
+ "PureBase/Hybrid",
+ };
+
+ /// Lists the hidden state properties that are synchronized with the selected mode.
+ private static readonly string[] RequiredStatePropertyNames =
+ {
+ SourceBlendPropertyName,
+ DestinationBlendPropertyName,
+ DepthWritePropertyName,
+ AdditiveSourceBlendPropertyName,
+ AdditiveDestinationBlendPropertyName,
+ };
+
+ /// Defines every derived state value for one rendering mode.
+ private static readonly ModeState[] ModeStates =
+ {
+ ModeState.CreateOpaque(),
+ ModeState.CreateCutout(),
+ ModeState.CreateTransparent(),
+ };
+
+ /// Applies the derived state for the material's current rendering-mode value.
+ /// The supported Pure-Base material to synchronize.
+ /// Thrown when is .
+ /// Thrown when the material does not expose the supported Pure-Base rendering-mode contract.
+ /// Thrown when the rendering-mode value is not an integral supported value.
+ public static void Apply(Material material)
+ {
+ Validate(material);
+ ApplyValidatedMaterials(new[] { material });
+ }
+
+ /// Validates and atomically applies derived rendering state to an already-filtered material selection.
+ /// The supported Pure-Base materials to synchronize.
+ internal static void ApplyAll(IReadOnlyList materials)
+ {
+ if (materials == null)
+ throw new ArgumentNullException(nameof(materials));
+
+ ValidateAll(materials);
+ ApplyValidatedMaterials(materials);
+ }
+
+ /// Determines whether one material uses a stable Pure-Base shader.
+ /// The material to inspect.
+ /// when the material uses one of the four supported shader names.
+ internal static bool IsPureBaseMaterial(Material material)
+ {
+ return material != null && material.shader != null && PureBaseShaderNames.Contains(material.shader.name);
+ }
+
+ /// Validates one material without modifying its serialized state.
+ /// The material to validate.
+ internal static void Validate(Material material)
+ {
+ if (material == null)
+ throw new ArgumentNullException(nameof(material));
+
+ if (!IsPureBaseMaterial(material))
+ throw CreateValidationException(material, "its shader is not a supported Pure-Base shader");
+
+ Shader shader = material.shader;
+
+ if (!material.HasProperty(RenderingModePropertyName))
+ throw CreateValidationException(material, "it does not expose the Pure-Base rendering-mode property");
+
+ int renderingModePropertyIndex = shader.FindPropertyIndex(RenderingModePropertyName);
+ if (renderingModePropertyIndex < 0 || shader.GetPropertyType(renderingModePropertyIndex) != ShaderPropertyType.Int)
+ throw CreateValidationException(material, "it does not expose the Pure-Base integer rendering-mode property");
+
+ for (int index = 0; index < RequiredStatePropertyNames.Length; index++)
+ {
+ if (!material.HasProperty(RequiredStatePropertyNames[index]))
+ throw CreateValidationException(material, "it does not expose the complete Pure-Base rendering-mode state contract");
+ }
+
+ GetModeIndex(material);
+ }
+
+ /// Creates a validation exception that identifies the rejected material and its contract failure.
+ /// The non-null material that failed validation.
+ /// The specific rendering-mode contract rejection reason.
+ /// An exception that preserves the established validation exception type.
+ private static InvalidOperationException CreateValidationException(Material material, string reason)
+ {
+ return new InvalidOperationException("Material '" + material.name + "' was rejected because " + reason + ".");
+ }
+
+ /// Invokes selected-material resynchronization from Unity's Assets menu.
+ [MenuItem(ResyncMenuItemName)]
+ private static void ResyncSelectedMaterials()
+ {
+ Material[] materials = GetSelectedPureBaseMaterials();
+ try
+ {
+ ValidateAll(materials);
+
+ Undo.IncrementCurrentGroup();
+ int undoGroup = Undo.GetCurrentGroup();
+ Undo.SetCurrentGroupName(ResyncUndoName);
+ Undo.RecordObjects(materials, ResyncUndoName);
+ ApplyValidatedMaterials(materials);
+ Undo.CollapseUndoOperations(undoGroup);
+ SCUpdateEvent.Invoke();
+ }
+ catch (Exception exception)
+ {
+ Debug.LogException(exception);
+ }
+ }
+
+ /// Determines whether selected-material resynchronization is currently available.
+ /// when at least one selected material satisfies the complete contract.
+ [MenuItem(ResyncMenuItemName, true)]
+ private static bool ValidateResyncSelectedMaterials()
+ {
+ Material[] materials = GetSelectedPureBaseMaterials();
+ if (materials.Length == 0)
+ return false;
+
+ try
+ {
+ ValidateAll(materials);
+ return true;
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ catch (InvalidOperationException)
+ {
+ return false;
+ }
+ }
+
+ /// Returns selected assets whose stable shader names belong to Pure-Base.
+ /// The filtered selection, without any non-Pure-Base materials.
+ private static Material[] GetSelectedPureBaseMaterials()
+ {
+ Material[] selectedMaterials = Selection.GetFiltered(SelectionMode.Assets);
+ var pureBaseMaterials = new List(selectedMaterials.Length);
+ for (int index = 0; index < selectedMaterials.Length; index++)
+ {
+ Material material = selectedMaterials[index];
+ if (IsPureBaseMaterial(material))
+ pureBaseMaterials.Add(material);
+ }
+
+ return pureBaseMaterials.ToArray();
+ }
+
+ /// Validates every material before an operation can mutate any selected target.
+ /// The materials to validate.
+ private static void ValidateAll(IReadOnlyList materials)
+ {
+ for (int index = 0; index < materials.Count; index++)
+ Validate(materials[index]);
+ }
+
+ /// Captures, applies, and restores a fully prevalidated material set as one atomic operation.
+ /// The prevalidated materials to synchronize.
+ private static void ApplyValidatedMaterials(IReadOnlyList materials)
+ {
+ var snapshots = new MaterialStateSnapshot[materials.Count];
+ for (int index = 0; index < materials.Count; index++)
+ snapshots[index] = MaterialStateSnapshot.Capture(materials[index]);
+
+ try
+ {
+ for (int index = 0; index < materials.Count; index++)
+ {
+ Material material = materials[index];
+ ApplyState(material, ModeStates[GetModeIndex(material)]);
+ EditorUtility.SetDirty(material);
+ }
+ }
+ catch (Exception applyException)
+ {
+ Exception rollbackException = null;
+ for (int index = snapshots.Length - 1; index >= 0; index--)
+ {
+ try
+ {
+ snapshots[index].Restore(materials[index]);
+ }
+ catch (Exception exception)
+ {
+ if (rollbackException == null)
+ rollbackException = exception;
+ }
+ }
+
+ if (rollbackException != null)
+ {
+ throw new AggregateException(
+ "Rendering-mode normalization failed and rollback encountered errors.",
+ new[] { applyException, rollbackException }
+ );
+ }
+
+ throw;
+ }
+ }
+
+ /// Applies the derived fields that are owned by the rendering-mode state table.
+ /// The material to synchronize.
+ /// The state selected by the material's rendering-mode value.
+ private static void ApplyState(Material material, ModeState state)
+ {
+ material.SetFloat(SourceBlendPropertyName, state.SourceBlend);
+ material.SetFloat(DestinationBlendPropertyName, state.DestinationBlend);
+ material.SetFloat(DepthWritePropertyName, state.DepthWrite);
+ material.SetFloat(AdditiveSourceBlendPropertyName, state.AdditiveSourceBlend);
+ material.SetFloat(AdditiveDestinationBlendPropertyName, state.AdditiveDestinationBlend);
+ material.SetOverrideTag(RenderTypeTagName, state.RenderType);
+ material.renderQueue = state.RawRenderQueue;
+ SetKeyword(material, OpaqueKeyword, state.EnableOpaqueKeyword);
+ SetKeyword(material, TransparentKeyword, state.EnableTransparentKeyword);
+ material.SetShaderPassEnabled(ShadowCasterPassName, state.EnableContributionPasses);
+ material.SetShaderPassEnabled(MetaPassName, state.EnableContributionPasses);
+ }
+
+ /// Sets one local keyword without affecting any other keyword.
+ /// The material whose keyword state changes.
+ /// The exact keyword to change.
+ /// Whether the keyword must be enabled.
+ private static void SetKeyword(Material material, string keyword, bool enabled)
+ {
+ if (enabled)
+ material.EnableKeyword(keyword);
+ else
+ material.DisableKeyword(keyword);
+ }
+
+ /// Returns the validated rendering-mode array index for a material.
+ /// The material whose mode is read.
+ /// The zero-based state-table index.
+ private static int GetModeIndex(Material material)
+ {
+ int value = material.GetInteger(RenderingModePropertyName);
+ if (value < 0 || value > 2)
+ throw new ArgumentOutOfRangeException(
+ RenderingModePropertyName,
+ value,
+ "Material '" + material.name + "' has a rendering-mode value outside the supported range: 0, 1, or 2."
+ );
+
+ return value;
+ }
+
+ /// Defines all derived rendering values for one supported mode.
+ private readonly struct ModeState
+ {
+ /// Creates the derived state for opaque rendering.
+ /// The immutable opaque rendering state.
+ public static ModeState CreateOpaque()
+ {
+ return new ModeState(
+ (int)BlendMode.One,
+ (int)BlendMode.Zero,
+ 1,
+ (int)BlendMode.One,
+ (int)BlendMode.One,
+ "Opaque",
+ 2000,
+ new ModeStateFlags(true, false, true)
+ );
+ }
+
+ /// Creates the derived state for cutout rendering.
+ /// The immutable cutout rendering state.
+ public static ModeState CreateCutout()
+ {
+ return new ModeState(
+ (int)BlendMode.One,
+ (int)BlendMode.Zero,
+ 1,
+ (int)BlendMode.One,
+ (int)BlendMode.One,
+ string.Empty,
+ -1,
+ new ModeStateFlags(false, false, true)
+ );
+ }
+
+ /// Creates the derived state for transparent rendering.
+ /// The immutable transparent rendering state.
+ public static ModeState CreateTransparent()
+ {
+ return new ModeState(
+ (int)BlendMode.SrcAlpha,
+ (int)BlendMode.OneMinusSrcAlpha,
+ 0,
+ (int)BlendMode.SrcAlpha,
+ (int)BlendMode.One,
+ "Transparent",
+ 3000,
+ new ModeStateFlags(false, true, false)
+ );
+ }
+
+ /// Initializes the immutable derived rendering state.
+ /// The base-pass source blend factor.
+ /// The base-pass destination blend factor.
+ /// The depth-write state.
+ /// The additive-pass source blend factor.
+ /// The additive-pass destination blend factor.
+ /// The RenderType override tag.
+ /// The raw material queue override.
+ /// The keyword and contribution-pass state.
+ private ModeState(
+ int sourceBlend,
+ int destinationBlend,
+ int depthWrite,
+ int additiveSourceBlend,
+ int additiveDestinationBlend,
+ string renderType,
+ int rawRenderQueue,
+ ModeStateFlags flags)
+ {
+ SourceBlend = sourceBlend;
+ DestinationBlend = destinationBlend;
+ DepthWrite = depthWrite;
+ AdditiveSourceBlend = additiveSourceBlend;
+ AdditiveDestinationBlend = additiveDestinationBlend;
+ RenderType = renderType;
+ RawRenderQueue = rawRenderQueue;
+ EnableOpaqueKeyword = flags.EnableOpaqueKeyword;
+ EnableTransparentKeyword = flags.EnableTransparentKeyword;
+ EnableContributionPasses = flags.EnableContributionPasses;
+ }
+
+ /// Gets the base-pass source blend factor.
+ public int SourceBlend { get; }
+
+ /// Gets the base-pass destination blend factor.
+ public int DestinationBlend { get; }
+
+ /// Gets the depth-write state.
+ public int DepthWrite { get; }
+
+ /// Gets the additive-pass source blend factor.
+ public int AdditiveSourceBlend { get; }
+
+ /// Gets the additive-pass destination blend factor.
+ public int AdditiveDestinationBlend { get; }
+
+ /// Gets the RenderType override tag.
+ public string RenderType { get; }
+
+ /// Gets the raw material queue override.
+ public int RawRenderQueue { get; }
+
+ /// Gets whether the Opaque keyword is enabled.
+ public bool EnableOpaqueKeyword { get; }
+
+ /// Gets whether the Transparent keyword is enabled.
+ public bool EnableTransparentKeyword { get; }
+
+ /// Gets whether ShadowCaster and Meta are enabled.
+ public bool EnableContributionPasses { get; }
+ }
+
+ /// Groups the boolean rendering-mode flags for immutable state construction.
+ private readonly struct ModeStateFlags
+ {
+ /// Initializes the immutable rendering-mode flags.
+ /// Whether the Opaque keyword is enabled.
+ /// Whether the Transparent keyword is enabled.
+ /// Whether ShadowCaster and Meta are enabled.
+ public ModeStateFlags(bool enableOpaqueKeyword, bool enableTransparentKeyword, bool enableContributionPasses)
+ {
+ EnableOpaqueKeyword = enableOpaqueKeyword;
+ EnableTransparentKeyword = enableTransparentKeyword;
+ EnableContributionPasses = enableContributionPasses;
+ }
+
+ /// Gets whether the Opaque keyword is enabled.
+ public bool EnableOpaqueKeyword { get; }
+
+ /// Gets whether the Transparent keyword is enabled.
+ public bool EnableTransparentKeyword { get; }
+
+ /// Gets whether ShadowCaster and Meta are enabled.
+ public bool EnableContributionPasses { get; }
+ }
+
+ /// Captures every field that the normalizer may modify for rollback.
+ private readonly struct MaterialStateSnapshot
+ {
+ /// Initializes the immutable rollback snapshot.
+ /// The prior base-pass source blend factor.
+ /// The prior base-pass destination blend factor.
+ /// The prior depth-write state.
+ /// The prior additive-pass source blend factor.
+ /// The prior additive-pass destination blend factor.
+ /// The raw tag, queue, pass, keyword, and dirty-state metadata.
+ private MaterialStateSnapshot(
+ float sourceBlend,
+ float destinationBlend,
+ float depthWrite,
+ float additiveSourceBlend,
+ float additiveDestinationBlend,
+ MaterialStateSnapshotMetadata metadata)
+ {
+ SourceBlend = sourceBlend;
+ DestinationBlend = destinationBlend;
+ DepthWrite = depthWrite;
+ AdditiveSourceBlend = additiveSourceBlend;
+ AdditiveDestinationBlend = additiveDestinationBlend;
+ HasRenderTypeOverride = metadata.HasRenderTypeOverride;
+ RenderTypeOverride = metadata.RenderTypeOverride;
+ RawRenderQueue = metadata.RawRenderQueue;
+ OpaqueKeywordEnabled = metadata.OpaqueKeywordEnabled;
+ TransparentKeywordEnabled = metadata.TransparentKeywordEnabled;
+ ShadowCasterEnabled = metadata.ShadowCasterEnabled;
+ MetaEnabled = metadata.MetaEnabled;
+ WasDirty = metadata.WasDirty;
+ }
+
+ /// Gets the prior base-pass source blend factor.
+ private float SourceBlend { get; }
+
+ /// Gets the prior base-pass destination blend factor.
+ private float DestinationBlend { get; }
+
+ /// Gets the prior depth-write state.
+ private float DepthWrite { get; }
+
+ /// Gets the prior additive-pass source blend factor.
+ private float AdditiveSourceBlend { get; }
+
+ /// Gets the prior additive-pass destination blend factor.
+ private float AdditiveDestinationBlend { get; }
+
+ /// Gets whether a prior RenderType override existed in the raw tag map.
+ private bool HasRenderTypeOverride { get; }
+
+ /// Gets the prior raw RenderType override value.
+ private string RenderTypeOverride { get; }
+
+ /// Gets the prior raw material queue override.
+ private int RawRenderQueue { get; }
+
+ /// Gets whether the Opaque keyword was enabled.
+ private bool OpaqueKeywordEnabled { get; }
+
+ /// Gets whether the Transparent keyword was enabled.
+ private bool TransparentKeywordEnabled { get; }
+
+ /// Gets whether ShadowCaster was enabled.
+ private bool ShadowCasterEnabled { get; }
+
+ /// Gets whether Meta was enabled.
+ private bool MetaEnabled { get; }
+
+ /// Gets whether the material was dirty before normalization.
+ private bool WasDirty { get; }
+
+ /// Captures the normalizer-owned state from one material.
+ /// The material to capture.
+ /// A rollback snapshot for .
+ public static MaterialStateSnapshot Capture(Material material)
+ {
+ bool hasRenderTypeOverride = TryGetRawRenderTypeOverride(material, out string renderTypeOverride);
+ return new MaterialStateSnapshot(
+ material.GetFloat(SourceBlendPropertyName),
+ material.GetFloat(DestinationBlendPropertyName),
+ material.GetFloat(DepthWritePropertyName),
+ material.GetFloat(AdditiveSourceBlendPropertyName),
+ material.GetFloat(AdditiveDestinationBlendPropertyName),
+ new MaterialStateSnapshotMetadata(
+ hasRenderTypeOverride,
+ renderTypeOverride,
+ GetRawRenderQueue(material),
+ material.IsKeywordEnabled(OpaqueKeyword),
+ material.IsKeywordEnabled(TransparentKeyword),
+ material.GetShaderPassEnabled(ShadowCasterPassName),
+ material.GetShaderPassEnabled(MetaPassName),
+ EditorUtility.IsDirty(material)
+ )
+ );
+ }
+
+ /// Restores the normalizer-owned state to one material.
+ /// The material to restore.
+ public void Restore(Material material)
+ {
+ material.SetFloat(SourceBlendPropertyName, SourceBlend);
+ material.SetFloat(DestinationBlendPropertyName, DestinationBlend);
+ material.SetFloat(DepthWritePropertyName, DepthWrite);
+ material.SetFloat(AdditiveSourceBlendPropertyName, AdditiveSourceBlend);
+ material.SetFloat(AdditiveDestinationBlendPropertyName, AdditiveDestinationBlend);
+ material.SetOverrideTag(RenderTypeTagName, HasRenderTypeOverride ? RenderTypeOverride : string.Empty);
+ material.renderQueue = RawRenderQueue;
+ SetKeyword(material, OpaqueKeyword, OpaqueKeywordEnabled);
+ SetKeyword(material, TransparentKeyword, TransparentKeywordEnabled);
+ material.SetShaderPassEnabled(ShadowCasterPassName, ShadowCasterEnabled);
+ material.SetShaderPassEnabled(MetaPassName, MetaEnabled);
+ if (!WasDirty)
+ EditorUtility.ClearDirty(material);
+ }
+ }
+
+ /// Groups the remaining rollback values for immutable snapshot construction.
+ private readonly struct MaterialStateSnapshotMetadata
+ {
+ /// Initializes the immutable rollback metadata.
+ /// Whether a prior RenderType override existed in the raw tag map.
+ /// The prior raw RenderType override value.
+ /// The prior raw material queue override.
+ /// Whether the Opaque keyword was enabled.
+ /// Whether the Transparent keyword was enabled.
+ /// Whether ShadowCaster was enabled.
+ /// Whether Meta was enabled.
+ /// Whether the material was dirty before normalization.
+ public MaterialStateSnapshotMetadata(
+ bool hasRenderTypeOverride,
+ string renderTypeOverride,
+ int rawRenderQueue,
+ bool opaqueKeywordEnabled,
+ bool transparentKeywordEnabled,
+ bool shadowCasterEnabled,
+ bool metaEnabled,
+ bool wasDirty)
+ {
+ HasRenderTypeOverride = hasRenderTypeOverride;
+ RenderTypeOverride = renderTypeOverride;
+ RawRenderQueue = rawRenderQueue;
+ OpaqueKeywordEnabled = opaqueKeywordEnabled;
+ TransparentKeywordEnabled = transparentKeywordEnabled;
+ ShadowCasterEnabled = shadowCasterEnabled;
+ MetaEnabled = metaEnabled;
+ WasDirty = wasDirty;
+ }
+
+ /// Gets whether a prior RenderType override existed in the raw tag map.
+ public bool HasRenderTypeOverride { get; }
+
+ /// Gets the prior raw RenderType override value.
+ public string RenderTypeOverride { get; }
+
+ /// Gets the prior raw material queue override.
+ public int RawRenderQueue { get; }
+
+ /// Gets whether the Opaque keyword was enabled.
+ public bool OpaqueKeywordEnabled { get; }
+
+ /// Gets whether the Transparent keyword was enabled.
+ public bool TransparentKeywordEnabled { get; }
+
+ /// Gets whether ShadowCaster was enabled.
+ public bool ShadowCasterEnabled { get; }
+
+ /// Gets whether Meta was enabled.
+ public bool MetaEnabled { get; }
+
+ /// Gets whether the material was dirty before normalization.
+ public bool WasDirty { get; }
+ }
+
+ /// Reads the raw RenderType override presence and value without resolving shader fallback tags.
+ /// The material whose serialized tag map is read.
+ /// Receives the raw override value when one exists.
+ /// when the material serializes an explicit RenderType override.
+ private static bool TryGetRawRenderTypeOverride(Material material, out string renderTypeOverride)
+ {
+ string serializedMaterial = EditorJsonUtility.ToJson(material);
+ Match tagMap = Regex.Match(serializedMaterial, @"""stringTagMap""\s*:\s*\{(?[^}]*)\}");
+ if (!tagMap.Success)
+ throw new InvalidOperationException("The material does not expose a serialized raw RenderType tag map.");
+
+ Match renderType = Regex.Match(tagMap.Groups["entries"].Value, @"""RenderType""\s*:\s*""(?[^""]*)""");
+ renderTypeOverride = renderType.Success ? renderType.Groups["value"].Value : null;
+ return renderType.Success;
+ }
+
+ /// Reads the serialized raw render queue without resolving the shader-default queue.
+ /// The material whose raw queue is read.
+ /// The serialized raw render queue.
+ private static int GetRawRenderQueue(Material material)
+ {
+ using (var serializedMaterial = new SerializedObject(material))
+ {
+ SerializedProperty rawRenderQueue = serializedMaterial.FindProperty("m_CustomRenderQueue");
+ if (rawRenderQueue == null)
+ throw new InvalidOperationException("The material does not expose a serialized raw render queue.");
+
+ return rawRenderQueue.intValue;
+ }
+ }
+ }
+}
diff --git a/Editor/PureBaseRenderingMode.cs.meta b/Editor/PureBaseRenderingMode.cs.meta
new file mode 100644
index 00000000..98918894
--- /dev/null
+++ b/Editor/PureBaseRenderingMode.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: cc7b12e3c6689d64993bb929241af70b
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Editor/PureBaseRenderingModeElement.cs b/Editor/PureBaseRenderingModeElement.cs
new file mode 100644
index 00000000..0937e07c
--- /dev/null
+++ b/Editor/PureBaseRenderingModeElement.cs
@@ -0,0 +1,300 @@
+/*
+ * 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.
+ */
+
+// Provides the Pure-Base rendering-mode Inspector popup and its explicit material-edit boundary.
+
+using System;
+using System.Collections.Generic;
+using jp.lilxyzw.shadercore;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UIElements;
+using SCMaterialProperty = jp.lilxyzw.shadercore.MaterialProperty;
+
+namespace PureBase.Editor
+{
+ /// Renders and applies the supported Pure-Base material rendering modes.
+ internal sealed class PureBaseRenderingModeElement : PopupField, IMaterialPropertyElement
+ {
+ /// Identifies the rendering-mode selector property.
+ private const string RenderingModePropertyName = "_RenderingMode";
+
+ /// Identifies the single Undo operation created by one popup action.
+ private const string UndoName = "Set PureBase Rendering Mode";
+
+ /// Explains the derived state of one Transparent material selection.
+ private const string TransparentDescription = "Transparent materials use alpha blending. ZWrite, ShadowCaster, and Meta are disabled.";
+
+ /// Explains the derived state when a mixed selection includes Transparent materials.
+ private const string MixedTransparentDescription = "One or more selected materials are Transparent. Those materials use alpha blending, and their ZWrite, ShadowCaster, and Meta are disabled.";
+
+ /// Defines the mode values in their popup display order.
+ private static readonly List ModeValues = new List
+ {
+ (int)PureBaseRenderingMode.Opaque,
+ (int)PureBaseRenderingMode.Cutout,
+ (int)PureBaseRenderingMode.Transparent,
+ };
+
+ /// Defines the stable English mode labels used by the selection model.
+ private static readonly string[] ModeNames =
+ {
+ "Opaque",
+ "Cutout",
+ "Transparent",
+ };
+
+ /// Stores the material property currently represented by this field.
+ public SCMaterialProperty Property { get; set; }
+
+ /// Stores the Shader-Core localization module identity.
+ public string ModuleID { get; set; }
+
+ /// Stores the localized Inspector label.
+ public string LocalizedLabel { get; set; }
+
+ /// Gets the popup and help-box root inserted into Shader-Core's property container.
+ private VisualElement Root { get; }
+
+ /// Gets the help box shown for a single Transparent selection.
+ private HelpBox TransparentHelpBox { get; }
+
+ /// Gets the help box shown for a mixed selection containing Transparent materials.
+ private HelpBox MixedTransparentHelpBox { get; }
+
+ /// Stores localized labels for the popup choices.
+ private List localizedModeNames;
+
+ /// Registers the rendering-mode drawer with Shader-Core when the Editor domain loads.
+ [InitializeOnLoadMethod]
+ private static void RegisterDrawer()
+ {
+ AttributeActions.AddDrawer("PureBaseRenderingMode", Draw);
+ }
+
+ /// Adds the rendering-mode popup to one Shader-Core property container.
+ /// The unused Shader-Core material editor.
+ /// The rendering-mode material property.
+ /// Unused drawer arguments.
+ /// The property container that owns the drawer UI.
+ private static void Draw(SCMaterialEditor _, SCMaterialProperty property, string arguments, VisualElement container)
+ {
+ var element = new PureBaseRenderingModeElement(property);
+ container.Add(element.Root);
+ }
+
+ /// Initializes a PopupField-based rendering-mode drawer without normalizing material state.
+ /// The rendering-mode material property represented by this element.
+ public PureBaseRenderingModeElement(SCMaterialProperty property)
+ {
+ localizedModeNames = CreateLocalizedModeNames();
+ choices = ModeValues;
+ formatListItemCallback = GetModeLabel;
+ formatSelectedValueCallback = GetModeLabel;
+
+ TransparentHelpBox = new HelpBox(SCL10n.L(TransparentDescription), HelpBoxMessageType.Info);
+ MixedTransparentHelpBox = new HelpBox(SCL10n.L(MixedTransparentDescription), HelpBoxMessageType.Info);
+ Root = new VisualElement();
+ Root.Add(this);
+ Root.Add(TransparentHelpBox);
+ Root.Add(MixedTransparentHelpBox);
+
+ ((IMaterialPropertyElement)this).InitializeVisualElement(this, UpdateUI, property);
+ SCStyles.ApplyPopupStyle(this);
+ style.flexGrow = 0;
+
+ RegisterCallback>(eventData =>
+ {
+ Material[] materials = GetPureBaseMaterials(Property.targets);
+ ApplySelection(materials, eventData.newValue);
+ UpdateUI();
+ });
+ RegisterCallback(_ => UpdateLocalizedText());
+ }
+
+ /// Applies one selected mode to every validated material as a single Undo operation.
+ /// The selected Pure-Base materials to update.
+ /// The requested supported rendering-mode value.
+ internal static void ApplySelection(Material[] materials, int mode)
+ {
+ if (materials == null)
+ throw new ArgumentNullException(nameof(materials));
+ if (mode < (int)PureBaseRenderingMode.Opaque || mode > (int)PureBaseRenderingMode.Transparent)
+ throw new ArgumentOutOfRangeException(nameof(mode), mode, "The rendering mode must be Opaque, Cutout, or Transparent.");
+
+ for (int index = 0; index < materials.Length; index++)
+ PureBaseMaterialRenderingMode.Validate(materials[index]);
+
+ if (materials.Length == 0)
+ return;
+
+ Undo.IncrementCurrentGroup();
+ int undoGroup = Undo.GetCurrentGroup();
+ Undo.SetCurrentGroupName(UndoName);
+ Undo.RecordObjects(materials, UndoName);
+ for (int index = 0; index < materials.Length; index++)
+ materials[index].SetInteger(RenderingModePropertyName, mode);
+
+ PureBaseMaterialRenderingMode.ApplyAll(materials);
+ Undo.CollapseUndoOperations(undoGroup);
+ SCUpdateEvent.Invoke();
+ }
+
+ /// Reads the supplied selection without applying or normalizing its material state.
+ /// The selected material targets.
+ internal static void RefreshSelection(Material[] materials)
+ {
+ GetSelectionDisplayState(materials);
+ }
+
+ /// Gets the read-only popup state for the supplied material selection.
+ /// The selected material targets.
+ /// The selected value, mixed state, and stable popup labels.
+ internal static SelectionDisplayState GetSelectionDisplayState(Material[] materials)
+ {
+ if (materials == null)
+ throw new ArgumentNullException(nameof(materials));
+
+ int selectedValue = (int)PureBaseRenderingMode.Cutout;
+ bool hasMixedValue = false;
+ bool containsTransparent = false;
+ if (materials.Length > 0)
+ {
+ selectedValue = GetDisplayModeValue(materials[0]);
+ containsTransparent = selectedValue == (int)PureBaseRenderingMode.Transparent;
+ for (int index = 1; index < materials.Length; index++)
+ {
+ int value = GetDisplayModeValue(materials[index]);
+ hasMixedValue |= value != selectedValue;
+ containsTransparent |= value == (int)PureBaseRenderingMode.Transparent;
+ }
+ }
+
+ return new SelectionDisplayState(selectedValue, hasMixedValue, containsTransparent, ModeNames);
+ }
+
+ /// Determines whether one material uses a stable Pure-Base shader.
+ /// The material to inspect.
+ /// when the material uses one of the four supported shader names.
+ internal static bool IsPureBaseMaterial(Material material)
+ {
+ return PureBaseMaterialRenderingMode.IsPureBaseMaterial(material);
+ }
+
+ /// Updates the popup and help-box state from the current material values without writing them.
+ public void UpdateUI()
+ {
+ Material[] materials = GetPureBaseMaterials(Property.targets);
+ SelectionDisplayState displayState = GetSelectionDisplayState(materials);
+ showMixedValue = displayState.HasMixedValue;
+ SetValueWithoutNotify(displayState.SelectedValue);
+ textElement.text = GetModeLabel(displayState.SelectedValue);
+ TransparentHelpBox.style.display = !displayState.HasMixedValue && displayState.SelectedValue == (int)PureBaseRenderingMode.Transparent
+ ? DisplayStyle.Flex
+ : DisplayStyle.None;
+ MixedTransparentHelpBox.style.display = displayState.HasMixedValue && displayState.ContainsTransparent
+ ? DisplayStyle.Flex
+ : DisplayStyle.None;
+ }
+
+ /// Creates localized labels for the fixed rendering-mode names.
+ /// Localized labels in popup display order.
+ private static List CreateLocalizedModeNames()
+ {
+ var names = new List(ModeNames.Length);
+ for (int index = 0; index < ModeNames.Length; index++)
+ names.Add(SCL10n.L(ModeNames[index]));
+
+ return names;
+ }
+
+ /// Filters an arbitrary shared property target set to stable Pure-Base materials.
+ /// The targets associated with one material property.
+ /// Only targets supported by the Pure-Base rendering-mode contract.
+ private static Material[] GetPureBaseMaterials(UnityEngine.Object[] targets)
+ {
+ var materials = new List(targets.Length);
+ for (int index = 0; index < targets.Length; index++)
+ {
+ if (targets[index] is Material material && IsPureBaseMaterial(material))
+ materials.Add(material);
+ }
+
+ return materials.ToArray();
+ }
+
+ /// Returns a supported popup value without mutating malformed stored data.
+ /// The material whose current selector value is read.
+ /// The stored mode when supported; otherwise the non-mutating Cutout fallback.
+ private static int GetDisplayModeValue(Material material)
+ {
+ int mode = material.HasProperty(RenderingModePropertyName)
+ ? material.GetInteger(RenderingModePropertyName)
+ : (int)PureBaseRenderingMode.Cutout;
+ return mode >= (int)PureBaseRenderingMode.Opaque && mode <= (int)PureBaseRenderingMode.Transparent
+ ? mode
+ : (int)PureBaseRenderingMode.Cutout;
+ }
+
+ /// Gets the localized display label for one popup value.
+ /// The rendering-mode value to label.
+ /// The localized label, or an empty string for an unsupported value.
+ private string GetModeLabel(int value)
+ {
+ int index = ModeValues.IndexOf(value);
+ return index >= 0 ? localizedModeNames[index] : string.Empty;
+ }
+
+ /// Refreshes localized labels and help text without changing any material.
+ private void UpdateLocalizedText()
+ {
+ SCL10n.Load(ModuleID);
+ localizedModeNames = CreateLocalizedModeNames();
+ TransparentHelpBox.text = SCL10n.L(TransparentDescription);
+ MixedTransparentHelpBox.text = SCL10n.L(MixedTransparentDescription);
+ UpdateUI();
+ }
+
+ /// Represents the read-only Inspector state of one material selection.
+ internal readonly struct SelectionDisplayState
+ {
+ /// Initializes a rendering-mode selection display state.
+ /// The non-mixed popup value.
+ /// Whether selected materials have different modes.
+ /// Whether any selected material is Transparent.
+ /// The stable labels displayed by the popup.
+ public SelectionDisplayState(int selectedValue, bool hasMixedValue, bool containsTransparent, IReadOnlyList choices)
+ {
+ SelectedValue = selectedValue;
+ HasMixedValue = hasMixedValue;
+ ContainsTransparent = containsTransparent;
+ Choices = choices;
+ }
+
+ /// Gets the selected value used when the field is not mixed.
+ public int SelectedValue { get; }
+
+ /// Gets whether the selection contains multiple rendering-mode values.
+ public bool HasMixedValue { get; }
+
+ /// Gets whether any selected material uses Transparent mode.
+ public bool ContainsTransparent { get; }
+
+ /// Gets the exact ordered popup labels.
+ public IReadOnlyList Choices { get; }
+ }
+ }
+}
diff --git a/Editor/PureBaseRenderingModeElement.cs.meta b/Editor/PureBaseRenderingModeElement.cs.meta
new file mode 100644
index 00000000..863e2188
--- /dev/null
+++ b/Editor/PureBaseRenderingModeElement.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 38fee00bbebbb024ea91e49ac643ac6f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/README.ja.md b/README.ja.md
index 5bf6d0d9..6e0f2fa8 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -28,7 +28,7 @@ limitations under the License.
[](https://github.com/Penguin-Repository/Pure-Base/actions/workflows/release-validation.yml)
[](https://github.com/Penguin-Repository/Pure-Base/actions/workflows/release.yml)
-Pure Base は、Shader-Core で使える4種類の基本シェーダーをまとめた Unity 向けパッケージです。
+Pure Base `0.2.0-beta.1` は、Shader-Core で使える4種類の基本シェーダーをまとめた Unity 向けパッケージです。
複雑な機能を最初から大量に備えるのではなく、必要な機能を Shader-Core の追加モジュールで組み合わせて使うための、軽くて分かりやすい土台を目指しています。
@@ -56,7 +56,7 @@ Pure Base には、用途の異なる4つのシェーダーが含まれていま
- Built-in Render Pipeline
- Shader-Core 0.1.9
-URPと半透明のマテリアルには対応していません。透明部分は切り抜き方式で表示します。
+URPには対応していません。Opaque、Cutout、Transparent の描画モードを利用でき、初期状態は Cutout です。
## 導入方法
@@ -87,23 +87,36 @@ https://lilxyzw.github.io/vpm-repos/vpm.json
3. 追加する版を選び、プロジェクトへ導入します。
4. Shader-Core 0.1.9 が一緒に導入されることを確認します。
-現在は開発版のため、管理ソフトの設定によっては一覧に表示されない場合があります。その場合は、開発版やプレリリースを表示する設定を有効にしてください。
+このREADMEが対象とするパッケージ版は `0.2.0-beta.1` です。
## 基本的な使い方
1. Unityで新しいマテリアルを作成します。
2. マテリアルのシェーダーから `PureBase` を選びます。
3. 用途に合わせて `Unlit`、`Toon`、`PBR`、`Hybrid` のいずれかを選びます。
-4. 基本色やテクスチャなどを設定します。
-5. 必要に応じて Shader-Core の追加モジュールを組み合わせます。
+4. 描画モードで Opaque、Cutout、Transparent のいずれかを選びます。初期状態は Cutout です。
+5. 基本色やテクスチャなどを設定します。
+6. 必要に応じて Shader-Core の追加モジュールを組み合わせます。
最初に迷った場合は、アニメ調なら `Toon`、一般的な質感なら `PBR` が分かりやすい選択です。
+## 描画モード
+
+`_RenderingMode` は ShaderLab の `Integer` で、値は `Opaque=0`、`Cutout=1`(初期値)、`Transparent=2` です。
+
+| モード | 動作 |
+| --- | --- |
+| Opaque | 切り抜きとブレンドを行いません。キューは `2000`、`ZWrite 1` です。 |
+| Cutout | 被覆を切り抜きます。キューは `AlphaTest 2450` に解決され、モードキーワードを使いません。 |
+| Transparent | 深度を書き込まずにアルファブレンドします。キューは `3000` で、`ShadowCaster` と `Meta` は無効です。 |
+
+エディターから明示的にモードを適用するには `PureBaseMaterialRenderingMode.Apply(Material)` を使います。選択中のマテリアルには `Assets/PureBase/Resync Rendering Mode` を使えます。Inspector を開いたり更新したりするだけでは、旧形式のマテリアルを移行したり変更済みにしたりしません。実行時の切り替えは保証せず、カスタムキューは次にモードを明示的に編集または Resync するまで維持します。
+
## 注意点
- Pure Base 本体は、できるだけ小さく保つ方針です。
- リムライト、MatCap、発光、ディゾルブなどの追加表現は、別の Shader-Core モジュールで補う想定です。
-- 正式版ではない版では、仕様や使い方が変更される可能性があります。
+- 描画モードとパスの完全な契約は、[Pure-Base シェーダー契約](Docs/pure-base-shader-contract.md)に記載しています。
- 不具合を報告する際は、使用したUnity、Pure Base、Shader-Coreの版を記載してください。
## 詳しい資料
@@ -242,7 +255,7 @@ URPは?
半透明マテリアルは?
-対応していない!!
+Transparent モードで対応している!!
透明部分はどうする!?
@@ -367,7 +380,7 @@ Pure Base は隠れているんじゃない。
- Pure Base 本体は、できるだけ小さく保つ方針です。
- リムライト、MatCap、発光、ディゾルブなどの追加表現は、別の Shader-Core モジュールで補う想定です。
-- 正式版ではない版では、仕様や使い方が変更される可能性があります。
+- 0.2.0-beta.1 の描画モード契約は、仕様と使い方を確認してから使ってください。
- 不具合を報告する際は、使用したUnity、Pure Base、Shader-Coreの版を記載してください。
なぜ小さく保つ!?
@@ -388,7 +401,7 @@ MatCapが欲しい?
一つの巨大な塊にするな。
必要な力を、必要な場所へ組み合わせろ!!
-そして忘れるな。これは正式版ではない版を含む! 仕様や使い方が変わる可能性がある!
+そして忘れるな。Opaque、Cutout、Transparent の描画モードがある! 仕様と使い方を確認して使え!
変化を恐れるな。
diff --git a/README.md b/README.md
index 23f0323c..b6927379 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ Language: [日本語](README.ja.md)
[](https://github.com/Penguin-Repository/Pure-Base/actions/workflows/release-validation.yml)
[](https://github.com/Penguin-Repository/Pure-Base/actions/workflows/release.yml)
-Pure Base is a Unity package that provides four base shaders for Shader-Core.
+Pure Base `0.2.0-beta.1` is a Unity package that provides four base shaders for Shader-Core.
Instead of including a large collection of optional effects, it provides a small and understandable foundation that can be extended with Shader-Core modules when needed.
@@ -54,7 +54,7 @@ Every shader can be used without installing an optional module.
- Built-in Render Pipeline
- Shader-Core 0.1.9
-URP and transparent material blending are not supported. Transparent areas use Cutout rendering.
+URP is not supported. Opaque, Cutout, and Transparent rendering modes are available; Cutout is the default.
## Installation
@@ -85,23 +85,36 @@ https://lilxyzw.github.io/vpm-repos/vpm.json
3. Select the version you want and add it to the project.
4. Confirm that Shader-Core 0.1.9 is installed with it.
-Pure Base is currently distributed as a prerelease. Some package managers hide prerelease packages by default, so you may need to enable prerelease or development-version visibility.
+The package version described here is `0.2.0-beta.1`.
## Basic use
1. Create a new material in Unity.
2. Open the material's shader menu and select `PureBase`.
3. Choose `Unlit`, `Toon`, `PBR`, or `Hybrid` for the intended look.
-4. Set the base color, texture, and other available properties.
-5. Add Shader-Core modules when additional effects are needed.
+4. Choose Opaque, Cutout, or Transparent in the rendering-mode setting. Cutout is the default.
+5. Set the base color, texture, and other available properties.
+6. Add Shader-Core modules when additional effects are needed.
For a simple starting point, choose `Toon` for anime-style materials or `PBR` for general-purpose materials.
+## Rendering modes
+
+The `_RenderingMode` property is a ShaderLab `Integer` with `Opaque=0`, `Cutout=1` (default), and `Transparent=2`.
+
+| Mode | Behavior |
+| --- | --- |
+| Opaque | Uncut and unblended; queue `2000`, `ZWrite 1`. |
+| Cutout | Clips coverage; queue resolves to `AlphaTest 2450`, with no mode keyword. |
+| Transparent | Alpha-blends without depth writing; queue `3000`, with `ShadowCaster` and `Meta` disabled. |
+
+To apply the mode explicitly in the editor, use `PureBaseMaterialRenderingMode.Apply(Material)`. For selected materials, use `Assets/PureBase/Resync Rendering Mode`. Opening or refreshing the Inspector does not migrate or dirty legacy materials. Runtime switching is not guaranteed, and a custom queue remains until the next explicit mode edit or Resync.
+
## Notes
- Pure Base is intentionally kept small.
- Effects such as rim lighting, MatCap, emission, and dissolve are expected to be supplied by separate Shader-Core modules.
-- Behavior and usage may change while the package is in prerelease.
+- The complete rendering-mode and pass contract is documented in [Pure-Base Shader Contract](Docs/pure-base-shader-contract.md).
- When reporting a problem, include the Unity, Pure Base, and Shader-Core versions you used.
## Technical documentation
diff --git a/Shaders/Common/birp_host.hlsl b/Shaders/Common/birp_host.hlsl
index 5c5ee2f6..fc17b098 100644
--- a/Shaders/Common/birp_host.hlsl
+++ b/Shaders/Common/birp_host.hlsl
@@ -19,6 +19,8 @@
#ifndef PUREBASE_BIRP_HOST_INCLUDED
#define PUREBASE_BIRP_HOST_INCLUDED
+#include "Packages/jp.penguin.purebase/Shaders/Common/rendering_mode.hlsl"
+
/// Accumulates a BIRP light after the Shader-Core per-light phase.
void SCCalculateLight(inout SCLightData lightSum, inout SCShadingData sd, inout SCCustomData cd, SCVertexData vertex, SCLightData light)
{
@@ -89,7 +91,7 @@ half4 frag(v2f input, bool isFront : SV_IsFrontFace) : SV_Target
__SC_PHASE_add__
sd.col.rgb += sd.add + sd.postadd;
- sd.col.a = 1;
+ PureBaseApplyRenderingModeOutputAlpha(sd.col, coverage);
#if defined(UNITY_PASS_FORWARDADD)
UNITY_APPLY_FOG_COLOR(input.fogCoord, sd.col, fixed4(0, 0, 0, 0));
#else
@@ -101,4 +103,4 @@ half4 frag(v2f input, bool isFront : SV_IsFrontFace) : SV_Target
return sd.col;
}
-#endif
\ No newline at end of file
+#endif
diff --git a/Shaders/Common/rendering_mode.hlsl b/Shaders/Common/rendering_mode.hlsl
new file mode 100644
index 00000000..7693592e
--- /dev/null
+++ b/Shaders/Common/rendering_mode.hlsl
@@ -0,0 +1,40 @@
+/*
+ * 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 shared rendering-mode coverage and output-alpha contracts for Pure-Base hosts.
+
+#ifndef PUREBASE_RENDERING_MODE_INCLUDED
+#define PUREBASE_RENDERING_MODE_INCLUDED
+
+/// Applies the Cutout coverage threshold only when neither opaque nor transparent mode is selected.
+void PureBaseApplyRenderingModeClip(half coverage)
+{
+ #if !defined(PUREBASE_RENDERING_OPAQUE) && !defined(PUREBASE_RENDERING_TRANSPARENT)
+ clip(coverage - _Cutoff);
+ #endif
+}
+
+/// Writes coverage alpha for Transparent and opaque alpha for Opaque and Cutout output.
+void PureBaseApplyRenderingModeOutputAlpha(inout half4 color, half coverage)
+{
+ #if defined(PUREBASE_RENDERING_TRANSPARENT)
+ color.a = coverage;
+ #else
+ color.a = 1;
+ #endif
+}
+
+#endif
diff --git a/Shaders/Common/rendering_mode.hlsl.meta b/Shaders/Common/rendering_mode.hlsl.meta
new file mode 100644
index 00000000..008f2e65
--- /dev/null
+++ b/Shaders/Common/rendering_mode.hlsl.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 8dfe409dccb839945803d014b1c305bc
+ShaderIncludeImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Shaders/Common/surface.hlsl b/Shaders/Common/surface.hlsl
index d8979c34..faab98ad 100644
--- a/Shaders/Common/surface.hlsl
+++ b/Shaders/Common/surface.hlsl
@@ -14,11 +14,13 @@
* limitations under the License.
*/
-// Defines deterministic Shader-Core surface initialization and module-compatible Cutout coverage.
+// Defines deterministic Shader-Core surface initialization and module-compatible rendering-mode coverage.
#ifndef PUREBASE_SURFACE_INCLUDED
#define PUREBASE_SURFACE_INCLUDED
+#include "Packages/jp.penguin.purebase/Shaders/Common/rendering_mode.hlsl"
+
/// Initializes every shared shading field and executes the sole base phase insertion point.
void SCInitializeSurface(inout SCShadingData sd, out half coverage, SCVertexData vertex)
{
@@ -57,10 +59,10 @@ void SCBuildWorldTangentBasis(inout SCShadingData sd, SCVertexData vertex)
sd.B = normalize(cross(sd.N_detail, sd.T) * vertex.crossDirection * SCTangentScale());
}
-/// Discards pixels below the module-adjusted Cutout coverage threshold.
+/// Applies the selected rendering mode to module-adjusted surface coverage.
void SCClipCutoutCoverage(half coverage)
{
- clip(coverage - _Cutoff);
+ PureBaseApplyRenderingModeClip(coverage);
}
-#endif
\ No newline at end of file
+#endif
diff --git a/Shaders/PureBaseHybrid.scshader b/Shaders/PureBaseHybrid.scshader
index 4d7d250a..c37d8d2a 100644
--- a/Shaders/PureBaseHybrid.scshader
+++ b/Shaders/PureBaseHybrid.scshader
@@ -20,11 +20,17 @@ Shader "PureBase/Hybrid"
Properties
{
__SC_SHADERLAB_properties__
+ [HideInInspector] _SrcBlend ("", Float) = 1
+ [HideInInspector] _DstBlend ("", Float) = 0
+ [HideInInspector] _ZWrite ("", Float) = 1
+ [HideInInspector] _AddSrcBlend ("", Float) = 1
+ [HideInInspector] _AddDstBlend ("", Float) = 1
[HideInInspector] _PureBaseShaderLabSentinel ("", Float) = 0
}
HLSLINCLUDE
__SC_SHADERKEYWORDS__
+ #pragma shader_feature_local _ PUREBASE_RENDERING_OPAQUE PUREBASE_RENDERING_TRANSPARENT
ENDHLSL
SubShader
@@ -36,8 +42,9 @@ Shader "PureBase/Hybrid"
Name "ForwardBase"
Tags { "LightMode" = "ForwardBase" }
Cull [_Cull]
- ZWrite On
+ ZWrite [_ZWrite]
ZTest LEqual
+ Blend [_SrcBlend] [_DstBlend]
HLSLPROGRAM
#pragma target 5.0
@@ -57,7 +64,7 @@ Shader "PureBase/Hybrid"
Cull [_Cull]
ZWrite Off
ZTest LEqual
- Blend One One
+ Blend [_AddSrcBlend] [_AddDstBlend]
ColorMask RGB
HLSLPROGRAM
@@ -106,6 +113,7 @@ Shader "PureBase/Hybrid"
#include "Packages/jp.lilxyzw.shadercore/ShaderLibrary/birp.hlsl"
#include "Common/pbr_brdf.hlsl"
__SC_BIRP_properties__
+ #include "Common/rendering_mode.hlsl"
/// Defines the bind-pose vertex and lightmap UV inputs for Unity's Meta pass.
struct PureBaseHybridMetaAppData
@@ -157,11 +165,11 @@ Shader "PureBase/Hybrid"
return output;
}
- /// Returns metallic BRDF Meta data using the fixed Cutout coverage contract.
+ /// Returns metallic BRDF Meta data using the selected rendering-mode coverage contract.
float4 PureBaseHybridMetaFragment(PureBaseHybridMetaVaryings input) : SV_Target
{
half4 albedoAlpha = SCSample(_BaseTexture, sampler_BaseTexture, input.uv) * _BaseColor;
- clip(albedoAlpha.a - _Cutoff);
+ PureBaseApplyRenderingModeClip(albedoAlpha.a);
PureBasePbrBrdfData brdf = PureBasePbrCreateBrdf(albedoAlpha.rgb, _Metallic, _Roughness);
UnityMetaInput output;
UNITY_INITIALIZE_OUTPUT(UnityMetaInput, output);
@@ -183,4 +191,4 @@ Shader "PureBase/Hybrid"
}
CustomEditor "SCMaterialEditor"
-}
\ No newline at end of file
+}
diff --git a/Shaders/PureBaseHybrid_properties.hlsl b/Shaders/PureBaseHybrid_properties.hlsl
index b7ccaba0..afdebda7 100644
--- a/Shaders/PureBaseHybrid_properties.hlsl
+++ b/Shaders/PureBaseHybrid_properties.hlsl
@@ -4,10 +4,11 @@ SC_ScaleOffset(_BaseTexture)
SC_color(_BaseColor, (1,1,1,1), [], "Base Color", "")
SC_Texture2D(_SharedMask, "white", [SCMask], "__SharedMask", "")
SC_Texture2DArray(_SharedGradients, "white", [SCGradients], "__SharedGradients", "")
-SC_float(_Cutoff, 0.5, [SCRange(-0.001,1.001)], "Cutoff", "")
+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_Texture2D(_NormalMap, "bump", [], "Normal Map", "")
SC_SamplerState(sampler_NormalMap)
SC_float(_NormalScale, 1, [SCRange(0,2)], "Normal Scale", "")
SC_float(_Metallic, 0, [SCRange(0,1)], "Metallic", "")
-SC_float(_Roughness, 0.5, [SCRange(0.002,1)], "Roughness", "")
\ No newline at end of file
+SC_float(_Roughness, 0.5, [SCRange(0.002,1)], "Roughness", "")
diff --git a/Shaders/PureBasePBR.scshader b/Shaders/PureBasePBR.scshader
index d546aeeb..26f429af 100644
--- a/Shaders/PureBasePBR.scshader
+++ b/Shaders/PureBasePBR.scshader
@@ -20,11 +20,17 @@ Shader "PureBase/PBR"
Properties
{
__SC_SHADERLAB_properties__
+ [HideInInspector] _SrcBlend ("", Float) = 1
+ [HideInInspector] _DstBlend ("", Float) = 0
+ [HideInInspector] _ZWrite ("", Float) = 1
+ [HideInInspector] _AddSrcBlend ("", Float) = 1
+ [HideInInspector] _AddDstBlend ("", Float) = 1
[HideInInspector] _PureBaseShaderLabSentinel ("", Float) = 0
}
HLSLINCLUDE
__SC_SHADERKEYWORDS__
+ #pragma shader_feature_local _ PUREBASE_RENDERING_OPAQUE PUREBASE_RENDERING_TRANSPARENT
ENDHLSL
SubShader
@@ -36,8 +42,9 @@ Shader "PureBase/PBR"
Name "ForwardBase"
Tags { "LightMode" = "ForwardBase" }
Cull [_Cull]
- ZWrite On
+ ZWrite [_ZWrite]
ZTest LEqual
+ Blend [_SrcBlend] [_DstBlend]
HLSLPROGRAM
#pragma target 5.0
@@ -57,7 +64,7 @@ Shader "PureBase/PBR"
Cull [_Cull]
ZWrite Off
ZTest LEqual
- Blend One One
+ Blend [_AddSrcBlend] [_AddDstBlend]
ColorMask RGB
HLSLPROGRAM
@@ -106,6 +113,7 @@ Shader "PureBase/PBR"
#include "Packages/jp.lilxyzw.shadercore/ShaderLibrary/birp.hlsl"
#include "Common/pbr_brdf.hlsl"
__SC_BIRP_properties__
+ #include "Common/rendering_mode.hlsl"
/// Defines the bind-pose vertex and lightmap UV inputs for Unity's Meta pass.
struct PureBasePBRMetaAppData
@@ -157,11 +165,11 @@ Shader "PureBase/PBR"
return output;
}
- /// Returns metallic BRDF Meta data using the fixed Cutout coverage contract.
+ /// Returns metallic BRDF Meta data using the selected rendering-mode coverage contract.
float4 PureBasePBRMetaFragment(PureBasePBRMetaVaryings input) : SV_Target
{
half4 albedoAlpha = SCSample(_BaseTexture, sampler_BaseTexture, input.uv) * _BaseColor;
- clip(albedoAlpha.a - _Cutoff);
+ PureBaseApplyRenderingModeClip(albedoAlpha.a);
PureBasePbrBrdfData brdf = PureBasePbrCreateBrdf(albedoAlpha.rgb, _Metallic, _Roughness);
UnityMetaInput output;
UNITY_INITIALIZE_OUTPUT(UnityMetaInput, output);
@@ -183,4 +191,4 @@ Shader "PureBase/PBR"
}
CustomEditor "SCMaterialEditor"
-}
\ No newline at end of file
+}
diff --git a/Shaders/PureBasePBR_properties.hlsl b/Shaders/PureBasePBR_properties.hlsl
index b7ccaba0..afdebda7 100644
--- a/Shaders/PureBasePBR_properties.hlsl
+++ b/Shaders/PureBasePBR_properties.hlsl
@@ -4,10 +4,11 @@ SC_ScaleOffset(_BaseTexture)
SC_color(_BaseColor, (1,1,1,1), [], "Base Color", "")
SC_Texture2D(_SharedMask, "white", [SCMask], "__SharedMask", "")
SC_Texture2DArray(_SharedGradients, "white", [SCGradients], "__SharedGradients", "")
-SC_float(_Cutoff, 0.5, [SCRange(-0.001,1.001)], "Cutoff", "")
+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_Texture2D(_NormalMap, "bump", [], "Normal Map", "")
SC_SamplerState(sampler_NormalMap)
SC_float(_NormalScale, 1, [SCRange(0,2)], "Normal Scale", "")
SC_float(_Metallic, 0, [SCRange(0,1)], "Metallic", "")
-SC_float(_Roughness, 0.5, [SCRange(0.002,1)], "Roughness", "")
\ No newline at end of file
+SC_float(_Roughness, 0.5, [SCRange(0.002,1)], "Roughness", "")
diff --git a/Shaders/PureBaseToon.scshader b/Shaders/PureBaseToon.scshader
index c1c5f07d..51049843 100644
--- a/Shaders/PureBaseToon.scshader
+++ b/Shaders/PureBaseToon.scshader
@@ -20,11 +20,17 @@ Shader "PureBase/Toon"
Properties
{
__SC_SHADERLAB_properties__
+ [HideInInspector] _SrcBlend ("", Float) = 1
+ [HideInInspector] _DstBlend ("", Float) = 0
+ [HideInInspector] _ZWrite ("", Float) = 1
+ [HideInInspector] _AddSrcBlend ("", Float) = 1
+ [HideInInspector] _AddDstBlend ("", Float) = 1
[HideInInspector] _PureBaseShaderLabSentinel ("", Float) = 0
}
HLSLINCLUDE
__SC_SHADERKEYWORDS__
+ #pragma shader_feature_local _ PUREBASE_RENDERING_OPAQUE PUREBASE_RENDERING_TRANSPARENT
ENDHLSL
SubShader
@@ -36,8 +42,9 @@ Shader "PureBase/Toon"
Name "ForwardBase"
Tags { "LightMode" = "ForwardBase" }
Cull [_Cull]
- ZWrite On
+ ZWrite [_ZWrite]
ZTest LEqual
+ Blend [_SrcBlend] [_DstBlend]
HLSLPROGRAM
#pragma target 5.0
@@ -54,7 +61,7 @@ Shader "PureBase/Toon"
Cull [_Cull]
ZWrite Off
ZTest LEqual
- Blend One One
+ Blend [_AddSrcBlend] [_AddDstBlend]
ColorMask RGB
HLSLPROGRAM
@@ -96,6 +103,7 @@ Shader "PureBase/Toon"
#include "UnityMetaPass.cginc"
#include "Packages/jp.lilxyzw.shadercore/ShaderLibrary/birp.hlsl"
__SC_BIRP_properties__
+ #include "Common/rendering_mode.hlsl"
#include "Models/toon.hlsl"
/// Defines the bind-pose vertex and lightmap UV inputs for Unity's Meta pass.
@@ -148,11 +156,11 @@ Shader "PureBase/Toon"
return output;
}
- /// Returns albedo-only Meta data using the fixed Cutout coverage contract.
+ /// Returns albedo-only Meta data using the selected rendering-mode coverage contract.
float4 PureBaseToonMetaFragment(PureBaseToonMetaVaryings input) : SV_Target
{
half4 albedoAlpha = SCSample(_BaseTexture, sampler_BaseTexture, input.uv) * _BaseColor;
- clip(albedoAlpha.a - _Cutoff);
+ PureBaseApplyRenderingModeClip(albedoAlpha.a);
UnityMetaInput output;
UNITY_INITIALIZE_OUTPUT(UnityMetaInput, output);
output.Albedo = albedoAlpha.rgb;
@@ -169,4 +177,4 @@ Shader "PureBase/Toon"
}
CustomEditor "SCMaterialEditor"
-}
\ No newline at end of file
+}
diff --git a/Shaders/PureBaseToon_properties.hlsl b/Shaders/PureBaseToon_properties.hlsl
index 9c2aa3db..57866e80 100644
--- a/Shaders/PureBaseToon_properties.hlsl
+++ b/Shaders/PureBaseToon_properties.hlsl
@@ -4,8 +4,9 @@ SC_ScaleOffset(_BaseTexture)
SC_color(_BaseColor, (1,1,1,1), [], "Base Color", "")
SC_Texture2D(_SharedMask, "white", [SCMask], "__SharedMask", "")
SC_Texture2DArray(_SharedGradients, "white", [SCGradients], "__SharedGradients", "")
-SC_float(_Cutoff, 0.5, [SCRange(-0.001,1.001)], "Cutoff", "")
+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_Texture2D(_NormalMap, "bump", [], "Normal Map", "")
SC_SamplerState(sampler_NormalMap)
-SC_float(_NormalScale, 1, [SCRange(0,2)], "Normal Scale", "")
\ No newline at end of file
+SC_float(_NormalScale, 1, [SCRange(0,2)], "Normal Scale", "")
diff --git a/Shaders/PureBaseUnlit.scshader b/Shaders/PureBaseUnlit.scshader
index 152c3c3f..b950b459 100644
--- a/Shaders/PureBaseUnlit.scshader
+++ b/Shaders/PureBaseUnlit.scshader
@@ -20,11 +20,17 @@ Shader "PureBase/Unlit"
Properties
{
__SC_SHADERLAB_properties__
+ [HideInInspector] _SrcBlend ("", Float) = 1
+ [HideInInspector] _DstBlend ("", Float) = 0
+ [HideInInspector] _ZWrite ("", Float) = 1
+ [HideInInspector] _AddSrcBlend ("", Float) = 1
+ [HideInInspector] _AddDstBlend ("", Float) = 1
[HideInInspector] _PureBaseShaderLabSentinel ("", Float) = 0
}
HLSLINCLUDE
__SC_SHADERKEYWORDS__
+ #pragma shader_feature_local _ PUREBASE_RENDERING_OPAQUE PUREBASE_RENDERING_TRANSPARENT
ENDHLSL
SubShader
@@ -36,8 +42,9 @@ Shader "PureBase/Unlit"
Name "ForwardBase"
Tags { "LightMode" = "ForwardBase" }
Cull [_Cull]
- ZWrite On
+ ZWrite [_ZWrite]
ZTest LEqual
+ Blend [_SrcBlend] [_DstBlend]
HLSLPROGRAM
#pragma target 5.0
@@ -53,7 +60,7 @@ Shader "PureBase/Unlit"
Cull [_Cull]
ZWrite Off
ZTest LEqual
- Blend One One
+ Blend [_AddSrcBlend] [_AddDstBlend]
ColorMask RGB
HLSLPROGRAM
@@ -93,6 +100,7 @@ Shader "PureBase/Unlit"
#include "UnityMetaPass.cginc"
#include "Packages/jp.lilxyzw.shadercore/ShaderLibrary/birp.hlsl"
__SC_BIRP_properties__
+ #include "Common/rendering_mode.hlsl"
#include "Models/unlit.hlsl"
/// Defines the bind-pose vertex and lightmap UV inputs for Unity's Meta pass.
@@ -145,11 +153,11 @@ Shader "PureBase/Unlit"
return output;
}
- /// Returns albedo-only Meta data using the fixed Cutout coverage contract.
+ /// Returns albedo-only Meta data using the selected rendering-mode coverage contract.
float4 PureBaseUnlitMetaFragment(PureBaseUnlitMetaVaryings input) : SV_Target
{
half4 albedoAlpha = SCSample(_BaseTexture, sampler_BaseTexture, input.uv) * _BaseColor;
- clip(albedoAlpha.a - _Cutoff);
+ PureBaseApplyRenderingModeClip(albedoAlpha.a);
UnityMetaInput output;
UNITY_INITIALIZE_OUTPUT(UnityMetaInput, output);
output.Albedo = albedoAlpha.rgb;
@@ -166,4 +174,4 @@ Shader "PureBase/Unlit"
}
CustomEditor "SCMaterialEditor"
-}
\ No newline at end of file
+}
diff --git a/Shaders/PureBaseUnlit_properties.hlsl b/Shaders/PureBaseUnlit_properties.hlsl
index 84a40da9..a11d752f 100644
--- a/Shaders/PureBaseUnlit_properties.hlsl
+++ b/Shaders/PureBaseUnlit_properties.hlsl
@@ -4,5 +4,6 @@ SC_ScaleOffset(_BaseTexture)
SC_color(_BaseColor, (1,1,1,1), [], "Base Color", "")
SC_Texture2D(_SharedMask, "white", [SCMask], "__SharedMask", "")
SC_Texture2DArray(_SharedGradients, "white", [SCGradients], "__SharedGradients", "")
-SC_float(_Cutoff, 0.5, [SCRange(-0.001,1.001)], "Cutoff", "")
-SC_float(_Cull, 2, [SCEnum(Off, 0, Front, 1, Back, 2)], "Cull", "")
\ No newline at end of file
+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", "")
diff --git a/Shaders/lang/ja-JP.po b/Shaders/lang/ja-JP.po
new file mode 100644
index 00000000..ceb77e36
--- /dev/null
+++ b/Shaders/lang/ja-JP.po
@@ -0,0 +1,38 @@
+# 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.
+
+msgid ""
+msgstr ""
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"Language: ja-JP\n"
+
+msgid "Rendering Mode"
+msgstr "レンダリングモード"
+
+msgid "Opaque"
+msgstr "不透明"
+
+msgid "Cutout"
+msgstr "カットアウト"
+
+msgid "Transparent"
+msgstr "半透明"
+
+msgid "Transparent materials use alpha blending. ZWrite, ShadowCaster, and Meta are disabled."
+msgstr "半透明マテリアルはアルファブレンドを使用します。ZWrite、ShadowCaster、Meta は無効です。"
+
+msgid "One or more selected materials are Transparent. Those materials use alpha blending, and their ZWrite, ShadowCaster, and Meta are disabled."
+msgstr "選択したマテリアルの 1 つ以上が半透明です。該当するマテリアルはアルファブレンドを使用し、ZWrite、ShadowCaster、Meta は無効です。"
diff --git a/Shaders/lang/ja-JP.po.meta b/Shaders/lang/ja-JP.po.meta
new file mode 100644
index 00000000..1124cb11
--- /dev/null
+++ b/Shaders/lang/ja-JP.po.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 3770ddebc31f79740aa247638f560375
+LocalizationImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Shaders/sc_common.hlsl b/Shaders/sc_common.hlsl
index 0bbb1d66..2c108ec8 100644
--- a/Shaders/sc_common.hlsl
+++ b/Shaders/sc_common.hlsl
@@ -38,7 +38,7 @@ void SCVertexPost(inout SCVertexData vertex, SCPositionAndDirection camera, SCPo
__SC_PHASE_postvertex__
}
-/// Evaluates immutable Cutout coverage for the Shader-Core shadow-caster wrapper.
+/// Applies mode-aware clipping to module-adjusted coverage: Opaque and Transparent do not clip, while keyword-free Cutout clips; the normalizer normally disables Transparent ShadowCaster.
void SCPixelClip(v2f input, bool isFront, float bitangentDirection)
{
SCPositionAndDirection camera = SCGetCameraData();
@@ -51,4 +51,4 @@ void SCPixelClip(v2f input, bool isFront, float bitangentDirection)
SCClipCutoutCoverage(coverage);
}
-#endif
\ No newline at end of file
+#endif
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.Atomicity.cs b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.Atomicity.cs
new file mode 100644
index 00000000..c5a82e87
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.Atomicity.cs
@@ -0,0 +1,345 @@
+/*
+ * 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 invalid-input and rollback atomicity contracts for rendering-mode normalization.
+
+// Defines the read-only material, normalizer, legacy-compatibility, and persistence contracts for rendering modes.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using System.Text.RegularExpressions;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.Rendering;
+
+
+namespace PureBase.Tests.Daily
+{
+ public sealed partial class PureBaseRenderingModeContractTests
+ {
+ /// Requires invalid public-API inputs to throw specified exceptions without changing serialized material state.
+ [Test]
+ public void InvalidNormalizerInputsAreAtomicForSingleAndMultipleTargets()
+ {
+ MethodInfo apply = RequireApplyMethod();
+ MethodInfo applyAll = RequireApplyAllMethod();
+ Assert.Throws(() => InvokeApply(apply, null));
+ var seededPropertyTypes = new HashSet();
+ var capturedPropertyTypes = new HashSet();
+ var assertedPropertyTypes = new HashSet();
+
+ var unsupportedOwnership = CreateMaterial(RequireUnsupportedRenderingModeShader());
+ AssertUnsupportedInputIsAtomic(apply, unsupportedOwnership, true, "The unsupported ownership input must expose _RenderingMode without being owned by Pure-Base.", "non-Pure-Base shader with _RenderingMode", seededPropertyTypes, capturedPropertyTypes, assertedPropertyTypes);
+
+ var unsupportedMissingProperty = CreateMaterial(RequireUnsupportedShaderWithoutRenderingMode());
+ AssertUnsupportedInputIsAtomic(apply, unsupportedMissingProperty, false, "The missing-property input must not expose _RenderingMode.", "non-Pure-Base shader without _RenderingMode", seededPropertyTypes, capturedPropertyTypes, assertedPropertyTypes);
+
+ var first = CreateMaterial(RequireProductShader("PureBase/Unlit"));
+ var second = CreateMaterial(RequireProductShader("PureBase/Toon"));
+ AssertInvalidModesAreAtomic(apply, applyAll, first, second, seededPropertyTypes, capturedPropertyTypes, assertedPropertyTypes);
+
+ foreach (Material coverageMaterial in CreateAtomicityCoverageMaterials(seededPropertyTypes, capturedPropertyTypes, assertedPropertyTypes))
+ {
+ SeedAtomicityState(coverageMaterial, seededPropertyTypes);
+ MaterialState before = MaterialState.Capture(coverageMaterial, capturedPropertyTypes);
+ Assert.Throws(() => InvokeApply(apply, coverageMaterial));
+ before.AssertEqual(coverageMaterial, "non-Pure-Base property-type coverage target", assertedPropertyTypes);
+ }
+
+ AssertCompleteAtomicityPropertyTypeCoverage(seededPropertyTypes, "seed");
+ AssertCompleteAtomicityPropertyTypeCoverage(capturedPropertyTypes, "capture");
+ AssertCompleteAtomicityPropertyTypeCoverage(assertedPropertyTypes, "assertion");
+ }
+
+ /// Asserts that one unsupported input is rejected without changing its serialized material state.
+ /// The reflected single-material normalizer method.
+ /// The unsupported material to inspect.
+ /// Whether the material is expected to expose _RenderingMode.
+ /// The assertion message for the rendering-mode property check.
+ /// The material-state assertion context.
+ /// The set that records seeded shader property types.
+ /// The set that records captured shader property types.
+ /// The set that records asserted shader property types.
+ private void AssertUnsupportedInputIsAtomic(MethodInfo apply, Material material, bool hasRenderingMode, string propertyMessage, string context, ISet seededPropertyTypes, ISet capturedPropertyTypes, ISet assertedPropertyTypes)
+ {
+ SeedAtomicityState(material, seededPropertyTypes);
+ Assert.That(material.HasProperty("_RenderingMode"), Is.EqualTo(hasRenderingMode), propertyMessage);
+ MaterialState before = MaterialState.Capture(material, capturedPropertyTypes);
+ Assert.Throws(() => InvokeApply(apply, material));
+ before.AssertEqual(material, context, assertedPropertyTypes);
+ }
+
+ /// Asserts that invalid single and batch rendering-mode values leave every target unchanged.
+ /// The reflected single-material normalizer method.
+ /// The reflected batch normalizer method.
+ /// The target whose rendering mode is invalidated.
+ /// The unaffected target used to verify batch atomicity.
+ /// The set that records seeded shader property types.
+ /// The set that records captured shader property types.
+ /// The set that records asserted shader property types.
+ private void AssertInvalidModesAreAtomic(MethodInfo apply, MethodInfo applyAll, Material first, Material second, ISet seededPropertyTypes, ISet capturedPropertyTypes, ISet assertedPropertyTypes)
+ {
+ SeedAtomicityState(first, seededPropertyTypes);
+ SeedAtomicityState(second, seededPropertyTypes);
+ EditorUtility.ClearDirty(second);
+ foreach (int invalidMode in new[] { -1, 3 })
+ {
+ first.SetInteger("_RenderingMode", invalidMode);
+ EditorUtility.ClearDirty(first);
+ MaterialState firstBefore = MaterialState.Capture(first, capturedPropertyTypes);
+ MaterialState secondBefore = MaterialState.Capture(second, capturedPropertyTypes);
+ firstBefore.AssertCapturesShaderProperty("_PureBaseShaderLabSentinel");
+ ArgumentOutOfRangeException exception = Assert.Throws(() => InvokeApply(apply, first));
+ AssertInvalidRenderingModeException(exception, first, invalidMode, "single-target invalid mode");
+ firstBefore.AssertEqual(first, $"invalid mode {invalidMode}", assertedPropertyTypes);
+ secondBefore.AssertEqual(second, $"unrelated target after invalid mode {invalidMode}", assertedPropertyTypes);
+ exception = Assert.Throws(() => InvokeApplyAll(applyAll, new[] { first, second }));
+ AssertInvalidRenderingModeException(exception, first, invalidMode, "batch invalid mode");
+ firstBefore.AssertEqual(first, $"batch invalid mode {invalidMode}", assertedPropertyTypes);
+ secondBefore.AssertEqual(second, $"unrelated target after batch invalid mode {invalidMode}", assertedPropertyTypes);
+ }
+ }
+
+ /// Requires a late batch failure to restore every already-mutated material exactly, including raw RenderType override presence.
+ [Test]
+ public void AtomicBatchRollbackRestoresRawRenderTypeOverridesAfterLateFailure()
+ {
+ MethodInfo applyAll = RequireApplyAllMethod();
+ var first = CreateMaterial(RequireProductShader("PureBase/Unlit"));
+ var second = CreateMaterial(RequireProductShader("PureBase/Toon"));
+ var failing = CreateMaterial(RequireProductShader("PureBase/PBR"));
+ SeedAtomicityState(first);
+ SeedAtomicityState(second);
+ SeedAtomicityState(failing);
+ first.SetInteger("_RenderingMode", 0);
+ second.SetInteger("_RenderingMode", 2);
+ failing.SetInteger("_RenderingMode", 1);
+ first.SetOverrideTag("RenderType", string.Empty);
+ second.SetOverrideTag("RenderType", "TransparentCutout");
+ foreach (int invalidMode in new[] { -1, 3 })
+ {
+ failing.SetInteger("_RenderingMode", 1);
+ EditorUtility.ClearDirty(first);
+ EditorUtility.ClearDirty(second);
+ EditorUtility.ClearDirty(failing);
+ AssertDistinctFallbackRenderTypeOverrideStates(first, second, "before late batch rollback");
+ MaterialState firstBefore = MaterialState.Capture(first);
+ MaterialState secondBefore = MaterialState.Capture(second);
+ var materials = new LateInvalidatingMaterialList(new[] { first, second, failing }, 2, invalidMode);
+
+ ArgumentOutOfRangeException exception = Assert.Throws(() => InvokeApplyAll(applyAll, materials));
+ AssertInvalidRenderingModeException(exception, failing, invalidMode, "late batch invalid mode");
+ Assert.That(materials.ObservedPriorMutations, Is.True, "The late invalidation must occur after prior materials are normalized.");
+ firstBefore.AssertEqual(first, "first material after late batch rollback");
+ secondBefore.AssertEqual(second, "second material after late batch rollback");
+ AssertDistinctFallbackRenderTypeOverrideStates(first, second, "after late batch rollback");
+ Assert.That(AssetDatabase.GetAssetPath(first), Is.Empty, "The rollback fixture must remain transient.");
+ Assert.That(AssetDatabase.GetAssetPath(second), Is.Empty, "The rollback fixture must remain transient.");
+ Assert.That(AssetDatabase.GetAssetPath(failing), Is.Empty, "The failure fixture must remain transient.");
+ }
+ }
+
+ /// Asserts that absent and fallback-valued RenderType overrides remain distinct serialized states.
+ /// The material whose raw RenderType override is absent.
+ /// The material whose raw override equals the shader fallback.
+ /// The operation boundary described by the assertions.
+ private static void AssertDistinctFallbackRenderTypeOverrideStates(Material withoutOverride, Material withFallbackOverride, string context)
+ {
+ bool hasAbsentOverride = TryGetSerializedRenderTypeOverride(withoutOverride, out string absentOverride);
+ bool hasFallbackOverride = TryGetSerializedRenderTypeOverride(withFallbackOverride, out string fallbackOverride);
+ Assert.That(hasAbsentOverride, Is.False, $"The {context} absent override fixture must not serialize RenderType.");
+ Assert.That(absentOverride, Is.Null, $"The {context} absent override fixture must not expose a RenderType value.");
+ Assert.That(hasFallbackOverride, Is.True, $"The {context} fallback override fixture must serialize RenderType.");
+ Assert.That(fallbackOverride, Is.EqualTo("TransparentCutout"), $"The {context} fallback override fixture must preserve its raw RenderType value.");
+ Assert.That(withoutOverride.GetTag("RenderType", false), Is.EqualTo("TransparentCutout"), $"The {context} absent override fixture must resolve the PureBase SubShader RenderType fallback.");
+ Assert.That(withFallbackOverride.GetTag("RenderType", false), Is.EqualTo("TransparentCutout"), $"The {context} fallback override fixture must resolve the same RenderType value.");
+ }
+
+ /// Assigns distinguishable values to every shader property before atomicity snapshots without modifying persistent assets.
+ /// The transient material that must remain unchanged after rejection.
+ /// The optional set that records seeded shader property types.
+ private void SeedAtomicityState(Material material, ISet observedPropertyTypes = null)
+ {
+ Shader shader = material.shader;
+ for (int index = 0; index < ShaderUtil.GetPropertyCount(shader); index++)
+ {
+ string propertyName = shader.GetPropertyName(index);
+ ShaderUtil.ShaderPropertyType propertyType = ShaderUtil.GetPropertyType(shader, index);
+ ObserveAtomicityPropertyType(observedPropertyTypes, propertyType);
+ switch (propertyType)
+ {
+ case ShaderUtil.ShaderPropertyType.Float:
+ case ShaderUtil.ShaderPropertyType.Range:
+ material.SetFloat(propertyName, 0.137f + (index * 0.019f));
+ break;
+ case ShaderUtil.ShaderPropertyType.Int:
+ material.SetInteger(propertyName, 17 + index);
+ break;
+ case ShaderUtil.ShaderPropertyType.Color:
+ material.SetColor(propertyName, new Color(0.13f + (index * 0.01f), 0.27f, 0.41f, 0.59f));
+ break;
+ case ShaderUtil.ShaderPropertyType.Vector:
+ material.SetVector(propertyName, new Vector4(0.11f, 0.23f, 0.37f, 0.53f + (index * 0.01f)));
+ break;
+ case ShaderUtil.ShaderPropertyType.TexEnv:
+ material.SetTexture(propertyName, CreateTextureSentinel(shader, index));
+ material.SetTextureScale(propertyName, new Vector2(0.71f, 0.83f));
+ material.SetTextureOffset(propertyName, new Vector2(0.17f, 0.29f));
+ break;
+ default:
+ Assert.Fail($"Unsupported shader property type '{ShaderUtil.GetPropertyType(shader, index)}' for '{propertyName}'.");
+ break;
+ }
+ }
+ }
+
+ /// Creates and tracks a transient texture matching one shader property's declared texture dimension.
+ /// The shader declaring the texture property.
+ /// The declared shader-property index.
+ /// A compatible transient texture sentinel.
+ private Texture CreateTextureSentinel(Shader shader, int propertyIndex)
+ {
+ TextureDimension dimension = shader.GetPropertyTextureDimension(propertyIndex);
+ Texture texture;
+ switch (dimension)
+ {
+ case TextureDimension.Tex2D:
+ var texture2D = new Texture2D(2, 2, TextureFormat.RGBA32, false, true);
+ texture2D.SetPixel(0, 0, new Color(0.17f, 0.43f, 0.71f, 1.0f));
+ texture2D.Apply(false, false);
+ texture = texture2D;
+ break;
+ case TextureDimension.Tex2DArray:
+ texture = new Texture2DArray(2, 2, 1, TextureFormat.RGBA32, false, true);
+ break;
+ case TextureDimension.Tex3D:
+ texture = new Texture3D(2, 2, 2, TextureFormat.RGBA32, false);
+ break;
+ case TextureDimension.Cube:
+ texture = new Cubemap(2, TextureFormat.RGBA32, false);
+ break;
+ case TextureDimension.CubeArray:
+ texture = new CubemapArray(2, 1, TextureFormat.RGBA32, false);
+ break;
+ default:
+ Assert.Fail($"Shader property '{shader.GetPropertyName(propertyIndex)}' has unsupported texture dimension '{dimension}'.");
+ return null;
+ }
+
+ transientTextures.Add(texture);
+ return texture;
+ }
+
+ /// Creates transient non-Pure-Base materials that fill any property-type coverage gap in all atomicity paths.
+ /// The property types observed while seeding existing atomicity targets.
+ /// The property types observed while capturing existing atomicity targets.
+ /// The property types observed while asserting existing atomicity targets.
+ /// One tracked material for every property type not already covered by all paths.
+ private IEnumerable CreateAtomicityCoverageMaterials(
+ ISet seededPropertyTypes,
+ ISet capturedPropertyTypes,
+ ISet assertedPropertyTypes)
+ {
+ foreach (ShaderUtil.ShaderPropertyType propertyType in RequiredAtomicityPropertyTypes)
+ {
+ if (seededPropertyTypes.Contains(propertyType)
+ && capturedPropertyTypes.Contains(propertyType)
+ && assertedPropertyTypes.Contains(propertyType))
+ continue;
+ yield return CreateMaterial(RequireSupportedNonProductShaderWithPropertyType(propertyType));
+ }
+ }
+
+ /// Returns a deterministic supported non-Pure-Base shader that exposes one required property type.
+ /// The property type required by atomicity coverage.
+ /// An imported, supported non-Pure-Base shader.
+ private static Shader RequireSupportedNonProductShaderWithPropertyType(ShaderUtil.ShaderPropertyType propertyType)
+ {
+ Shader shader = RequireUnsupportedRenderingModeShader();
+ for (int index = 0; index < ShaderUtil.GetPropertyCount(shader); index++)
+ {
+ if (ShaderUtil.GetPropertyType(shader, index) == propertyType)
+ return shader;
+ }
+
+ Assert.Fail($"The deterministic non-Pure-Base fixture shader did not expose '{propertyType}' for atomicity coverage.");
+ return null;
+ }
+
+ /// Records one property type observed by an atomicity execution path.
+ /// The optional path-local observed type set.
+ /// The property type encountered by the path.
+ private static void ObserveAtomicityPropertyType(ISet observedPropertyTypes, ShaderUtil.ShaderPropertyType propertyType)
+ {
+ if (observedPropertyTypes != null)
+ observedPropertyTypes.Add(propertyType);
+ }
+
+ /// Requires one atomicity execution path to exercise every supported property type.
+ /// The types observed by the execution path.
+ /// The diagnostic name of the execution path.
+ private static void AssertCompleteAtomicityPropertyTypeCoverage(ISet observedPropertyTypes, string pathName)
+ {
+ CollectionAssert.AreEquivalent(
+ RequiredAtomicityPropertyTypes,
+ observedPropertyTypes,
+ $"The atomicity {pathName} path must exercise every supported shader property type."
+ );
+ }
+
+ /// Records every property type visible to one atomicity assertion path.
+ /// The material whose shader properties are being asserted.
+ /// The optional path-local observed type set.
+ private static void ObserveAtomicityPropertyTypes(Material material, ISet observedPropertyTypes)
+ {
+ Shader shader = material.shader;
+ for (int index = 0; index < ShaderUtil.GetPropertyCount(shader); index++)
+ ObserveAtomicityPropertyType(observedPropertyTypes, ShaderUtil.GetPropertyType(shader, index));
+ }
+
+ /// Returns one supported non-Pure-Base shader that has no rendering-mode property.
+ /// A supported shader that is not owned by Pure-Base.
+ private static Shader RequireUnsupportedShaderWithoutRenderingMode()
+ {
+ Shader shader = Shader.Find("Standard") ?? Shader.Find("Unlit/Color");
+ Assert.That(shader, Is.Not.Null, "No built-in unsupported shader was available.");
+ Assert.That(shader.FindPropertyIndex("_RenderingMode"), Is.LessThan(0), "The missing-property shader must not expose _RenderingMode.");
+ return shader;
+ }
+
+ /// Returns one supported non-Pure-Base shader that independently exposes the common rendering-mode property.
+ /// A non-Pure-Base shader with _RenderingMode.
+ private static Shader RequireUnsupportedRenderingModeShader()
+ {
+ Shader shader = AssetDatabase.LoadAssetAtPath(UnsupportedRenderingModeFixturePath);
+ Assert.That(shader, Is.Not.Null, $"The unsupported-ownership fixture shader was not imported at '{UnsupportedRenderingModeFixturePath}'.");
+ Assert.That(shader.name, Is.EqualTo("PureBaseTests/Unsupported Rendering Mode"), "The unsupported-ownership fixture shader name changed.");
+ Assert.That(shader.name, Does.Not.StartWith("PureBase/"), "The unsupported-ownership fixture shader must not be owned by Pure-Base.");
+ Assert.That(ShaderUtil.ShaderHasError(shader), Is.False, "The unsupported-ownership fixture shader has import errors.");
+ Assert.That(shader.isSupported, Is.True, "The unsupported-ownership fixture shader is unsupported.");
+ Assert.That(shader.FindPropertyIndex("_RenderingMode"), Is.GreaterThanOrEqualTo(0), "The unsupported-ownership fixture shader must expose _RenderingMode.");
+ return shader;
+ }
+
+ /// Returns the product shader's ordered visible property names.
+ /// The shader to inspect.
+ /// The visible property names in declaration order.
+ }
+}
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.Atomicity.cs.meta b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.Atomicity.cs.meta
new file mode 100644
index 00000000..5889ab82
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.Atomicity.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 610ca5470d157134992b8209ccbdda46
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.InspectorPersistence.cs b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.InspectorPersistence.cs
new file mode 100644
index 00000000..9208dcd0
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.InspectorPersistence.cs
@@ -0,0 +1,490 @@
+/*
+ * 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 Inspector registration, selection workflow, and persistence contracts for rendering modes.
+
+// Defines the read-only material, normalizer, legacy-compatibility, and persistence contracts for rendering modes.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using System.Text.RegularExpressions;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.Rendering;
+
+
+namespace PureBase.Tests.Daily
+{
+ public sealed partial class PureBaseRenderingModeContractTests
+ {
+ /// Requires the registered Shader-Core drawer to preserve mixed values without mutating a clean normalized selection.
+ [Test]
+ public void InspectorDrawerIsRegisteredForMixedSelectionAndExposesOneAtomicUndoWorkflow()
+ {
+ AssertRenderingModeDrawerRegistration();
+ var opaque = CreateMaterial(RequireProductShader("PureBase/Unlit"));
+ var transparent = CreateMaterial(RequireProductShader("PureBase/Unlit"));
+ AssertMixedSelectionDrawerReadsAreReadOnly(opaque, transparent);
+ }
+
+ /// Requires the rendering-mode drawer registration to remain discoverable through Shader-Core.
+ private static void AssertRenderingModeDrawerRegistration()
+ {
+ Assert.That(
+ FindLoadedType("PureBase.Editor.PureBaseRenderingModeElement"),
+ Is.Not.Null,
+ "The dedicated rendering-mode Inspector drawer must be loaded."
+ );
+
+ Type attributeActionsType = FindLoadedType("jp.lilxyzw.shadercore.AttributeActions");
+ Assert.That(attributeActionsType, Is.Not.Null, "Shader-Core AttributeActions was not loaded.");
+ MethodInfo containsKey = attributeActionsType.GetMethod(
+ "ContainsKey",
+ BindingFlags.Public | BindingFlags.Static,
+ null,
+ new[] { typeof(string) },
+ null
+ );
+ Assert.That(containsKey, Is.Not.Null);
+ Assert.That((bool)containsKey.Invoke(null, new object[] { "PureBaseRenderingMode" }), Is.True);
+ }
+
+ /// Asserts the complete read-only mixed-selection drawer workflow for two normalized material modes.
+ private static void AssertMixedSelectionDrawerReadsAreReadOnly(Material opaque, Material transparent)
+ {
+ MethodInfo apply = RequireApplyMethod();
+ MethodInfo refreshSelection = RequireDrawerSelectionRefreshMethod();
+ MethodInfo getSelectionDisplayState = RequireDrawerSelectionDisplayStateMethod();
+ opaque.SetInteger("_RenderingMode", 0);
+ transparent.SetInteger("_RenderingMode", 2);
+ NormalizeAndClearMixedSelectionTargets(apply, opaque, transparent);
+ MaterialState opaqueBaseline = MaterialState.Capture(opaque);
+ MaterialState transparentBaseline = MaterialState.Capture(transparent);
+ MaterialProperty property = MaterialEditor.GetMaterialProperty(new UnityEngine.Object[] { opaque, transparent }, "_RenderingMode");
+ Assert.That(property.hasMixedValue, Is.True, "The rendering-mode field must expose mixed state before user selection.");
+ opaqueBaseline.AssertEqual(opaque, "Opaque target after mixed field binding");
+ transparentBaseline.AssertEqual(transparent, "Transparent target after mixed field binding");
+ object selectionDisplayState = InvokeDrawerSelectionDisplayState(getSelectionDisplayState, new[] { opaque, transparent });
+ AssertSelectionDisplayState(selectionDisplayState, true, new[] { "Opaque", "Cutout", "Transparent" });
+ opaqueBaseline.AssertEqual(opaque, "Opaque target after mixed drawer display-state read");
+ transparentBaseline.AssertEqual(transparent, "Transparent target after mixed drawer display-state read");
+ InvokeDrawerSelectionRefresh(refreshSelection, new[] { opaque, transparent });
+ opaqueBaseline.AssertEqual(opaque, "Opaque target after read-only mixed refresh");
+ transparentBaseline.AssertEqual(transparent, "Transparent target after read-only mixed refresh");
+ }
+
+ /// Explicitly normalizes each mixed-selection target and restores the clean read-only baseline.
+ private static void NormalizeAndClearMixedSelectionTargets(MethodInfo apply, Material opaque, Material transparent)
+ {
+ EditorUtility.ClearDirty(opaque);
+ EditorUtility.ClearDirty(transparent);
+ Assert.That(EditorUtility.IsDirty(opaque), Is.False, "The Opaque resync target must be clean before explicit normalization.");
+ Assert.That(EditorUtility.IsDirty(transparent), Is.False, "The Transparent resync target must be clean before explicit normalization.");
+ InvokeApply(apply, opaque);
+ Assert.That(EditorUtility.IsDirty(opaque), Is.True, "Explicit normalization must move the clean Opaque resync target to dirty.");
+ EditorUtility.ClearDirty(transparent);
+ Assert.That(EditorUtility.IsDirty(transparent), Is.False, "The Transparent resync target must be clean immediately before its own explicit normalization.");
+ InvokeApply(apply, transparent);
+ Assert.That(EditorUtility.IsDirty(transparent), Is.True, "Explicit normalization must move the clean Transparent resync target to dirty.");
+ EditorUtility.ClearDirty(opaque);
+ EditorUtility.ClearDirty(transparent);
+ Assert.That(EditorUtility.IsDirty(opaque), Is.False, "The Opaque mixed-selection baseline must be clean.");
+ Assert.That(EditorUtility.IsDirty(transparent), Is.False, "The Transparent mixed-selection baseline must be clean.");
+ }
+
+ /// Requires the Cutoff drawer to register and report read-only visibility from supported Cutout selections only.
+ [Test]
+ public void CutoffDrawerIsRegisteredAndVisibilityModelIsReadOnly()
+ {
+ Type attributeActionsType = FindLoadedType("jp.lilxyzw.shadercore.AttributeActions");
+ Assert.That(attributeActionsType, Is.Not.Null, "Shader-Core AttributeActions was not loaded.");
+ MethodInfo containsKey = attributeActionsType.GetMethod(
+ "ContainsKey",
+ BindingFlags.Public | BindingFlags.Static,
+ null,
+ new[] { typeof(string) },
+ null
+ );
+ Assert.That(containsKey, Is.Not.Null);
+ Assert.That((bool)containsKey.Invoke(null, new object[] { "PureBaseCutoff" }), Is.True);
+
+ Type cutoffElementType = FindLoadedType("PureBase.Editor.PureBaseCutoffElement");
+ Assert.That(cutoffElementType, Is.Not.Null, "The dedicated Cutoff Inspector drawer must be loaded.");
+ MethodInfo getSelectionDisplayState = cutoffElementType.GetMethod(
+ "GetSelectionDisplayState",
+ BindingFlags.Static | BindingFlags.NonPublic,
+ null,
+ new[] { typeof(UnityEngine.Object[]) },
+ null
+ );
+ Assert.That(getSelectionDisplayState, Is.Not.Null, "The Cutoff drawer must expose its read-only selection display model.");
+ PropertyInfo isVisible = getSelectionDisplayState.ReturnType.GetProperty("IsVisible", BindingFlags.Public | BindingFlags.Instance);
+ Assert.That(isVisible, Is.Not.Null, "The Cutoff selection display model must expose visibility.");
+
+ var opaque = CreateMaterial(RequireProductShader("PureBase/Unlit"));
+ var transparent = CreateMaterial(RequireProductShader("PureBase/Toon"));
+ var cutout = CreateMaterial(RequireProductShader("PureBase/PBR"));
+ var unsupported = CreateMaterial(RequireUnsupportedRenderingModeShader());
+ opaque.SetInteger("_RenderingMode", Modes[0].value);
+ transparent.SetInteger("_RenderingMode", Modes[2].value);
+ cutout.SetInteger("_RenderingMode", Modes[1].value);
+ MaterialState opaqueBaseline = MaterialState.Capture(opaque);
+ MaterialState transparentBaseline = MaterialState.Capture(transparent);
+ MaterialState cutoutBaseline = MaterialState.Capture(cutout);
+ MaterialState unsupportedBaseline = MaterialState.Capture(unsupported);
+
+ Func getVisibility = targets =>
+ (bool)isVisible.GetValue(getSelectionDisplayState.Invoke(null, new object[] { targets }));
+ Assert.That(getVisibility(new UnityEngine.Object[] { opaque, transparent }), Is.False, "All Opaque and Transparent supported targets must hide Cutoff.");
+ Assert.That(getVisibility(new UnityEngine.Object[] { opaque, transparent, unsupported }), Is.False, "Unsupported targets must not make Cutoff visible.");
+ Assert.That(getVisibility(new UnityEngine.Object[] { opaque, transparent, cutout, unsupported }), Is.True, "Any supported Cutout target must make Cutoff visible.");
+ opaqueBaseline.AssertEqual(opaque, "Opaque target after Cutoff display-state read");
+ transparentBaseline.AssertEqual(transparent, "Transparent target after Cutoff display-state read");
+ cutoutBaseline.AssertEqual(cutout, "Cutout target after Cutoff display-state read");
+ unsupportedBaseline.AssertEqual(unsupported, "Unsupported target after Cutoff display-state read");
+ }
+
+ /// Requires the drawer's one-action multi-target boundary to validate, normalize, undo, redo, and refresh without incidental mutation.
+ [Test]
+ public void InspectorMultiTargetActionIsAtomicAndUndoRedoRefreshesAreReadOnly()
+ {
+ MethodInfo apply = RequireApplyMethod();
+ MethodInfo applySelection = RequireDrawerSelectionApplyMethod();
+ MethodInfo refreshSelection = RequireDrawerSelectionRefreshMethod();
+ var first = CreateMaterial(RequireProductShader("PureBase/Unlit"));
+ var second = CreateMaterial(RequireProductShader("PureBase/Toon"));
+ var unsupported = CreateMaterial(RequireUnsupportedRenderingModeShader());
+ int initialUndoGroup = Undo.GetCurrentGroup();
+ try
+ {
+ first.SetInteger("_RenderingMode", 0);
+ second.SetInteger("_RenderingMode", 1);
+ InvokeApply(apply, first);
+ InvokeApply(apply, second);
+ MaterialState firstBefore = MaterialState.Capture(first);
+ MaterialState secondBefore = MaterialState.Capture(second);
+ MaterialState unsupportedBefore = MaterialState.Capture(unsupported);
+ AssertRejectedSelectionPreservesEveryTarget(applySelection, first, second, unsupported, firstBefore, secondBefore, unsupportedBefore);
+
+ InvokeDrawerSelectionApply(applySelection, new[] { first, second }, 2);
+ int editUndoGroup = Undo.GetCurrentGroup();
+ Assert.That(
+ editUndoGroup,
+ Is.EqualTo(initialUndoGroup + 1),
+ "One multi-target mode selection must create exactly one Undo group."
+ );
+ AssertModeState(first, Modes[2]);
+ AssertModeState(second, Modes[2]);
+ AssertUndoRedoRefreshesAreReadOnly(refreshSelection, first, second, firstBefore, secondBefore);
+ }
+ finally
+ {
+ Undo.RevertAllDownToGroup(initialUndoGroup);
+ }
+ }
+
+ /// Asserts that a rejected mixed selection leaves all targets and the Undo stack unchanged.
+ private static void AssertRejectedSelectionPreservesEveryTarget(MethodInfo applySelection, Material first, Material second, Material unsupported, MaterialState firstBefore, MaterialState secondBefore, MaterialState unsupportedBefore)
+ {
+ int undoBeforeRejectedSelection = Undo.GetCurrentGroup();
+ Assert.Throws(
+ () => InvokeDrawerSelectionApply(applySelection, new[] { first, second, unsupported }, 2),
+ "The drawer must validate every selected material before mutating any valid target."
+ );
+ firstBefore.AssertEqual(first, "valid target after rejected mixed selection");
+ secondBefore.AssertEqual(second, "second valid target after rejected mixed selection");
+ unsupportedBefore.AssertEqual(unsupported, "unsupported target after rejected mixed selection");
+ Assert.That(
+ Undo.GetCurrentGroup(),
+ Is.EqualTo(undoBeforeRejectedSelection),
+ "A rejected multi-target selection must not create an Undo group before validation succeeds."
+ );
+ }
+
+ /// Asserts that Undo, Redo, and their subsequent drawer refreshes preserve established material state.
+ private static void AssertUndoRedoRefreshesAreReadOnly(MethodInfo refreshSelection, Material first, Material second, MaterialState firstBefore, MaterialState secondBefore)
+ {
+ Undo.PerformUndo();
+ firstBefore.AssertEqual(first, "first target after Undo");
+ secondBefore.AssertEqual(second, "second target after Undo");
+ InvokeDrawerSelectionRefresh(refreshSelection, new[] { first, second });
+ firstBefore.AssertEqual(first, "first target after read-only Undo refresh");
+ secondBefore.AssertEqual(second, "second target after read-only Undo refresh");
+ Undo.PerformRedo();
+ AssertModeState(first, Modes[2]);
+ AssertModeState(second, Modes[2]);
+ MaterialState firstRedo = MaterialState.Capture(first);
+ MaterialState secondRedo = MaterialState.Capture(second);
+ InvokeDrawerSelectionRefresh(refreshSelection, new[] { first, second });
+ firstRedo.AssertEqual(first, "first target after read-only Redo refresh");
+ secondRedo.AssertEqual(second, "second target after read-only Redo refresh");
+ }
+
+ /// Requires explicit normalization to survive material and prefab save-reload while deleting every temporary asset.
+ [Test]
+ public void ExplicitNormalizationPersistsThroughMaterialAndPrefabSaveReloadAndCleansUp()
+ {
+ string materialPath = TemporaryAssetRoot + "/mode.mat";
+ string prefabPath = TemporaryAssetRoot + "/mode.prefab";
+ var retainedPaths = new List();
+ try
+ {
+ Assert.That(AssetDatabase.IsValidFolder(TemporaryAssetRoot), Is.False, "Temporary asset root already exists.");
+ AssetDatabase.CreateFolder("Assets", "PureBaseRenderingModeTests");
+ Material material = CreateAndPersistTransparentMaterial(materialPath);
+ SaveMaterialAsPrefab(material, prefabPath);
+
+ GameObject savedPrefab = AssetDatabase.LoadAssetAtPath(prefabPath);
+ Assert.That(savedPrefab, Is.Not.Null);
+ SaveOnlyOwnedAssetAndReimport(savedPrefab, prefabPath);
+ AssetDatabase.ImportAsset(materialPath, ImportAssetOptions.ForceSynchronousImport);
+ Material reloaded = AssetDatabase.LoadAssetAtPath(materialPath);
+ Assert.That(reloaded, Is.Not.Null);
+ AssertModeState(reloaded, Modes[2]);
+ GameObject prefab = AssetDatabase.LoadAssetAtPath(prefabPath);
+ Assert.That(prefab, Is.Not.Null);
+ Assert.That(prefab.GetComponent().sharedMaterial, Is.EqualTo(reloaded));
+ }
+ finally
+ {
+ if (!AssetDatabase.DeleteAsset(TemporaryAssetRoot))
+ retainedPaths.Add(TemporaryAssetRoot);
+ if (AssetDatabase.IsValidFolder(TemporaryAssetRoot))
+ retainedPaths.Add(TemporaryAssetRoot);
+ if (AssetDatabase.LoadAssetAtPath(materialPath) != null)
+ retainedPaths.Add(materialPath);
+ if (AssetDatabase.LoadAssetAtPath(prefabPath) != null)
+ retainedPaths.Add(prefabPath);
+ Assert.That(retainedPaths, Is.Empty, $"Rendering-mode persistence test retained temporary assets: {string.Join(", ", retainedPaths)}.");
+ }
+ }
+
+ /// Creates, normalizes, saves, and reloads the transient material used by the persistence contract.
+ private Material CreateAndPersistTransparentMaterial(string materialPath)
+ {
+ var material = CreateMaterial(RequireProductShader("PureBase/Toon"));
+ AssetDatabase.CreateAsset(material, materialPath);
+ material.SetInteger("_RenderingMode", 2);
+ InvokeApply(RequireApplyMethod(), material);
+ Assert.That(EditorUtility.IsDirty(material), Is.True, "Explicit normalization must dirty the temporary material before the path-scoped save.");
+ SaveOnlyOwnedAssetAndReimport(material, materialPath);
+ material = AssetDatabase.LoadAssetAtPath(materialPath);
+ Assert.That(material, Is.Not.Null);
+ return material;
+ }
+
+ /// Saves one transient material reference in a temporary prefab while releasing the source instance.
+ private static void SaveMaterialAsPrefab(Material material, string prefabPath)
+ {
+ var instance = GameObject.CreatePrimitive(PrimitiveType.Quad);
+ try
+ {
+ instance.GetComponent().sharedMaterial = material;
+ PrefabUtility.SaveAsPrefabAsset(instance, prefabPath);
+ }
+ finally
+ {
+ UnityEngine.Object.DestroyImmediate(instance);
+ }
+ }
+
+ /// Returns the required public normalizer method without statically referencing its not-yet-created assembly.
+ /// The public static Apply(Material) method.
+ private static MethodInfo RequireApplyMethod()
+ {
+ Type type = FindLoadedType("PureBase.Editor.PureBaseMaterialRenderingMode");
+ Assert.That(type, Is.Not.Null, "PureBaseMaterialRenderingMode must be loaded from PureBase.Editor.");
+ Assert.That(type.IsPublic, Is.True, "PureBaseMaterialRenderingMode must be public.");
+ MethodInfo method = type.GetMethod("Apply", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(Material) }, null);
+ Assert.That(method, Is.Not.Null, "PureBaseMaterialRenderingMode must expose public static Apply(Material).");
+ Assert.That(method.ReturnType, Is.EqualTo(typeof(void)), "PureBaseMaterialRenderingMode.Apply(Material) must return void.");
+ return method;
+ }
+
+ /// Returns the internal validated batch boundary used to verify rollback after an apply-time failure.
+ /// The static ApplyAll(IReadOnlyList<Material>) method.
+ private static MethodInfo RequireApplyAllMethod()
+ {
+ Type type = FindLoadedType("PureBase.Editor.PureBaseMaterialRenderingMode");
+ Assert.That(type, Is.Not.Null, "PureBaseMaterialRenderingMode must be loaded from PureBase.Editor.");
+ MethodInfo method = type.GetMethod(
+ "ApplyAll",
+ BindingFlags.NonPublic | BindingFlags.Static,
+ null,
+ new[] { typeof(IReadOnlyList) },
+ null
+ );
+ Assert.That(method, Is.Not.Null, "PureBaseMaterialRenderingMode must retain the validated batch boundary.");
+ return method;
+ }
+
+ /// Returns the drawer operation that applies one selected mode to every validated target in one user action.
+ /// The static ApplySelection(Material[], int) drawer operation.
+ private static MethodInfo RequireDrawerSelectionApplyMethod()
+ {
+ return RequireDrawerMethod("ApplySelection", new[] { typeof(Material[]), typeof(int) });
+ }
+
+ /// Returns the drawer operation that refreshes the current selection without applying or normalizing material state.
+ /// The static RefreshSelection(Material[]) drawer operation.
+ private static MethodInfo RequireDrawerSelectionRefreshMethod()
+ {
+ return RequireDrawerMethod("RefreshSelection", new[] { typeof(Material[]) });
+ }
+
+ /// Returns the drawer's read-only selection model boundary used to render mixed state and exact popup choices.
+ /// The static GetSelectionDisplayState(Material[]) drawer operation.
+ private static MethodInfo RequireDrawerSelectionDisplayStateMethod()
+ {
+ MethodInfo method = RequireDrawerMethod("GetSelectionDisplayState", new[] { typeof(Material[]) });
+ Assert.That(method.ReturnType, Is.Not.EqualTo(typeof(void)), "The drawer selection display-state boundary must return a readable UI model.");
+ return method;
+ }
+
+ /// Returns one required static drawer operation without adding a compile-time dependency on its future assembly.
+ /// The required operation name.
+ /// The exact operation parameter types.
+ /// The required static drawer operation.
+ private static MethodInfo RequireDrawerMethod(string methodName, Type[] parameterTypes)
+ {
+ Type type = FindLoadedType("PureBase.Editor.PureBaseRenderingModeElement");
+ Assert.That(type, Is.Not.Null, "The dedicated rendering-mode Inspector drawer must be loaded.");
+ MethodInfo method = type.GetMethod(
+ methodName,
+ BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static,
+ null,
+ parameterTypes,
+ null
+ );
+ Assert.That(
+ method,
+ Is.Not.Null,
+ "PureBaseRenderingModeElement must expose the testable " + methodName + " selection boundary."
+ );
+ return method;
+ }
+
+ /// Invokes the public normalizer while preserving its original exception type for NUnit assertions.
+ /// The reflected normalizer method.
+ /// The material passed to the normalizer.
+ private static void InvokeApply(MethodInfo method, Material material)
+ {
+ InvokeReflectedMethod(method, new object[] { material });
+ }
+
+ /// Invokes the validated batch boundary while preserving its original exception type.
+ /// The reflected batch normalizer method.
+ /// The material list passed to the batch normalizer.
+ private static void InvokeApplyAll(MethodInfo method, IReadOnlyList materials)
+ {
+ InvokeReflectedMethod(method, new object[] { materials });
+ }
+
+ /// Asserts that one rejected rendering-mode value preserves its established exception contract.
+ /// The exception thrown for the rejected value.
+ /// The rejected material identified by the exception.
+ /// The rejected rendering-mode value.
+ /// The operation context used in assertion diagnostics.
+ private static void AssertInvalidRenderingModeException(ArgumentOutOfRangeException exception, Material material, int value, string context)
+ {
+ Assert.That(exception, Is.Not.Null, context + " must throw an ArgumentOutOfRangeException.");
+ Assert.That(exception.ParamName, Is.EqualTo("_RenderingMode"), context + " exception parameter.");
+ Assert.That(exception.ActualValue, Is.EqualTo(value), context + " exception value.");
+ StringAssert.Contains(material.name, exception.Message, context + " exception material identity.");
+ StringAssert.Contains("0, 1, or 2", exception.Message, context + " exception supported values.");
+ }
+
+ /// Invokes the drawer's one-action multi-target operation while preserving its original exception type.
+ /// The reflected drawer operation.
+ /// The selected material targets.
+ /// The requested serialized rendering-mode value.
+ private static void InvokeDrawerSelectionApply(MethodInfo method, Material[] materials, int mode)
+ {
+ InvokeReflectedMethod(method, new object[] { materials, mode });
+ }
+
+ /// Invokes the drawer's read-only selection refresh while preserving its original exception type.
+ /// The reflected drawer refresh operation.
+ /// The selected material targets.
+ private static void InvokeDrawerSelectionRefresh(MethodInfo method, Material[] materials)
+ {
+ InvokeReflectedMethod(method, new object[] { materials });
+ }
+
+ /// Reads the drawer-owned display model without invoking a user action or normalizing material state.
+ /// The reflected drawer display-state operation.
+ /// The selected material targets.
+ /// The read-only drawer display model.
+ private static object InvokeDrawerSelectionDisplayState(MethodInfo method, Material[] materials)
+ {
+ return InvokeReflectedMethod(method, new object[] { materials });
+ }
+
+ /// Invokes a reflected operation while preserving its original exception type for NUnit assertions.
+ /// The reflected operation.
+ /// The operation arguments.
+ private static object InvokeReflectedMethod(MethodInfo method, object[] arguments)
+ {
+ try
+ {
+ return method.Invoke(null, arguments);
+ }
+ catch (TargetInvocationException exception) when (exception.InnerException != null)
+ {
+ ExceptionDispatchInfo.Capture(exception.InnerException).Throw();
+ throw;
+ }
+ }
+
+ /// Asserts the read-only drawer model for one current material selection.
+ /// The reflection-returned drawer selection model.
+ /// Whether the selection must be displayed as mixed.
+ /// The complete ordered mode labels presented by the popup.
+ private static void AssertSelectionDisplayState(object displayState, bool expectedMixed, string[] expectedChoices)
+ {
+ Assert.That(displayState, Is.Not.Null, "The drawer must return a real selection display model.");
+ Assert.That(ReadDisplayStateMember(displayState, "HasMixedValue"), Is.EqualTo(expectedMixed), "The drawer display model mixed indicator.");
+ object choices = ReadDisplayStateMember(displayState, "Choices");
+ var labels = choices as IEnumerable;
+ Assert.That(labels, Is.Not.Null, "The drawer display model Choices member must be a readable string sequence.");
+ CollectionAssert.AreEqual(expectedChoices, labels, "The drawer popup must expose exactly the three supported rendering-mode choices.");
+ }
+
+ /// Reads one field or property from a drawer-owned selection display model without depending on its accessibility.
+ /// The reflection-returned selection display model.
+ /// The required field or property name.
+ /// The member value.
+ private static object ReadDisplayStateMember(object displayState, string memberName)
+ {
+ Type type = displayState.GetType();
+ const BindingFlags Flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
+ PropertyInfo property = type.GetProperty(memberName, Flags);
+ if (property != null)
+ return property.GetValue(displayState, null);
+ FieldInfo field = type.GetField(memberName, Flags);
+ Assert.That(field, Is.Not.Null, "The drawer display model must expose " + memberName + " as a readable field or property.");
+ return field.GetValue(displayState);
+ }
+
+ /// Finds a type from all currently loaded assemblies without introducing a compile-time assembly dependency.
+ /// The required fully-qualified type name.
+ /// The loaded type, or .
+ }
+}
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.InspectorPersistence.cs.meta b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.InspectorPersistence.cs.meta
new file mode 100644
index 00000000..91ab8525
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.InspectorPersistence.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: cbbe4ceef7f0ea848a76eb672f0a7c7b
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.MaterialState.cs b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.MaterialState.cs
new file mode 100644
index 00000000..f1de32fa
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.MaterialState.cs
@@ -0,0 +1,315 @@
+/*
+ * 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 material snapshot and delayed-invalidating collection support for atomicity contracts.
+
+// Defines the read-only material, normalizer, legacy-compatibility, and persistence contracts for rendering modes.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using System.Text.RegularExpressions;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.Rendering;
+
+
+namespace PureBase.Tests.Daily
+{
+ public sealed partial class PureBaseRenderingModeContractTests
+ {
+ /// Captures every material field whose mutation must be rejected by invalid normalizer inputs.
+ private sealed class MaterialState
+ {
+ /// Captures an immutable snapshot from one material.
+ /// The material to snapshot.
+ /// The optional set that records captured shader property types.
+ /// The captured state.
+ public static MaterialState Capture(Material material, ISet observedPropertyTypes = null)
+ {
+ MaterialState state = CreateBaseState(material);
+ CaptureShaderPropertyState(state, material, observedPropertyTypes);
+ CaptureHiddenStateAndPasses(state, material);
+ return state;
+ }
+
+ /// Captures material-wide rendering state before visible shader properties are enumerated.
+ private static MaterialState CreateBaseState(Material material)
+ {
+ return new MaterialState
+ {
+ hasRenderTypeOverride = TryGetSerializedRenderTypeOverride(material, out string renderTypeOverride),
+ renderTypeOverride = renderTypeOverride,
+ resolvedRenderType = material.GetTag("RenderType", true),
+ rawQueue = GetRawRenderQueue(material),
+ resolvedQueue = material.renderQueue,
+ shadowCasterEnabled = material.GetShaderPassEnabled("ShadowCaster"),
+ metaEnabled = material.GetShaderPassEnabled("Meta"),
+ dirty = EditorUtility.IsDirty(material),
+ keywords = material.shaderKeywords,
+ };
+ }
+
+ /// Captures every visible shader property in declaration order.
+ private static void CaptureShaderPropertyState(MaterialState state, Material material, ISet observedPropertyTypes)
+ {
+ Shader shader = material.shader;
+ for (int index = 0; index < ShaderUtil.GetPropertyCount(shader); index++)
+ {
+ string propertyName = shader.GetPropertyName(index);
+ ShaderUtil.ShaderPropertyType propertyType = ShaderUtil.GetPropertyType(shader, index);
+ ObserveAtomicityPropertyType(observedPropertyTypes, propertyType);
+ switch (propertyType)
+ {
+ case ShaderUtil.ShaderPropertyType.Float:
+ case ShaderUtil.ShaderPropertyType.Range:
+ state.floats[propertyName] = material.GetFloat(propertyName);
+ break;
+ case ShaderUtil.ShaderPropertyType.Int:
+ state.integers[propertyName] = material.GetInteger(propertyName);
+ break;
+ case ShaderUtil.ShaderPropertyType.Color:
+ state.colors[propertyName] = material.GetColor(propertyName);
+ break;
+ case ShaderUtil.ShaderPropertyType.Vector:
+ state.vectors[propertyName] = material.GetVector(propertyName);
+ break;
+ case ShaderUtil.ShaderPropertyType.TexEnv:
+ state.textures[propertyName] = TexturePropertyState.Capture(material, propertyName);
+ break;
+ default:
+ Assert.Fail($"Unsupported shader property type '{ShaderUtil.GetPropertyType(shader, index)}' for '{propertyName}'.");
+ break;
+ }
+ }
+ }
+
+ /// Captures the hidden normalizer state and pass enabled values.
+ private static void CaptureHiddenStateAndPasses(MaterialState state, Material material)
+ {
+ foreach (string propertyName in HiddenStatePropertyNames)
+ {
+ if (material.HasProperty(propertyName))
+ state.floats[propertyName] = material.GetFloat(propertyName);
+ }
+
+ foreach (string passName in PassNames)
+ state.passes[passName] = material.GetShaderPassEnabled(passName);
+ }
+
+ /// Asserts that a material still matches this immutable snapshot.
+ /// The material to compare.
+ /// The diagnostic operation context.
+ /// The optional set that records asserted shader property types.
+ public void AssertEqual(Material material, string context, ISet observedPropertyTypes = null)
+ {
+ ObserveAtomicityPropertyTypes(material, observedPropertyTypes);
+ bool actualHasRenderTypeOverride = TryGetSerializedRenderTypeOverride(material, out string actualRenderTypeOverride);
+ Assert.That(actualHasRenderTypeOverride, Is.EqualTo(hasRenderTypeOverride), context + " RenderType override presence.");
+ if (actualHasRenderTypeOverride)
+ Assert.That(actualRenderTypeOverride, Is.EqualTo(renderTypeOverride), context + " RenderType override.");
+ Assert.That(material.GetTag("RenderType", true), Is.EqualTo(resolvedRenderType), context + " resolved RenderType tag.");
+ Assert.That(GetRawRenderQueue(material), Is.EqualTo(rawQueue), context + " raw render queue.");
+ Assert.That(material.renderQueue, Is.EqualTo(resolvedQueue), context + " resolved render queue.");
+ Assert.That(material.GetShaderPassEnabled("ShadowCaster"), Is.EqualTo(shadowCasterEnabled), context + " ShadowCaster state.");
+ Assert.That(material.GetShaderPassEnabled("Meta"), Is.EqualTo(metaEnabled), context + " Meta state.");
+ Assert.That(EditorUtility.IsDirty(material), Is.EqualTo(dirty), context + " dirty state.");
+ CollectionAssert.AreEquivalent(keywords, material.shaderKeywords, context + " keyword set.");
+ foreach (KeyValuePair pair in floats)
+ Assert.That(material.GetFloat(pair.Key), Is.EqualTo(pair.Value), context + " property " + pair.Key + ".");
+ foreach (KeyValuePair pair in integers)
+ {
+ int actual = material.GetInteger(pair.Key);
+ Assert.That(actual, Is.EqualTo(pair.Value), context + " int property " + pair.Key + ".");
+ }
+ foreach (KeyValuePair pair in colors)
+ Assert.That(material.GetColor(pair.Key), Is.EqualTo(pair.Value), context + " color property " + pair.Key + ".");
+ foreach (KeyValuePair pair in vectors)
+ Assert.That(material.GetVector(pair.Key), Is.EqualTo(pair.Value), context + " vector property " + pair.Key + ".");
+ foreach (KeyValuePair pair in textures)
+ pair.Value.AssertEqual(material, pair.Key, context);
+ foreach (KeyValuePair pair in passes)
+ Assert.That(material.GetShaderPassEnabled(pair.Key), Is.EqualTo(pair.Value), context + " pass " + pair.Key + ".");
+ }
+
+ /// Asserts that this snapshot includes one visible or hidden shader property.
+ /// The shader property that must be captured.
+ public void AssertCapturesShaderProperty(string propertyName)
+ {
+ Assert.That(
+ floats.ContainsKey(propertyName)
+ || integers.ContainsKey(propertyName)
+ || colors.ContainsKey(propertyName)
+ || vectors.ContainsKey(propertyName)
+ || textures.ContainsKey(propertyName),
+ Is.True,
+ "The material snapshot must include shader property '" + propertyName + "'."
+ );
+ }
+
+ /// Stores whether the snapshot captured an explicit RenderType override.
+ public bool hasRenderTypeOverride;
+
+ /// Stores the captured serialized RenderType override.
+ public string renderTypeOverride;
+
+ /// Stores the captured shader-resolved RenderType tag.
+ public string resolvedRenderType;
+
+ /// Stores the captured raw queue.
+ public int rawQueue;
+
+ /// Stores the captured shader-resolved render queue.
+ public int resolvedQueue;
+
+ /// Stores the captured ShadowCaster flag.
+ public bool shadowCasterEnabled;
+
+ /// Stores the captured Meta flag.
+ public bool metaEnabled;
+
+ /// Stores the captured dirty flag.
+ public bool dirty;
+
+ /// Stores the captured keyword set.
+ public string[] keywords;
+
+ /// Stores captured float and range property values.
+ public readonly Dictionary floats = new Dictionary(StringComparer.Ordinal);
+
+ /// Stores captured integer property values.
+ public readonly Dictionary integers = new Dictionary(StringComparer.Ordinal);
+
+ /// Stores captured color property values.
+ public readonly Dictionary colors = new Dictionary(StringComparer.Ordinal);
+
+ /// Stores captured vector property values.
+ public readonly Dictionary vectors = new Dictionary(StringComparer.Ordinal);
+
+ /// Stores captured texture property values and their UV transforms.
+ public readonly Dictionary textures = new Dictionary(StringComparer.Ordinal);
+
+ /// Stores captured enabled-state values for every rendering-mode-relevant pass.
+ public readonly Dictionary passes = new Dictionary(StringComparer.Ordinal);
+ }
+
+ /// Returns valid materials during validation and snapshots, then makes one later target invalid during application.
+ private sealed class LateInvalidatingMaterialList : IReadOnlyList
+ {
+ /// Initializes a deterministic material list that invalidates one target on its third indexed read.
+ /// The ordered batch materials.
+ /// The later material index to invalidate.
+ /// The unsupported mode assigned immediately before its application.
+ public LateInvalidatingMaterialList(Material[] materials, int invalidMaterialIndex, int invalidRenderingMode)
+ {
+ this.materials = materials;
+ this.invalidMaterialIndex = invalidMaterialIndex;
+ this.invalidRenderingMode = invalidRenderingMode;
+ }
+
+ /// Gets the number of materials in the batch.
+ public int Count => materials.Length;
+
+ /// Returns the batch materials in their deterministic order.
+ /// An enumerator for the batch materials.
+ public IEnumerator GetEnumerator()
+ {
+ return ((IEnumerable)materials).GetEnumerator();
+ }
+
+ /// Returns the batch materials through the non-generic enumeration contract.
+ /// An enumerator for the batch materials.
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
+ {
+ return materials.GetEnumerator();
+ }
+
+ /// Gets a material and invalidates the designated later target immediately before application.
+ /// The requested batch index.
+ /// The requested material.
+ public Material this[int index]
+ {
+ get
+ {
+ if (index == invalidMaterialIndex && ++invalidMaterialReadCount == 3)
+ {
+ ObservedPriorMutations = materials[0].GetTag("RenderType", false) == "Opaque"
+ && materials[1].GetTag("RenderType", false) == "Transparent";
+ materials[index].SetInteger("_RenderingMode", invalidRenderingMode);
+ }
+
+ return materials[index];
+ }
+ }
+
+ /// Gets whether the list observed normalized prior targets before it invalidated the later target.
+ public bool ObservedPriorMutations { get; private set; }
+
+ /// Stores the ordered batch materials.
+ private readonly Material[] materials;
+
+ /// Stores the later material index invalidated during application.
+ private readonly int invalidMaterialIndex;
+
+ /// Stores the unsupported rendering-mode value used to force application failure.
+ private readonly int invalidRenderingMode;
+
+ /// Counts accesses to the material that becomes invalid.
+ private int invalidMaterialReadCount;
+ }
+
+ /// Stores one texture property and its material-local UV transform for atomicity assertions.
+ private sealed class TexturePropertyState
+ {
+ /// Captures one texture property's complete material-local state.
+ /// The source material.
+ /// The texture property name.
+ /// The immutable texture-property snapshot.
+ public static TexturePropertyState Capture(Material material, string propertyName)
+ {
+ return new TexturePropertyState
+ {
+ texture = material.GetTexture(propertyName),
+ scale = material.GetTextureScale(propertyName),
+ offset = material.GetTextureOffset(propertyName),
+ };
+ }
+
+ /// Asserts one material texture property still matches this snapshot.
+ /// The material to inspect.
+ /// The texture property name.
+ /// The diagnostic operation context.
+ public void AssertEqual(Material material, string propertyName, string context)
+ {
+ Assert.That(material.GetTexture(propertyName), Is.EqualTo(texture), context + " texture property " + propertyName + ".");
+ Assert.That(material.GetTextureScale(propertyName), Is.EqualTo(scale), context + " texture scale " + propertyName + ".");
+ Assert.That(material.GetTextureOffset(propertyName), Is.EqualTo(offset), context + " texture offset " + propertyName + ".");
+ }
+
+ /// Stores the captured texture object.
+ public Texture texture;
+
+ /// Stores the captured texture UV scale.
+ public Vector2 scale;
+
+ /// Stores the captured texture UV offset.
+ public Vector2 offset;
+ }
+ }
+}
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.MaterialState.cs.meta b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.MaterialState.cs.meta
new file mode 100644
index 00000000..85bc6c71
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.MaterialState.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 6040f9e1e9db386409ca276f9f9d33ea
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.ProductContracts.cs b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.ProductContracts.cs
new file mode 100644
index 00000000..fe0ea934
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.ProductContracts.cs
@@ -0,0 +1,311 @@
+/*
+ * 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 product shader, public API, and explicit state-table contracts for rendering modes.
+
+// Defines the read-only material, normalizer, legacy-compatibility, and persistence contracts for rendering modes.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using System.Text.RegularExpressions;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.Rendering;
+
+
+namespace PureBase.Tests.Daily
+{
+ public sealed partial class PureBaseRenderingModeContractTests
+ {
+ private readonly List transientMaterials = new List();
+
+ /// Tracks transient texture sentinels used to make invalid-input atomicity snapshots discriminating.
+ private readonly List transientTextures = new List();
+
+ /// Identifies the package-local root used only by persistence tests.
+ private const string TemporaryAssetRoot = "Assets/PureBaseRenderingModeTests";
+
+ /// Identifies the pre-rendering-mode material fixture that must remain byte-identical.
+ private const string LegacyFixturePath =
+ "Packages/jp.penguin.purebase/Tests/Fixtures/Materials/PureBaseLegacyCutout.mat";
+
+ /// Identifies the deterministic non-Pure-Base shader fixture used for unsupported-ownership and atomicity coverage.
+ private const string UnsupportedRenderingModeFixturePath =
+ "Packages/jp.penguin.purebase/Tests/Fixtures/RenderingMode/PureBaseUnsupportedRenderingMode.shader";
+
+ /// Matches the required Shader-Core property declaration without relying on reflection metadata.
+ private const string RenderingModePropertySourcePattern =
+ @"SC_uint\s*\(\s*_RenderingMode\s*,\s*1(?:\.0+)?\s*,\s*\[\s*PureBaseRenderingMode\s*\]\s*,\s*""[^""\r\n]*""\s*,\s*""[^""\r\n]*""\s*\)";
+
+ /// Matches the required Cutoff declaration with its Pure-Base drawer and stable range bounds.
+ private const string CutoffPropertySourcePattern =
+ @"SC_float\s*\(\s*_Cutoff\s*,\s*0\.5(?:0+)?\s*,\s*\[\s*PureBaseCutoff\s*\]\s*\[\s*SCRange\s*\(\s*-0\.001\s*,\s*1\.001\s*\)\s*\]\s*,\s*""Cutoff""\s*,\s*""""\s*\)";
+
+ /// Lists the public product shaders and their complete visible property ABI.
+ private static readonly ProductContract[] Products =
+ {
+ new ProductContract(
+ "PureBase/Unlit",
+ "Packages/jp.penguin.purebase/Shaders/PureBaseUnlit_properties.hlsl",
+ new[] { "_BaseTexture", "_BaseColor", "_SharedMask", "_SharedGradients", "_RenderingMode", "_Cutoff", "_Cull" }
+ ),
+ new ProductContract(
+ "PureBase/Toon",
+ "Packages/jp.penguin.purebase/Shaders/PureBaseToon_properties.hlsl",
+ new[]
+ {
+ "_BaseTexture", "_BaseColor", "_SharedMask", "_SharedGradients", "_RenderingMode", "_Cutoff", "_Cull", "_NormalMap", "_NormalScale",
+ }
+ ),
+ new ProductContract(
+ "PureBase/PBR",
+ "Packages/jp.penguin.purebase/Shaders/PureBasePBR_properties.hlsl",
+ new[]
+ {
+ "_BaseTexture", "_BaseColor", "_SharedMask", "_SharedGradients", "_RenderingMode", "_Cutoff", "_Cull", "_NormalMap", "_NormalScale", "_Metallic", "_Roughness",
+ }
+ ),
+ new ProductContract(
+ "PureBase/Hybrid",
+ "Packages/jp.penguin.purebase/Shaders/PureBaseHybrid_properties.hlsl",
+ new[]
+ {
+ "_BaseTexture", "_BaseColor", "_SharedMask", "_SharedGradients", "_RenderingMode", "_Cutoff", "_Cull", "_NormalMap", "_NormalScale", "_Metallic", "_Roughness",
+ }
+ ),
+ };
+
+ /// Lists the hidden material-state properties synchronized by the normalizer.
+ private static readonly string[] HiddenStatePropertyNames =
+ {
+ "_SrcBlend",
+ "_DstBlend",
+ "_ZWrite",
+ "_AddSrcBlend",
+ "_AddDstBlend",
+ };
+
+ /// Lists the only local keywords the rendering-mode feature may declare.
+ private static readonly string[] RenderingModeKeywords =
+ {
+ "PUREBASE_RENDERING_OPAQUE",
+ "PUREBASE_RENDERING_TRANSPARENT",
+ };
+
+ /// Lists the source-level pass ABI retained by every product material.
+ private static readonly string[] PassNames =
+ {
+ "ForwardBase",
+ "ForwardAdd",
+ "ShadowCaster",
+ "Meta",
+ };
+
+ /// Lists every ShaderUtil property type whose invalid-input atomicity path must execute.
+ private static readonly ShaderUtil.ShaderPropertyType[] RequiredAtomicityPropertyTypes =
+ {
+ ShaderUtil.ShaderPropertyType.Float,
+ ShaderUtil.ShaderPropertyType.Range,
+ ShaderUtil.ShaderPropertyType.Int,
+ ShaderUtil.ShaderPropertyType.Color,
+ ShaderUtil.ShaderPropertyType.Vector,
+ ShaderUtil.ShaderPropertyType.TexEnv,
+ };
+
+ /// Defines the complete state expected for one explicit material rendering mode.
+ private static readonly ModeContract[] Modes =
+ {
+ new ModeContract(
+ 0,
+ "Opaque",
+ new BlendState((int)BlendMode.One, (int)BlendMode.Zero, 1, (int)BlendMode.One, (int)BlendMode.One),
+ new RenderTypeState("Opaque", true, "Opaque"),
+ new QueueState(2000, 2000),
+ new[] { "PUREBASE_RENDERING_OPAQUE" },
+ true
+ ),
+ new ModeContract(
+ 1,
+ "Cutout",
+ new BlendState((int)BlendMode.One, (int)BlendMode.Zero, 1, (int)BlendMode.One, (int)BlendMode.One),
+ new RenderTypeState(string.Empty, false, "TransparentCutout"),
+ new QueueState(-1, (int)RenderQueue.AlphaTest),
+ Array.Empty(),
+ true
+ ),
+ new ModeContract(
+ 2,
+ "Transparent",
+ new BlendState((int)BlendMode.SrcAlpha, (int)BlendMode.OneMinusSrcAlpha, 0, (int)BlendMode.SrcAlpha, (int)BlendMode.One),
+ new RenderTypeState("Transparent", true, "Transparent"),
+ new QueueState(3000, 3000),
+ new[] { "PUREBASE_RENDERING_TRANSPARENT" },
+ false
+ ),
+ };
+
+ /// Requires the complete shader ABI, static Cutout defaults, pass ABI, and local-keyword declaration.
+ [Test]
+ public void ProductShadersExposeRenderingModeAndCutoutCompatibleStaticDefaults()
+ {
+ foreach (ProductContract product in Products)
+ {
+ Shader shader = RequireProductShader(product.shaderName);
+ AssertProductShaderAbi(product, shader);
+ AssertProductShaderStaticDefaults(product, shader);
+ }
+ }
+
+ /// Asserts the visible-property ABI and rendering-mode property declarations for one product shader.
+ /// The expected product shader contract.
+ /// The imported product shader.
+ private static void AssertProductShaderAbi(ProductContract product, Shader shader)
+ {
+ CollectionAssert.AreEqual(product.visiblePropertyNames, GetVisiblePropertyNames(shader), $"Product shader '{product.shaderName}' changed its public property ABI.");
+ int modeIndex = shader.FindPropertyIndex("_RenderingMode");
+ Assert.That(modeIndex, Is.GreaterThanOrEqualTo(0), $"Product shader '{product.shaderName}' must expose _RenderingMode.");
+ Assert.That(shader.GetPropertyType(modeIndex), Is.EqualTo(ShaderPropertyType.Int), $"Product shader '{product.shaderName}' must expose _RenderingMode as an Integer property.");
+ CollectionAssert.Contains(shader.GetPropertyAttributes(modeIndex), "PureBaseRenderingMode", $"Product shader '{product.shaderName}' must use the Pure-Base rendering-mode drawer.");
+ Assert.That(Regex.IsMatch(File.ReadAllText(product.propertySourcePath), RenderingModePropertySourcePattern), Is.True, $"Product property source '{product.propertySourcePath}' must declare _RenderingMode as SC_uint with default 1 and the PureBaseRenderingMode drawer.");
+ int cutoffIndex = shader.FindPropertyIndex("_Cutoff");
+ 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).");
+ }
+
+ /// Asserts the static Cutout defaults, pass ABI, and keyword declarations for one product shader.
+ /// The expected product shader contract.
+ /// The imported product shader.
+ private void AssertProductShaderStaticDefaults(ProductContract product, Shader shader)
+ {
+ var material = CreateMaterial(shader);
+ Assert.That(material.GetInteger("_RenderingMode"), Is.EqualTo(1));
+ AssertHiddenState(material, Modes[1]);
+ Assert.That(material.renderQueue, Is.EqualTo((int)RenderQueue.AlphaTest));
+ Assert.That(material.GetTag("RenderType", false), Is.EqualTo("TransparentCutout"));
+ Assert.That(material.GetShaderPassEnabled("ShadowCaster"), Is.True);
+ Assert.That(material.GetShaderPassEnabled("Meta"), Is.True);
+ AssertRenderingKeywords(material, Array.Empty());
+ CollectionAssert.AreEqual(PassNames, GetPassNames(shader));
+ AssertRenderingModeKeywordDeclarations(LoadGeneratedSource(product.shaderName), product.shaderName);
+ }
+
+ /// Requires a new unsaved material to behave as Cutout without creating persistence dirtiness.
+ [Test]
+ public void NewMaterialWithoutSavedModeRemainsReadOnlyCutoutUntilExplicitNormalization()
+ {
+ Shader shader = RequireProductShader("PureBase/Unlit");
+ var material = CreateMaterial(shader);
+ {
+ Assert.That(shader.FindPropertyIndex("_RenderingMode"), Is.GreaterThanOrEqualTo(0));
+ EditorUtility.ClearDirty(material);
+ Assert.That(EditorUtility.IsDirty(material), Is.False, "The Inspector-bind test must establish a clean baseline.");
+ MaterialState baseline = MaterialState.Capture(material);
+ MaterialEditor.GetMaterialProperties(new UnityEngine.Object[] { material });
+ baseline.AssertEqual(material, "Inspector bind");
+ Assert.That(material.GetInteger("_RenderingMode"), Is.EqualTo(1));
+ AssertHiddenState(material, Modes[1]);
+ Assert.That(material.renderQueue, Is.EqualTo((int)RenderQueue.AlphaTest));
+ Assert.That(material.GetShaderPassEnabled("ShadowCaster"), Is.True);
+ Assert.That(material.GetShaderPassEnabled("Meta"), Is.True);
+ AssertRenderingKeywords(material, Array.Empty());
+ }
+ }
+
+ /// Ensures a 0.1.x serialized material keeps all noncanonical overrides after an Inspector bind and save-reload.
+ [Test]
+ public void LegacyCutoutFixtureRemainsByteAndStateIdenticalAcrossReadOnlyBindAndSaveReload()
+ {
+ byte[] beforeBytes = File.ReadAllBytes(LegacyFixturePath);
+ string beforeText = File.ReadAllText(LegacyFixturePath);
+ Assert.That(beforeText.IndexOf("_RenderingMode", StringComparison.Ordinal), Is.LessThan(0));
+
+ AssetDatabase.ImportAsset(LegacyFixturePath, ImportAssetOptions.ForceSynchronousImport);
+ Material material = AssetDatabase.LoadAssetAtPath(LegacyFixturePath);
+ Assert.That(material, Is.Not.Null, "The legacy fixture did not import as a material.");
+ Assert.That(material.shader.FindPropertyIndex("_RenderingMode"), Is.GreaterThanOrEqualTo(0));
+ MaterialState before = MaterialState.Capture(material);
+ AssertLegacyState(before);
+ MaterialEditor.GetMaterialProperties(new UnityEngine.Object[] { material });
+ Assert.That(EditorUtility.IsDirty(material), Is.False, "Binding a legacy material must not normalize it.");
+
+ SaveOnlyOwnedAssetAndReimport(material, LegacyFixturePath);
+ material = AssetDatabase.LoadAssetAtPath(LegacyFixturePath);
+ Assert.That(material, Is.Not.Null);
+ AssertLegacyState(MaterialState.Capture(material));
+ CollectionAssert.AreEqual(beforeBytes, File.ReadAllBytes(LegacyFixturePath));
+ }
+
+ /// Requires the public normalizer API and checks every product against the complete explicit state table.
+ [Test]
+ public void ExplicitModeNormalizationMatchesTheCompleteFourByThreeStateTable()
+ {
+ MethodInfo apply = RequireApplyMethod();
+ foreach (ProductContract product in Products)
+ {
+ var material = CreateMaterial(RequireProductShader(product.shaderName));
+ {
+ foreach (ModeContract mode in Modes)
+ {
+ material.SetInteger("_RenderingMode", mode.value);
+ InvokeApply(apply, material);
+ Assert.That(material.GetInteger("_RenderingMode"), Is.EqualTo(mode.value), $"{product.shaderName} {mode.name} mode value.");
+ AssertRenderTypeState(material, mode);
+ Assert.That(GetRawRenderQueue(material), Is.EqualTo(mode.rawQueue));
+ Assert.That(material.renderQueue, Is.EqualTo(mode.resolvedQueue));
+ AssertHiddenState(material, mode);
+ AssertRenderingKeywords(material, mode.enabledKeywords);
+ Assert.That(material.GetShaderPassEnabled("ShadowCaster"), Is.EqualTo(mode.enableContributionPasses));
+ Assert.That(material.GetShaderPassEnabled("Meta"), Is.EqualTo(mode.enableContributionPasses));
+ }
+ }
+ }
+ }
+
+ /// Requires the public enum and method shape through reflection so missing production code remains a test failure.
+ [Test]
+ public void PublicRenderingModeApiIsDiscoverableWithoutATestAssemblyDependency()
+ {
+ Type enumType = FindLoadedType("PureBase.Editor.PureBaseRenderingMode");
+ Assert.That(enumType, Is.Not.Null, "PureBaseRenderingMode must be discoverable from the loaded Editor assemblies.");
+ Assert.That(enumType.IsPublic, Is.True, "PureBaseRenderingMode must be public.");
+ Assert.That(enumType.IsEnum, Is.True, "PureBaseRenderingMode must be an enum.");
+ CollectionAssert.AreEqual(
+ new[] { "Opaque", "Cutout", "Transparent" },
+ Enum.GetNames(enumType),
+ "PureBaseRenderingMode must expose exactly the three stable public names without aliases."
+ );
+ Array enumValues = Enum.GetValues(enumType);
+ var numericValues = new int[enumValues.Length];
+ for (int index = 0; index < enumValues.Length; index++)
+ numericValues[index] = Convert.ToInt32(enumValues.GetValue(index));
+ CollectionAssert.AreEqual(
+ new[] { 0, 1, 2 },
+ numericValues,
+ "PureBaseRenderingMode must expose exactly the stable 0, 1, and 2 ABI values without aliases."
+ );
+ Type normalizerType = FindLoadedType("PureBase.Editor.PureBaseMaterialRenderingMode");
+ Assert.That(normalizerType, Is.Not.Null, "PureBaseMaterialRenderingMode must be discoverable from the loaded Editor assemblies.");
+ Assert.That(normalizerType.IsPublic, Is.True, "PureBaseMaterialRenderingMode must be public.");
+ Assert.That(RequireApplyMethod(), Is.Not.Null);
+ }
+
+ }
+}
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.ProductContracts.cs.meta b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.ProductContracts.cs.meta
new file mode 100644
index 00000000..b1bdfc83
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.ProductContracts.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: cec7253c103517046abbbbd0071bb276
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.Support.cs b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.Support.cs
new file mode 100644
index 00000000..c2a38bd4
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.Support.cs
@@ -0,0 +1,443 @@
+/*
+ * 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.
+ */
+
+// Provides shared fixture lifecycle, shader inspection, reflection, and rendering-state assertion support.
+
+// Defines the read-only material, normalizer, legacy-compatibility, and persistence contracts for rendering modes.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Reflection;
+using System.Runtime.ExceptionServices;
+using System.Text.RegularExpressions;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.Rendering;
+
+
+namespace PureBase.Tests.Daily
+{
+ public sealed partial class PureBaseRenderingModeContractTests
+ {
+ /// Saves and synchronously reimports one test-owned asset without persisting unrelated dirty Editor assets.
+ /// The exact fixture or temporary asset owned by this test.
+ /// The expected project-relative path for .
+ private static void SaveOnlyOwnedAssetAndReimport(UnityEngine.Object asset, string assetPath)
+ {
+ Assert.That(asset, Is.Not.Null, $"Test-owned asset '{assetPath}' must exist before persistence.");
+ Assert.That(AssetDatabase.GetAssetPath(asset), Is.EqualTo(assetPath), "Persistence must target only the supplied test-owned asset path.");
+ AssetDatabase.SaveAssetIfDirty(asset);
+ AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceSynchronousImport);
+ }
+
+ /// Returns one imported and compilable public product shader.
+ /// The stable public shader name.
+ /// The imported product shader.
+ private static Shader RequireProductShader(string shaderName)
+ {
+ Shader shader = Shader.Find(shaderName);
+ Assert.That(shader, Is.Not.Null, $"Product shader '{shaderName}' was not imported.");
+ Assert.That(ShaderUtil.ShaderHasError(shader), Is.False, $"Product shader '{shaderName}' has compiler errors.");
+ Assert.That(shader.isSupported, Is.True, $"Product shader '{shaderName}' is unsupported.");
+ return shader;
+ }
+
+ /// Creates and registers one transient material for deterministic test cleanup.
+ /// The shader assigned to the new material.
+ /// The tracked transient material.
+ private Material CreateMaterial(Shader shader)
+ {
+ var material = new Material(shader);
+ transientMaterials.Add(material);
+ return material;
+ }
+
+ /// Releases transient material resources after each test, including partial-failure paths.
+ [TearDown]
+ public void DestroyTransientMaterials()
+ {
+ foreach (Material material in transientMaterials)
+ {
+ if (material != null)
+ UnityEngine.Object.DestroyImmediate(material);
+ }
+
+ transientMaterials.Clear();
+ foreach (Texture texture in transientTextures)
+ {
+ if (texture != null)
+ UnityEngine.Object.DestroyImmediate(texture);
+ }
+
+ transientTextures.Clear();
+ }
+
+ /// Returns the non-hidden property names in shader declaration order.
+ /// The shader whose visible property ABI is inspected.
+ /// The ordered visible property names.
+ private static string[] GetVisiblePropertyNames(Shader shader)
+ {
+ var result = new List();
+ for (int index = 0; index < ShaderUtil.GetPropertyCount(shader); index++)
+ {
+ if ((shader.GetPropertyFlags(index) & ShaderPropertyFlags.HideInInspector) == 0)
+ result.Add(shader.GetPropertyName(index));
+ }
+
+ return result.ToArray();
+ }
+
+ /// Returns the source-level pass names in declaration order.
+ /// The shader to inspect.
+ /// The ordered pass names.
+ private static string[] GetPassNames(Shader shader)
+ {
+ var names = new List();
+ foreach (Match match in Regex.Matches(LoadGeneratedSource(shader.name), "\\bName\\s+\\\"([^\\\"]+)\\\""))
+ names.Add(match.Groups[1].Value);
+ return names.ToArray();
+ }
+
+ /// Loads the generated source subasset for one imported product shader without requesting a reimport.
+ /// The imported public shader name.
+ /// The non-empty generated source text.
+ private static string LoadGeneratedSource(string shaderName)
+ {
+ string path = null;
+ foreach (string guid in AssetDatabase.FindAssets("t:Shader", new[] { "Packages/jp.penguin.purebase/Shaders" }))
+ {
+ string candidate = AssetDatabase.GUIDToAssetPath(guid);
+ Shader shader = AssetDatabase.LoadAssetAtPath(candidate);
+ if (shader != null && string.Equals(shader.name, shaderName, StringComparison.Ordinal))
+ {
+ path = candidate;
+ break;
+ }
+ }
+
+ Assert.That(path, Is.Not.Empty, $"Could not locate the Shader-Core source asset for '{shaderName}'.");
+ foreach (UnityEngine.Object asset in AssetDatabase.LoadAllAssetsAtPath(path))
+ {
+ var source = asset as TextAsset;
+ if (source != null && string.Equals(source.name, "Shader Source", StringComparison.Ordinal))
+ return source.text;
+ }
+
+ Assert.Fail($"Shader-Core source asset '{path}' for '{shaderName}' has no generated Shader Source subasset.");
+ return null;
+ }
+
+ /// Finds one loaded type by its assembly-qualified full name.
+ /// The exact full type name to find.
+ /// The loaded type, or when no loaded assembly defines it.
+ private static Type FindLoadedType(string fullName)
+ {
+ foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ Type type = assembly.GetType(fullName, false);
+ if (type != null)
+ return type;
+ }
+
+ return null;
+ }
+
+ /// Asserts every hidden rendering state property for one expected mode.
+ /// The inspected material.
+ /// The expected rendering-mode state.
+ private static void AssertHiddenState(Material material, ModeContract mode)
+ {
+ Assert.That(material.HasProperty("_SrcBlend"), Is.True);
+ Assert.That(material.HasProperty("_DstBlend"), Is.True);
+ Assert.That(material.HasProperty("_ZWrite"), Is.True);
+ Assert.That(material.HasProperty("_AddSrcBlend"), Is.True);
+ Assert.That(material.HasProperty("_AddDstBlend"), Is.True);
+ Assert.That(material.GetFloat("_SrcBlend"), Is.EqualTo(mode.srcBlend));
+ Assert.That(material.GetFloat("_DstBlend"), Is.EqualTo(mode.dstBlend));
+ Assert.That(material.GetFloat("_ZWrite"), Is.EqualTo(mode.zWrite));
+ Assert.That(material.GetFloat("_AddSrcBlend"), Is.EqualTo(mode.addSrcBlend));
+ Assert.That(material.GetFloat("_AddDstBlend"), Is.EqualTo(mode.addDstBlend));
+ }
+
+ /// Asserts the exact enabled subset of the two rendering-mode local keywords.
+ /// The inspected material.
+ /// The expected enabled keyword names.
+ private static void AssertRenderingKeywords(Material material, string[] expected)
+ {
+ var actual = new List();
+ foreach (string keyword in RenderingModeKeywords)
+ {
+ if (material.IsKeywordEnabled(keyword))
+ actual.Add(keyword);
+ }
+
+ CollectionAssert.AreEquivalent(expected, actual);
+ }
+
+ /// Asserts every serializable state-table column for one material.
+ /// The inspected material.
+ /// The expected state-table row.
+ private static void AssertModeState(Material material, ModeContract mode)
+ {
+ Assert.That(material.GetInteger("_RenderingMode"), Is.EqualTo(mode.value));
+ AssertRenderTypeState(material, mode);
+ Assert.That(GetRawRenderQueue(material), Is.EqualTo(mode.rawQueue));
+ Assert.That(material.renderQueue, Is.EqualTo(mode.resolvedQueue));
+ AssertHiddenState(material, mode);
+ AssertRenderingKeywords(material, mode.enabledKeywords);
+ Assert.That(material.GetShaderPassEnabled("ShadowCaster"), Is.EqualTo(mode.enableContributionPasses));
+ Assert.That(material.GetShaderPassEnabled("Meta"), Is.EqualTo(mode.enableContributionPasses));
+ }
+
+ /// Asserts all noncanonical fields that the legacy fixture must preserve unchanged.
+ /// The captured legacy material state.
+ private static void AssertLegacyState(MaterialState state)
+ {
+ Assert.That(state.rawQueue, Is.EqualTo(2467));
+ Assert.That(state.hasRenderTypeOverride, Is.True);
+ Assert.That(state.renderTypeOverride, Is.EqualTo("LegacyCutout"));
+ CollectionAssert.AreEquivalent(new[] { "PUREBASE_LEGACY_UNRELATED" }, state.keywords);
+ Assert.That(state.shadowCasterEnabled, Is.True);
+ Assert.That(state.metaEnabled, Is.False);
+ Assert.That(state.dirty, Is.False);
+ }
+
+ /// Reads Unity's serialized raw queue without conflating it with the shader-resolved queue.
+ /// The material whose serialized queue is inspected.
+ /// The raw m_CustomRenderQueue value.
+ private static int GetRawRenderQueue(Material material)
+ {
+ var serializedMaterial = new SerializedObject(material);
+ SerializedProperty queue = serializedMaterial.FindProperty("m_CustomRenderQueue");
+ Assert.That(queue, Is.Not.Null, "Material serialization has no m_CustomRenderQueue property.");
+ return queue.intValue;
+ }
+
+ /// Asserts the serialized RenderType override separately from Unity's resolved shader tag.
+ /// The material whose RenderType state is inspected.
+ /// The expected rendering-mode state.
+ private static void AssertRenderTypeState(Material material, ModeContract mode)
+ {
+ bool hasOverride = TryGetSerializedRenderTypeOverride(material, out string renderTypeOverride);
+ Assert.That(hasOverride, Is.EqualTo(mode.hasRenderTypeOverride), mode.name + " RenderType override presence.");
+ if (hasOverride)
+ Assert.That(renderTypeOverride, Is.EqualTo(mode.renderTypeOverride), mode.name + " RenderType override.");
+ Assert.That(material.GetTag("RenderType", false), Is.EqualTo(mode.resolvedRenderType), mode.name + " resolved RenderType tag.");
+ }
+
+ /// Reads the raw RenderType override from Unity's serialized material tag map.
+ /// The material whose serialized tag map is inspected.
+ /// Receives the override value when it exists.
+ /// Whether the material serializes an explicit RenderType override.
+ private static bool TryGetSerializedRenderTypeOverride(Material material, out string renderTypeOverride)
+ {
+ string serializedMaterial = EditorJsonUtility.ToJson(material);
+ Match tagMap = Regex.Match(serializedMaterial, @"""stringTagMap""\s*:\s*\{(?[^}]*)\}");
+ Assert.That(tagMap.Success, Is.True, "Material serialization has no stringTagMap object.");
+ Match renderType = Regex.Match(tagMap.Groups["entries"].Value, @"""RenderType""\s*:\s*""(?[^""]*)""");
+ renderTypeOverride = renderType.Success ? renderType.Groups["value"].Value : null;
+ return renderType.Success;
+ }
+
+ /// Asserts the local rendering-mode feature ABI in each required generated shader pass.
+ /// The generated shader source.
+ /// The product shader name used in diagnostics.
+ private static void AssertRenderingModeKeywordDeclarations(string source, string shaderName)
+ {
+ var declaredKeywords = new HashSet(StringComparer.Ordinal);
+ foreach (Match declaration in Regex.Matches(source, @"^\s*#pragma\s+shader_feature_local\s+([^\r\n]+)", RegexOptions.Multiline))
+ {
+ foreach (Match keyword in Regex.Matches(declaration.Groups[1].Value, @"\bPUREBASE_RENDERING_[A-Z0-9_]+\b"))
+ declaredKeywords.Add(keyword.Value);
+ }
+
+ CollectionAssert.AreEquivalent(
+ RenderingModeKeywords,
+ declaredKeywords,
+ $"Product shader '{shaderName}' must declare exactly the Opaque and Transparent rendering-mode local keywords."
+ );
+ foreach (string passName in PassNames)
+ {
+ Assert.That(
+ Regex.IsMatch(
+ source,
+ "HLSLINCLUDE[\\s\\S]*?#pragma\\s+shader_feature_local\\s+(?:_\\s+)?PUREBASE_RENDERING_OPAQUE\\s+PUREBASE_RENDERING_TRANSPARENT[\\s\\S]*?ENDHLSL[\\s\\S]*?Name\\s+\\\"" + Regex.Escape(passName) + "\\\""
+ ),
+ Is.True,
+ $"Product shader '{shaderName}' pass '{passName}' must inherit the rendering-mode local shader feature from the shared HLSLINCLUDE block."
+ );
+ }
+ }
+
+ /// Stores the public shader identity and visible property ABI for one product.
+ private sealed class ProductContract
+ {
+ /// Initializes one immutable product contract.
+ /// The stable public shader name.
+ /// The ordered visible property ABI.
+ public ProductContract(string shaderName, string propertySourcePath, string[] visiblePropertyNames)
+ {
+ this.shaderName = shaderName;
+ this.propertySourcePath = propertySourcePath;
+ this.visiblePropertyNames = visiblePropertyNames;
+ }
+
+ /// Stores the stable public shader name.
+ public readonly string shaderName;
+
+ /// Stores the property source used to generate the product ShaderLab declaration.
+ public readonly string propertySourcePath;
+
+ /// Stores the ordered visible property ABI.
+ public readonly string[] visiblePropertyNames;
+ }
+
+ /// Stores one complete, immutable rendering-mode state-table row.
+ private sealed class ModeContract
+ {
+ /// Initializes one immutable state-table row.
+ public ModeContract(int value, string name, BlendState blend, RenderTypeState renderType, QueueState queue, string[] enabledKeywords, bool enableContributionPasses)
+ {
+ this.value = value;
+ this.name = name;
+ srcBlend = blend.srcBlend;
+ dstBlend = blend.dstBlend;
+ zWrite = blend.zWrite;
+ addSrcBlend = blend.addSrcBlend;
+ addDstBlend = blend.addDstBlend;
+ renderTypeOverride = renderType.renderTypeOverride;
+ hasRenderTypeOverride = renderType.hasRenderTypeOverride;
+ resolvedRenderType = renderType.resolvedRenderType;
+ rawQueue = queue.rawQueue;
+ resolvedQueue = queue.resolvedQueue;
+ this.enabledKeywords = enabledKeywords;
+ this.enableContributionPasses = enableContributionPasses;
+ }
+
+ /// Stores the serialized mode value.
+ public readonly int value;
+
+ /// Stores the diagnostic mode name.
+ public readonly string name;
+
+ /// Stores the ForwardBase source blend value.
+ public readonly int srcBlend;
+
+ /// Stores the ForwardBase destination blend value.
+ public readonly int dstBlend;
+
+ /// Stores the ForwardBase depth-write value.
+ public readonly int zWrite;
+
+ /// Stores the ForwardAdd source blend value.
+ public readonly int addSrcBlend;
+
+ /// Stores the ForwardAdd destination blend value.
+ public readonly int addDstBlend;
+
+ /// Stores the material RenderType override.
+ public readonly string renderTypeOverride;
+
+ /// Stores whether the material serializes an explicit RenderType override.
+ public readonly bool hasRenderTypeOverride;
+
+ /// Stores the shader-resolved RenderType tag.
+ public readonly string resolvedRenderType;
+
+ /// Stores the raw material render queue.
+ public readonly int rawQueue;
+
+ /// Stores the resolved render queue.
+ public readonly int resolvedQueue;
+
+ /// Stores the exact enabled local keywords.
+ public readonly string[] enabledKeywords;
+
+ /// Stores whether ShadowCaster and Meta are enabled.
+ public readonly bool enableContributionPasses;
+ }
+
+ /// Stores the blend state columns for one rendering-mode state-table row.
+ private sealed class BlendState
+ {
+ /// Initializes one immutable blend-state value group.
+ public BlendState(int srcBlend, int dstBlend, int zWrite, int addSrcBlend, int addDstBlend)
+ {
+ this.srcBlend = srcBlend;
+ this.dstBlend = dstBlend;
+ this.zWrite = zWrite;
+ this.addSrcBlend = addSrcBlend;
+ this.addDstBlend = addDstBlend;
+ }
+
+ /// Stores the ForwardBase source blend value.
+ public readonly int srcBlend;
+
+ /// Stores the ForwardBase destination blend value.
+ public readonly int dstBlend;
+
+ /// Stores the ForwardBase depth-write value.
+ public readonly int zWrite;
+
+ /// Stores the ForwardAdd source blend value.
+ public readonly int addSrcBlend;
+
+ /// Stores the ForwardAdd destination blend value.
+ public readonly int addDstBlend;
+ }
+
+ /// Stores the RenderType state columns for one rendering-mode state-table row.
+ private sealed class RenderTypeState
+ {
+ /// Initializes one immutable RenderType-state value group.
+ public RenderTypeState(string renderTypeOverride, bool hasRenderTypeOverride, string resolvedRenderType)
+ {
+ this.renderTypeOverride = renderTypeOverride;
+ this.hasRenderTypeOverride = hasRenderTypeOverride;
+ this.resolvedRenderType = resolvedRenderType;
+ }
+
+ /// Stores the material RenderType override.
+ public readonly string renderTypeOverride;
+
+ /// Stores whether the material serializes an explicit RenderType override.
+ public readonly bool hasRenderTypeOverride;
+
+ /// Stores the shader-resolved RenderType tag.
+ public readonly string resolvedRenderType;
+ }
+
+ /// Stores the queue state columns for one rendering-mode state-table row.
+ private sealed class QueueState
+ {
+ /// Initializes one immutable queue-state value group.
+ public QueueState(int rawQueue, int resolvedQueue)
+ {
+ this.rawQueue = rawQueue;
+ this.resolvedQueue = resolvedQueue;
+ }
+
+ /// Stores the raw material render queue.
+ public readonly int rawQueue;
+
+ /// Stores the shader-resolved render queue.
+ public readonly int resolvedQueue;
+ }
+
+}
+ }
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.Support.cs.meta b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.Support.cs.meta
new file mode 100644
index 00000000..970ddfe9
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.Support.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: bc53bb2433d9eb141a47144456185936
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.cs b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.cs
new file mode 100644
index 00000000..9a790698
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.cs
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+
+// Declares the rendering-mode contract test fixture across cohesive partial test sources.
+
+namespace PureBase.Tests.Daily
+{
+ /// Defines Editor-side rendering-mode contracts before the product normalizer is implemented.
+ public sealed partial class PureBaseRenderingModeContractTests
+ {
+ }
+}
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeContractTests.cs.meta b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.cs.meta
new file mode 100644
index 00000000..b396a076
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeContractTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0b96f476ec95b9e4e84195b929e34a69
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.FrameReadbacks.cs b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.FrameReadbacks.cs
new file mode 100644
index 00000000..f6175cad
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.FrameReadbacks.cs
@@ -0,0 +1,435 @@
+/*
+ * 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 numeric alpha and depth observations that render and read transient frames.
+
+using NUnit.Framework;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+using UnityEngine.Rendering;
+using UnityEngine.SceneManagement;
+
+namespace PureBase.Tests.Daily
+{
+ public sealed partial class PureBaseRenderingModeRenderingTests
+ {
+ /// Defines finite, threshold, and numeric alpha metrics for the future BIRP mode rendering observations.
+ [Test]
+ public void NumericObservationMetricsRejectOpaqueAlphaLeakCutoutLeakAndTransparentDepthOrAddAlphaErrors()
+ {
+ Shader shader = RequireProductShader("PureBase/Unlit");
+ var opaque = CreateConfiguredMaterial(shader, 0, new Color(0.8f, 0.2f, 0.1f, 0.1f));
+ var cutoutBelow = CreateConfiguredMaterial(shader, 1, new Color(0.8f, 0.2f, 0.1f, 0.25f));
+ var transparent = CreateConfiguredMaterial(shader, 2, new Color(0.8f, 0.2f, 0.1f, 0.25f));
+ {
+ RequireRenderingModeProperty(opaque);
+ Color opaquePixel = RenderCenterPixel(opaque, Color.clear);
+ Color cutoutPixel = RenderCenterPixel(cutoutBelow, Color.clear);
+ Color transparentPixel = RenderCenterPixel(transparent, Color.clear);
+ AssertFinite(opaquePixel, "Opaque readback");
+ AssertFinite(cutoutPixel, "Cutout readback");
+ AssertFinite(transparentPixel, "Transparent readback");
+ Assert.That(opaquePixel.a, Is.GreaterThan(0.95f), "Opaque output must ignore base alpha.");
+ Assert.That(cutoutPixel.a, Is.LessThan(0.02f), "Cutout coverage below _Cutoff must not contribute.");
+ Assert.That(
+ transparentPixel.a,
+ Is.EqualTo(0.0625f).Within(0.005f),
+ "Transparent source alpha 0.25 over clear destination alpha 0 must use standard alpha blending: 0.25 * 0.25."
+ );
+ }
+ }
+
+ /// Requires Transparent material sorting to produce the expected finite back-to-front two-layer readback without depth writes.
+ [Test]
+ public void TransparentDepthOrderingUsesBackToFrontCompositionWithoutDepthWrite()
+ {
+ Shader shader = RequireProductShader("PureBase/Unlit");
+ var red = CreateConfiguredMaterial(shader, 2, new Color(1.0f, 0.0f, 0.0f, 0.25f));
+ var blue = CreateConfiguredMaterial(shader, 2, new Color(0.0f, 0.0f, 1.0f, 0.25f));
+ {
+ Color redInFront = RenderLayeredCenterPixel(red, blue);
+ Color blueInFront = RenderLayeredCenterPixel(blue, red);
+ AssertFinite(redInFront, "Red-front transparent depth readback");
+ AssertFinite(blueInFront, "Blue-front transparent depth readback");
+ Assert.That(redInFront.r, Is.EqualTo(0.25f).Within(0.05f));
+ Assert.That(redInFront.b, Is.EqualTo(0.1875f).Within(0.05f));
+ Assert.That(blueInFront.b, Is.EqualTo(0.25f).Within(0.05f));
+ Assert.That(blueInFront.r, Is.EqualTo(0.1875f).Within(0.05f));
+ Assert.That(redInFront.r, Is.GreaterThan(redInFront.b + 0.02f));
+ Assert.That(blueInFront.b, Is.GreaterThan(blueInFront.r + 0.02f));
+ }
+ }
+
+ /// Requires Transparent ForwardBase to leave depth unchanged so an explicitly later opaque marker behind it remains visible.
+ [Test]
+ public void TransparentDepthWriteDoesNotOccludeAnExplicitlyLaterOpaqueMarker()
+ {
+ Shader transparentShader = RequireProductShader("PureBase/Unlit");
+ Shader markerShader = Shader.Find("Unlit/Color");
+ Assert.That(markerShader, Is.Not.Null, "The Built-in Unlit/Color shader is unavailable for the Transparent depth-write probe.");
+ var transparent = CreateConfiguredMaterial(transparentShader, 2, new Color(1.0f, 0.0f, 0.0f, 0.25f));
+ var marker = CreateMaterial(markerShader);
+ {
+ marker.SetColor("_Color", Color.green);
+ Color observed = RenderTransparentThenOpaqueDepthProbe(transparent, marker);
+ AssertFinite(observed, "Transparent explicit-depth probe readback");
+ Assert.That(
+ observed.g,
+ Is.GreaterThan(0.85f),
+ "Transparent ZWrite Off must allow the explicitly later opaque marker behind the transparent surface to pass depth."
+ );
+ Assert.That(
+ observed.r,
+ Is.LessThan(0.08f),
+ "The later opaque marker must replace the transparent probe color when Transparent does not write depth."
+ );
+ }
+ }
+
+ /// Renders an isolated directional-light fixture with and without shadows and returns the measured receiver silhouette.
+ /// The configured material assigned to the shadow caster.
+ /// The controlled actual ShadowCaster readback.
+ private static ShadowReadback RenderShadowReadback(Material material)
+ {
+ var fixture = new ShadowReadbackFixture();
+ try
+ {
+ fixture.Initialize(material);
+ return fixture.Render();
+ }
+ finally
+ {
+ fixture.Dispose();
+ }
+ }
+
+ /// Owns the temporary preview-scene resources for one ShadowCaster readback.
+ private sealed class ShadowReadbackFixture : System.IDisposable
+ {
+ private const int FixtureLayer = 31;
+ private Scene scene;
+ private GameObject cameraObject;
+ private GameObject lightObject;
+ private GameObject receiver;
+ private GameObject caster;
+ private Material receiverMaterial;
+ private RenderTexture renderTexture;
+ private Texture2D texture;
+
+ /// Initializes an allocation-free ShadowCaster readback fixture.
+ public ShadowReadbackFixture()
+ {
+ }
+
+ /// Allocates and configures the ShadowCaster readback fixture.
+ /// The material assigned to the caster.
+ public void Initialize(Material material)
+ {
+ scene = EditorSceneManager.NewPreviewScene();
+ CreateResources();
+ MoveObjectsToFixtureScene();
+ ConfigureCamera();
+ ConfigureLight();
+ ConfigureReceiver();
+ ConfigureCaster(material);
+ renderTexture.Create();
+ }
+
+ /// Captures the receiver with shadows disabled and enabled.
+ /// The measured ShadowCaster silhouette.
+ public ShadowReadback Render()
+ {
+ Camera camera = cameraObject.GetComponent();
+ Light light = lightObject.GetComponent();
+ light.shadows = LightShadows.None;
+ camera.Render();
+ Color[] withoutShadows = ReadPixels(renderTexture, texture);
+ light.shadows = LightShadows.Hard;
+ camera.Render();
+ Color[] withShadows = ReadPixels(renderTexture, texture);
+ return AnalyzeShadowReadback(withoutShadows, withShadows);
+ }
+
+ /// Releases every preview-scene resource in its original ownership order.
+ public void Dispose()
+ {
+ Camera camera = cameraObject != null ? cameraObject.GetComponent() : null;
+ if (camera != null)
+ camera.targetTexture = null;
+ if (texture != null)
+ UnityEngine.Object.DestroyImmediate(texture);
+ if (renderTexture != null)
+ {
+ renderTexture.Release();
+ UnityEngine.Object.DestroyImmediate(renderTexture);
+ }
+ if (receiverMaterial != null)
+ UnityEngine.Object.DestroyImmediate(receiverMaterial);
+ if (caster != null)
+ UnityEngine.Object.DestroyImmediate(caster);
+ if (receiver != null)
+ UnityEngine.Object.DestroyImmediate(receiver);
+ if (lightObject != null)
+ UnityEngine.Object.DestroyImmediate(lightObject);
+ if (cameraObject != null)
+ UnityEngine.Object.DestroyImmediate(cameraObject);
+ if (scene.IsValid() && scene.isLoaded)
+ EditorSceneManager.ClosePreviewScene(scene);
+ }
+
+ /// Allocates every temporary Unity resource used by the fixture.
+ private void CreateResources()
+ {
+ cameraObject = new GameObject("PureBaseRenderingModeShadowCamera");
+ lightObject = new GameObject("PureBaseRenderingModeShadowLight");
+ receiver = GameObject.CreatePrimitive(PrimitiveType.Plane);
+ caster = GameObject.CreatePrimitive(PrimitiveType.Cube);
+ Shader receiverShader = Shader.Find("Standard");
+ Assert.That(receiverShader, Is.Not.Null, "The Built-in Standard shader is unavailable for ShadowCaster readback.");
+ receiverMaterial = new Material(receiverShader);
+ renderTexture = new RenderTexture(RenderSize, RenderSize, 24, RenderTextureFormat.ARGBFloat);
+ texture = new Texture2D(RenderSize, RenderSize, TextureFormat.RGBAFloat, false, true);
+ }
+
+ /// Moves every fixture object into the isolated preview scene and layer.
+ private void MoveObjectsToFixtureScene()
+ {
+ SceneManager.MoveGameObjectToScene(cameraObject, scene);
+ SceneManager.MoveGameObjectToScene(lightObject, scene);
+ SceneManager.MoveGameObjectToScene(receiver, scene);
+ SceneManager.MoveGameObjectToScene(caster, scene);
+ cameraObject.layer = FixtureLayer;
+ lightObject.layer = FixtureLayer;
+ receiver.layer = FixtureLayer;
+ caster.layer = FixtureLayer;
+ }
+
+ /// Configures the isolated ShadowCaster camera.
+ private void ConfigureCamera()
+ {
+ Camera camera = cameraObject.AddComponent();
+ camera.enabled = false;
+ camera.cullingMask = 1 << FixtureLayer;
+ camera.overrideSceneCullingMask = EditorSceneManager.GetSceneCullingMask(scene);
+ camera.clearFlags = CameraClearFlags.SolidColor;
+ camera.backgroundColor = new Color(0.02f, 0.025f, 0.03f, 1.0f);
+ camera.transform.position = new Vector3(0.0f, 3.0f, -7.0f);
+ camera.transform.LookAt(new Vector3(0.0f, 0.5f, 0.0f));
+ camera.fieldOfView = 45.0f;
+ camera.targetTexture = renderTexture;
+ }
+
+ /// Configures the directional light used by the ShadowCaster fixture.
+ private void ConfigureLight()
+ {
+ Light light = lightObject.AddComponent();
+ light.type = LightType.Directional;
+ light.intensity = 1.5f;
+ light.cullingMask = 1 << FixtureLayer;
+ light.shadows = LightShadows.Hard;
+ lightObject.transform.rotation = Quaternion.Euler(55.0f, -35.0f, 0.0f);
+ }
+
+ /// Configures the receiver plane for directional-shadow measurements.
+ private void ConfigureReceiver()
+ {
+ receiver.transform.localScale = Vector3.one * 0.8f;
+ receiver.GetComponent().sharedMaterial = receiverMaterial;
+ }
+
+ /// Configures the measured caster with its effective ShadowCaster state.
+ /// The source material.
+ private void ConfigureCaster(Material material)
+ {
+ caster.transform.position = new Vector3(0.0f, 1.0f, 0.0f);
+ MeshRenderer casterRenderer = caster.GetComponent();
+ casterRenderer.sharedMaterial = material;
+ casterRenderer.shadowCastingMode = material.GetShaderPassEnabled("ShadowCaster")
+ ? ShadowCastingMode.ShadowsOnly
+ : ShadowCastingMode.Off;
+ }
+ }
+
+ /// Renders the actual Meta pass into a linear target and returns its center pixel without changing persistent assets.
+ /// The configured source material.
+ /// The linear Meta center readback.
+ private static Color RenderMetaCenterPixel(Material material)
+ {
+ MetaGlobalState globalState = MetaGlobalState.Capture();
+ GameObject cameraObject = null;
+ GameObject quadObject = null;
+ RenderTexture renderTexture = null;
+ Texture2D texture = null;
+ CommandBuffer commandBuffer = null;
+ try
+ {
+ return RenderMetaReadback(material, out cameraObject, out quadObject, out renderTexture, out texture, out commandBuffer);
+ }
+ finally
+ {
+ globalState.Restore();
+ ReleaseMetaReadbackResources(cameraObject, quadObject, renderTexture, texture, commandBuffer);
+ }
+ }
+
+ /// Creates and executes the actual Meta-pass command-buffer readback.
+ private static Color RenderMetaReadback(Material material, out GameObject cameraObject, out GameObject quadObject, out RenderTexture renderTexture, out Texture2D texture, out CommandBuffer commandBuffer)
+ {
+ cameraObject = new GameObject("PureBaseRenderingModeMetaCamera");
+ quadObject = GameObject.CreatePrimitive(PrimitiveType.Quad);
+ renderTexture = new RenderTexture(RenderSize, RenderSize, 24, RenderTextureFormat.ARGBFloat);
+ texture = new Texture2D(RenderSize, RenderSize, TextureFormat.RGBAFloat, false, true);
+ commandBuffer = new CommandBuffer { name = "PureBase Rendering Mode Meta Readback" };
+ int pass = material.FindPass("Meta");
+ Assert.That(pass, Is.GreaterThanOrEqualTo(0), "The material must expose an actual Meta pass.");
+ Camera camera = cameraObject.AddComponent();
+ camera.enabled = false;
+ camera.cullingMask = 0;
+ camera.orthographic = true;
+ camera.orthographicSize = 1.0f;
+ camera.transform.position = new Vector3(0.0f, 0.0f, -5.0f);
+ camera.targetTexture = renderTexture;
+ renderTexture.Create();
+ ApplyMetaGlobals();
+ commandBuffer.SetRenderTarget(renderTexture);
+ commandBuffer.ClearRenderTarget(true, true, Color.clear);
+ if (material.GetShaderPassEnabled("Meta"))
+ commandBuffer.DrawMesh(quadObject.GetComponent().sharedMesh, Matrix4x4.identity, material, 0, pass);
+ camera.AddCommandBuffer(CameraEvent.BeforeImageEffects, commandBuffer);
+ camera.Render();
+ return ReadCenterPixel(renderTexture, texture);
+ }
+
+ /// Sets the Meta pass globals required for the controlled albedo readback.
+ private static void ApplyMetaGlobals()
+ {
+ Shader.SetGlobalVector("unity_MetaVertexControl", new Vector4(1.0f, 0.0f, 0.0f, 0.0f));
+ Shader.SetGlobalVector("unity_MetaFragmentControl", new Vector4(1.0f, 0.0f, 0.0f, 0.0f));
+ Shader.SetGlobalVector("unity_LightmapST", new Vector4(1.0f, 1.0f, 0.0f, 0.0f));
+ Shader.SetGlobalFloat("unity_OneOverOutputBoost", 1.0f);
+ Shader.SetGlobalFloat("unity_MaxOutputValue", 1.0f);
+ }
+
+ /// Releases the Meta command buffer and transient rendering resources.
+ private static void ReleaseMetaReadbackResources(GameObject cameraObject, GameObject quadObject, RenderTexture renderTexture, Texture2D texture, CommandBuffer commandBuffer)
+ {
+ Camera camera = cameraObject != null ? cameraObject.GetComponent() : null;
+ if (camera != null && commandBuffer != null)
+ camera.RemoveCommandBuffer(CameraEvent.BeforeImageEffects, commandBuffer);
+ if (commandBuffer != null)
+ commandBuffer.Release();
+ ReleaseQuadReadbackResources(cameraObject, quadObject, camera, renderTexture, texture);
+ }
+
+ /// Captures the global state modified by one Meta-pass readback.
+ private sealed class MetaGlobalState
+ {
+ private readonly Vector4 vertexControl;
+ private readonly Vector4 fragmentControl;
+ private readonly Vector4 lightmapSt;
+ private readonly float outputBoost;
+ private readonly float maxOutput;
+
+ public MetaGlobalState(Vector4 vertexControl, Vector4 fragmentControl, Vector4 lightmapSt, float outputBoost, float maxOutput)
+ {
+ this.vertexControl = vertexControl;
+ this.fragmentControl = fragmentControl;
+ this.lightmapSt = lightmapSt;
+ this.outputBoost = outputBoost;
+ this.maxOutput = maxOutput;
+ }
+
+ /// Captures the current Meta globals before the temporary readback mutates them.
+ /// The state to restore.
+ public static MetaGlobalState Capture()
+ {
+ return new MetaGlobalState(
+ Shader.GetGlobalVector("unity_MetaVertexControl"),
+ Shader.GetGlobalVector("unity_MetaFragmentControl"),
+ Shader.GetGlobalVector("unity_LightmapST"),
+ Shader.GetGlobalFloat("unity_OneOverOutputBoost"),
+ Shader.GetGlobalFloat("unity_MaxOutputValue")
+ );
+ }
+
+ /// Restores the Meta globals in their original mutation order.
+ public void Restore()
+ {
+ Shader.SetGlobalVector("unity_MetaVertexControl", vertexControl);
+ Shader.SetGlobalVector("unity_MetaFragmentControl", fragmentControl);
+ Shader.SetGlobalVector("unity_LightmapST", lightmapSt);
+ Shader.SetGlobalFloat("unity_OneOverOutputBoost", outputBoost);
+ Shader.SetGlobalFloat("unity_MaxOutputValue", maxOutput);
+ }
+ }
+
+ /// Reads the center pixel of an already-rendered target without changing the active render target after completion.
+ /// The source render target.
+ /// The transient readback texture.
+ /// The center pixel.
+ private static Color ReadCenterPixel(RenderTexture renderTexture, Texture2D texture)
+ {
+ ReadPixels(renderTexture, texture);
+ return texture.GetPixel(RenderSize / 2, RenderSize / 2);
+ }
+
+ /// Reads every pixel from one target while restoring the previous active render target.
+ /// The source render target.
+ /// The transient readback texture.
+ /// The copied target pixels.
+ private static Color[] ReadPixels(RenderTexture renderTexture, Texture2D texture)
+ {
+ RenderTexture previous = RenderTexture.active;
+ try
+ {
+ RenderTexture.active = renderTexture;
+ texture.ReadPixels(new Rect(0, 0, RenderSize, RenderSize), 0, 0);
+ texture.Apply(false, false);
+ return texture.GetPixels();
+ }
+ finally
+ {
+ RenderTexture.active = previous;
+ }
+ }
+
+ /// Measures the maximum RGB delta and changed-pixel count caused by directional shadows.
+ /// The unshadowed readback pixels.
+ /// The shadowed readback pixels.
+ /// The measured directional-shadow silhouette.
+ private static ShadowReadback AnalyzeShadowReadback(Color[] withoutShadows, Color[] withShadows)
+ {
+ Assert.That(withShadows.Length, Is.EqualTo(withoutShadows.Length), "Directional-shadow readbacks must have matching dimensions.");
+ var changedPixelCount = 0;
+ var maxAbsoluteRgbDelta = 0.0f;
+ for (int index = 0; index < withoutShadows.Length; index++)
+ {
+ Color delta = withoutShadows[index] - withShadows[index];
+ float maximumAbsoluteDelta = Mathf.Max(
+ Mathf.Abs(delta.r),
+ Mathf.Max(Mathf.Abs(delta.g), Mathf.Abs(delta.b))
+ );
+ if (maximumAbsoluteDelta > ShadowPixelNoiseThreshold)
+ changedPixelCount++;
+ if (maximumAbsoluteDelta > maxAbsoluteRgbDelta)
+ maxAbsoluteRgbDelta = maximumAbsoluteDelta;
+ }
+
+ return new ShadowReadback(maxAbsoluteRgbDelta, changedPixelCount);
+ }
+ }
+}
+
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.FrameReadbacks.cs.meta b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.FrameReadbacks.cs.meta
new file mode 100644
index 00000000..46710343
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.FrameReadbacks.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 6c62001d5c572b5478076aca55a015ca
+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
new file mode 100644
index 00000000..b3c6edda
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs
@@ -0,0 +1,110 @@
+/*
+ * 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 source-order contracts for the rendering-mode BIRP integration.
+
+using System;
+using System.IO;
+using System.Text.RegularExpressions;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+
+namespace PureBase.Tests.Daily
+{
+ public sealed partial class PureBaseRenderingModeRenderingTests
+ {
+ /// Identifies the common BIRP fragment host whose ordering is part of the generated-source ABI.
+ private const string BirpHostPath = "Packages/jp.penguin.purebase/Shaders/Common/birp_host.hlsl";
+
+ /// Identifies the shared rendering-mode helper that owns mode clip and output-alpha semantics.
+ private const string RenderingModeHelperPath = "Packages/jp.penguin.purebase/Shaders/Common/rendering_mode.hlsl";
+
+ /// Identifies the shared operation that publishes the mode-specific output alpha.
+ private const string RenderingModeOutputAlphaOperation = "PureBaseApplyRenderingModeOutputAlpha";
+
+ /// Identifies the rendering-mode keyword whose output alpha preserves coverage.
+ private const string TransparentRenderingModeKeyword = "PUREBASE_RENDERING_TRANSPARENT";
+
+ /// Identifies the release-only postpixel alpha probe source.
+ private const string PostPixelProbePath = "Packages/jp.penguin.purebase/Tests/Release/Modules/Standard/PostPixel/phase_postpixel.hlsl";
+
+ /// Requires the shared mode-alpha helper to run after add and before fog, postpixel, and return.
+ [Test]
+ public void BirpHostPreservesModeAlphaFogPostPixelAndForwardAddSourceOrder()
+ {
+ string host = File.ReadAllText(BirpHostPath);
+ string renderingModeHelper = File.ReadAllText(RenderingModeHelperPath);
+ int addPhase = RequireIndex(host, "__SC_PHASE_add__");
+ Match modeOutputAlphaCall = Regex.Match(host, @"\b" + Regex.Escape(RenderingModeOutputAlphaOperation) + @"\s*\(");
+ Assert.That(modeOutputAlphaCall.Success, Is.True, "The BIRP host must call the shared rendering-mode output-alpha operation.");
+ int modeOutputAlpha = modeOutputAlphaCall.Index;
+ int fog = RequireIndex(host, "UNITY_APPLY_FOG");
+ int postPixel = RequireIndex(host, "__SC_PHASE_postpixel__");
+ int returnStatement = RequireIndex(host, "return sd.col;");
+ StringAssert.Contains("#include \"Packages/jp.penguin.purebase/Shaders/Common/rendering_mode.hlsl\"", host);
+ Assert.That(modeOutputAlpha, Is.GreaterThan(addPhase), "The shared mode-alpha helper must run after the add phase.");
+ Assert.That(modeOutputAlpha, Is.LessThan(fog), "The shared mode-alpha helper must run before fog.");
+ Assert.That(fog, Is.LessThan(postPixel), "Fog must occur before postpixel.");
+ Assert.That(postPixel, Is.LessThan(returnStatement), "Postpixel must remain the final color mutation point before return.");
+ StringAssert.Contains(RenderingModeOutputAlphaOperation, renderingModeHelper);
+ StringAssert.Contains(TransparentRenderingModeKeyword, renderingModeHelper);
+ StringAssert.Contains("coverage", renderingModeHelper);
+ StringAssert.Contains(".a", renderingModeHelper);
+ Assert.That(Regex.IsMatch(renderingModeHelper, @"\b1(?:\.0+)?\b"), Is.True, "The shared helper must distinguish Transparent coverage alpha from Opaque and Cutout alpha one.");
+ string generatedProductSource = LoadProductSource("PureBase/Toon");
+ Assert.That(Regex.IsMatch(generatedProductSource, @"\b" + Regex.Escape(RenderingModeOutputAlphaOperation) + @"\s*\("), Is.True, "The generated product source must retain the shared rendering-mode output-alpha operation.");
+ StringAssert.Contains("Blend [_AddSrcBlend] [_AddDstBlend]", generatedProductSource);
+ StringAssert.Contains("ColorMask RGB", generatedProductSource);
+ StringAssert.Contains("sd.col.a = half(0.25)", File.ReadAllText(PostPixelProbePath));
+ }
+
+ /// Loads one generated product source subasset without modifying its import state.
+ /// The product shader name.
+ /// The generated source text.
+ private static string LoadProductSource(string shaderName)
+ {
+ foreach (string guid in AssetDatabase.FindAssets("t:Shader", new[] { "Packages/jp.penguin.purebase/Shaders" }))
+ {
+ string path = AssetDatabase.GUIDToAssetPath(guid);
+ Shader shader = AssetDatabase.LoadAssetAtPath(path);
+ if (shader == null || !string.Equals(shader.name, shaderName, StringComparison.Ordinal))
+ continue;
+ foreach (UnityEngine.Object asset in AssetDatabase.LoadAllAssetsAtPath(path))
+ {
+ var source = asset as TextAsset;
+ if (source != null && source.name == "Shader Source")
+ return source.text;
+ }
+ }
+
+ Assert.Fail("Generated source for product shader '" + shaderName + "' was unavailable.");
+ return null;
+ }
+
+ /// Returns one required marker index with a diagnostic that keeps source-order failures local.
+ /// The source text to inspect.
+ /// The required marker.
+ /// The marker index.
+ private static int RequireIndex(string source, string marker)
+ {
+ int index = source.IndexOf(marker, StringComparison.Ordinal);
+ Assert.That(index, Is.GreaterThanOrEqualTo(0), "Required source marker '" + marker + "' was absent.");
+ return index;
+ }
+ }
+}
+
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs.meta b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs.meta
new file mode 100644
index 00000000..24173ac9
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 08988dfcae7e08d42af26f762553d370
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.cs b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.cs
new file mode 100644
index 00000000..05d760f4
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.cs
@@ -0,0 +1,605 @@
+/*
+ * 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 source-order and BIRP numeric rendering contracts for rendering-mode alpha, depth, lighting, ShadowCaster, and Meta behavior.
+
+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
+{
+ /// Defines focused BIRP rendering-mode observations without changing canonical scenes or baselines.
+ public sealed partial class PureBaseRenderingModeRenderingTests
+ {
+ /// Tracks transient materials so rendering observations release every native Unity object they allocate.
+ private readonly List transientMaterials = new List();
+
+ /// Defines the small readback dimension used by transient numeric observations.
+ private const int RenderSize = 64;
+
+ /// Defines the largest per-channel readback difference treated as directional-shadow noise.
+ private const float ShadowPixelNoiseThreshold = 0.002f;
+
+ /// Defines the minimum changed-pixel count required for a meaningful directional-shadow silhouette.
+ private const int MinimumShadowSilhouettePixelCount = 32;
+
+ /// Requires representative Opaque, Cutout, and Transparent material state before fragile BIRP observations execute.
+ [Test]
+ public void RepresentativeModesHaveNumericAlphaDepthAndContributionObservationPreconditions()
+ {
+ Shader unlit = RequireProductShader("PureBase/Unlit");
+ Shader toon = RequireProductShader("PureBase/Toon");
+ var opaque = CreateMaterial(unlit);
+ var cutout = CreateMaterial(unlit);
+ var transparent = CreateMaterial(unlit);
+ var transparentToon = CreateMaterial(toon);
+ {
+ RequireRenderingModeProperty(opaque);
+ ConfigureMode(opaque, 0);
+ ConfigureMode(cutout, 1);
+ ConfigureMode(transparent, 2);
+ ConfigureMode(transparentToon, 2);
+
+ Assert.That(opaque.GetFloat("_ZWrite"), Is.EqualTo(1.0f));
+ Assert.That(cutout.GetFloat("_ZWrite"), Is.EqualTo(1.0f));
+ Assert.That(transparent.GetFloat("_ZWrite"), Is.EqualTo(0.0f));
+ Assert.That(transparent.GetFloat("_AddSrcBlend"), Is.EqualTo((float)BlendMode.SrcAlpha));
+ Assert.That(transparentToon.GetShaderPassEnabled("ShadowCaster"), Is.False);
+ Assert.That(transparentToon.GetShaderPassEnabled("Meta"), Is.False);
+ }
+ }
+
+ /// Requires controlled numeric ShadowCaster and Meta readbacks for all three rendering-mode contribution boundaries.
+ [Test]
+ public void OpaqueCutoutAndTransparentModesHaveObservedShadowCasterAndMetaContributions()
+ {
+ Shader shader = RequireProductShader("PureBase/Unlit");
+ Color contributingBaseColor = new Color(0.8f, 0.2f, 0.1f, 1.0f);
+ var opaque = CreateConfiguredMaterial(shader, 0, contributingBaseColor);
+ var cutout = CreateConfiguredMaterial(shader, 1, contributingBaseColor);
+ var cutoutBelow = CreateConfiguredMaterial(shader, 1, new Color(0.8f, 0.2f, 0.1f, 0.25f));
+ var transparent = CreateConfiguredMaterial(shader, 2, new Color(0.8f, 0.2f, 0.1f, 0.25f));
+ {
+ AssertShadowContributions(opaque, cutout, cutoutBelow, transparent);
+ AssertMetaContributions(opaque, cutout, transparent, contributingBaseColor.linear);
+ }
+ }
+
+ /// Asserts ShadowCaster enablement and measured contribution boundaries for all rendering modes.
+ private static void AssertShadowContributions(Material opaque, Material cutout, Material cutoutBelow, Material transparent)
+ {
+ Assert.That(opaque.GetShaderPassEnabled("ShadowCaster"), Is.True, "Opaque ShadowCaster must be enabled before its silhouette is observed.");
+ Assert.That(cutout.GetShaderPassEnabled("ShadowCaster"), Is.True, "Cutout ShadowCaster must be enabled before its silhouette is observed.");
+ Assert.That(cutoutBelow.GetShaderPassEnabled("ShadowCaster"), Is.True, "Cutout below-cutoff ShadowCaster must remain enabled so clip behavior is observed at runtime.");
+ Assert.That(transparent.GetShaderPassEnabled("ShadowCaster"), Is.False, "Transparent ShadowCaster must be disabled before its missing silhouette is observed.");
+ ShadowReadback opaqueShadow = RenderShadowReadback(opaque);
+ ShadowReadback cutoutShadow = RenderShadowReadback(cutout);
+ ShadowReadback cutoutBelowShadow = RenderShadowReadback(cutoutBelow);
+ ShadowReadback transparentShadow = RenderShadowReadback(transparent);
+ AssertFinite(opaqueShadow.maxAbsoluteRgbDelta, "Opaque ShadowCaster maximum RGB delta");
+ AssertFinite(cutoutShadow.maxAbsoluteRgbDelta, "Cutout ShadowCaster maximum RGB delta");
+ AssertFinite(cutoutBelowShadow.maxAbsoluteRgbDelta, "Cutout below-cutoff ShadowCaster maximum RGB delta");
+ AssertFinite(transparentShadow.maxAbsoluteRgbDelta, "Transparent ShadowCaster maximum RGB delta");
+ AssertContributingShadowReadbacks(opaqueShadow, cutoutShadow);
+ AssertNoncontributingShadowReadbacks(opaqueShadow, cutoutShadow, cutoutBelowShadow, transparentShadow);
+ }
+
+ /// Asserts that Opaque and Cutout ShadowCaster measurements retain meaningful silhouettes.
+ private static void AssertContributingShadowReadbacks(ShadowReadback opaqueShadow, ShadowReadback cutoutShadow)
+ {
+ Assert.That(opaqueShadow.maxAbsoluteRgbDelta, Is.GreaterThan(ShadowPixelNoiseThreshold), opaqueShadow.Describe("Opaque"));
+ Assert.That(opaqueShadow.changedPixelCount, Is.GreaterThan(MinimumShadowSilhouettePixelCount), opaqueShadow.Describe("Opaque"));
+ Assert.That(cutoutShadow.maxAbsoluteRgbDelta, Is.GreaterThan(ShadowPixelNoiseThreshold), cutoutShadow.Describe("Cutout"));
+ Assert.That(cutoutShadow.changedPixelCount, Is.GreaterThan(MinimumShadowSilhouettePixelCount), cutoutShadow.Describe("Cutout"));
+ Assert.That(cutoutShadow.maxAbsoluteRgbDelta, Is.GreaterThan(opaqueShadow.maxAbsoluteRgbDelta * 0.25f), cutoutShadow.Describe("Cutout") + " must retain a visible silhouette relative to Opaque.");
+ Assert.That(cutoutShadow.changedPixelCount, Is.GreaterThan(opaqueShadow.changedPixelCount * 0.25f), cutoutShadow.Describe("Cutout") + " must retain sufficient changed pixels relative to Opaque.");
+ }
+
+ /// Asserts that below-cutoff and Transparent ShadowCaster measurements remain noncontributing.
+ private static void AssertNoncontributingShadowReadbacks(ShadowReadback opaqueShadow, ShadowReadback cutoutShadow, ShadowReadback cutoutBelowShadow, ShadowReadback transparentShadow)
+ {
+ Assert.That(cutoutBelowShadow.maxAbsoluteRgbDelta, Is.LessThanOrEqualTo(ShadowPixelNoiseThreshold), cutoutBelowShadow.Describe("Cutout below cutoff"));
+ Assert.That(cutoutBelowShadow.changedPixelCount, Is.LessThanOrEqualTo(MinimumShadowSilhouettePixelCount), cutoutBelowShadow.Describe("Cutout below cutoff"));
+ Assert.That(transparentShadow.maxAbsoluteRgbDelta, Is.LessThanOrEqualTo(ShadowPixelNoiseThreshold), transparentShadow.Describe("Transparent"));
+ Assert.That(transparentShadow.changedPixelCount, Is.LessThanOrEqualTo(MinimumShadowSilhouettePixelCount), transparentShadow.Describe("Transparent"));
+ float minimumContributingShadowDelta = Mathf.Min(opaqueShadow.maxAbsoluteRgbDelta, cutoutShadow.maxAbsoluteRgbDelta);
+ int minimumContributingShadowPixels = Mathf.Min(opaqueShadow.changedPixelCount, cutoutShadow.changedPixelCount);
+ Assert.That(transparentShadow.maxAbsoluteRgbDelta, Is.LessThan(minimumContributingShadowDelta * 0.25f), transparentShadow.Describe("Transparent") + " must remain below the Opaque and Cutout contribution boundary.");
+ Assert.That(transparentShadow.changedPixelCount, Is.LessThan(minimumContributingShadowPixels * 0.25f), transparentShadow.Describe("Transparent") + " must remain below the Opaque and Cutout changed-pixel contribution boundary.");
+ }
+
+ /// Asserts Meta readback contribution boundaries for Opaque, Cutout, and Transparent materials.
+ private static void AssertMetaContributions(Material opaque, Material cutout, Material transparent, Color expectedContributingMeta)
+ {
+ float opaqueMetaMagnitude = AssertContributingMeta(RenderMetaCenterPixel(opaque), expectedContributingMeta, "Opaque");
+ float cutoutMetaMagnitude = AssertContributingMeta(RenderMetaCenterPixel(cutout), expectedContributingMeta, "Cutout");
+ Color transparentMeta = RenderMetaCenterPixel(transparent);
+ AssertFinite(transparentMeta, "Transparent Meta readback");
+ float transparentMetaMagnitude = RgbMagnitude(transparentMeta);
+ Assert.That(transparentMetaMagnitude, Is.LessThan(0.02f), "Transparent Meta must not contribute effective albedo data in the actual BIRP readback.");
+ float minimumContributingMetaMagnitude = Mathf.Min(opaqueMetaMagnitude, cutoutMetaMagnitude);
+ Assert.That(transparentMetaMagnitude, Is.LessThan(minimumContributingMetaMagnitude * 0.25f), "Transparent Meta must remain below the Opaque and Cutout contribution boundary.");
+ }
+
+ /// Asserts one Meta contribution's expected linear albedo and returns its RGB magnitude.
+ private static float AssertContributingMeta(Color observedMeta, Color expectedMeta, string label)
+ {
+ AssertFinite(observedMeta, label + " Meta readback");
+ Assert.That(observedMeta.r, Is.EqualTo(expectedMeta.r).Within(0.08f));
+ Assert.That(observedMeta.g, Is.EqualTo(expectedMeta.g).Within(0.08f));
+ Assert.That(observedMeta.b, Is.EqualTo(expectedMeta.b).Within(0.08f));
+ float magnitude = RgbMagnitude(observedMeta);
+ Assert.That(magnitude, Is.GreaterThan(0.2f), label + " Meta pass must contribute non-clear albedo data.");
+ return magnitude;
+ }
+
+ /// Requires Transparent Toon ForwardAdd to accumulate a second light in RGB while preserving the once-blended destination alpha.
+ [Test]
+ public void TransparentToonForwardAddAccumulatesRgbBySourceAlphaWithoutChangingDestinationAlpha()
+ {
+ Shader toon = RequireProductShader("PureBase/Toon");
+ var lowAlphaMaterial = CreateConfiguredMaterial(toon, 2, new Color(0.8f, 0.6f, 0.4f, 0.25f));
+ var highAlphaMaterial = CreateConfiguredMaterial(toon, 2, new Color(0.8f, 0.6f, 0.4f, 0.5f));
+ {
+ Color oneLowAlphaLight = RenderTransparentToonPixel(lowAlphaMaterial, 1);
+ Color twoLowAlphaLights = RenderTransparentToonPixel(lowAlphaMaterial, 2);
+ Color oneHighAlphaLight = RenderTransparentToonPixel(highAlphaMaterial, 1);
+ Color twoHighAlphaLights = RenderTransparentToonPixel(highAlphaMaterial, 2);
+ AssertFinite(oneLowAlphaLight, "Transparent Toon low-alpha one-light readback");
+ AssertFinite(twoLowAlphaLights, "Transparent Toon low-alpha two-light readback");
+ AssertFinite(oneHighAlphaLight, "Transparent Toon high-alpha one-light readback");
+ AssertFinite(twoHighAlphaLights, "Transparent Toon high-alpha two-light readback");
+ float lowAlphaAddDelta = RgbMagnitude(twoLowAlphaLights - oneLowAlphaLight);
+ float highAlphaAddDelta = RgbMagnitude(twoHighAlphaLights - oneHighAlphaLight);
+ AssertFinite(lowAlphaAddDelta, "Transparent Toon low-alpha ForwardAdd delta");
+ AssertFinite(highAlphaAddDelta, "Transparent Toon high-alpha ForwardAdd delta");
+ Assert.That(
+ lowAlphaAddDelta,
+ Is.GreaterThan(0.01f),
+ "A second ForwardAdd light must increase Transparent Toon RGB contribution."
+ );
+ Assert.That(
+ highAlphaAddDelta,
+ Is.GreaterThan(lowAlphaAddDelta),
+ "ForwardAdd RGB must respond to the Transparent source alpha."
+ );
+ Assert.That(
+ highAlphaAddDelta / lowAlphaAddDelta,
+ Is.InRange(1.65f, 2.35f),
+ "Doubling Transparent source alpha must double the isolated ForwardAdd RGB delta; alpha-ignored and alpha-squared contributions are invalid."
+ );
+ Assert.That(
+ twoLowAlphaLights.a,
+ Is.EqualTo(oneLowAlphaLight.a).Within(0.01f),
+ "ForwardAdd must not modify the destination alpha written by ForwardBase."
+ );
+ Assert.That(
+ twoHighAlphaLights.a,
+ Is.EqualTo(oneHighAlphaLight.a).Within(0.01f),
+ "ForwardAdd must not modify the destination alpha written by ForwardBase at either source alpha."
+ );
+ Assert.That(
+ oneLowAlphaLight.a,
+ Is.InRange(0.49f, 0.54f),
+ "ForwardBase must blend the 0.25 source alpha exactly once against the 0.60 destination alpha."
+ );
+ }
+ }
+
+ /// Requires Transparent materials to disable both contribution passes for every public product before shadow or Meta work can run.
+ [Test]
+ public void TransparentMaterialsHaveNoEffectiveShadowCasterOrMetaContribution()
+ {
+ foreach (string shaderName in new[] { "PureBase/Unlit", "PureBase/Toon", "PureBase/PBR", "PureBase/Hybrid" })
+ {
+ var material = CreateMaterial(RequireProductShader(shaderName));
+ ConfigureMode(material, 2);
+ Assert.That(material.GetShaderPassEnabled("ShadowCaster"), Is.False, shaderName + " Transparent ShadowCaster contribution.");
+ Assert.That(material.GetShaderPassEnabled("Meta"), Is.False, shaderName + " Transparent Meta contribution.");
+ }
+ }
+
+ /// Creates one configured transient material without saving or modifying any persistent asset.
+ /// The source shader.
+ /// The requested rendering-mode value.
+ /// The base color assigned before rendering.
+ /// The caller-owned material.
+ private Material CreateConfiguredMaterial(Shader shader, int mode, Color baseColor)
+ {
+ Material material = CreateMaterial(shader);
+ material.SetColor("_BaseColor", baseColor);
+ material.SetFloat("_Cutoff", 0.5f);
+ ConfigureMode(material, mode);
+ return material;
+ }
+
+ /// Creates and registers one transient material for deterministic test cleanup.
+ /// The shader assigned to the material.
+ /// The tracked material.
+ private Material CreateMaterial(Shader shader)
+ {
+ var material = new Material(shader);
+ transientMaterials.Add(material);
+ return material;
+ }
+
+ /// Releases every transient material after each rendering observation, including failure paths.
+ [TearDown]
+ public void DestroyTransientMaterials()
+ {
+ foreach (Material material in transientMaterials)
+ {
+ if (material != null)
+ UnityEngine.Object.DestroyImmediate(material);
+ }
+
+ transientMaterials.Clear();
+ }
+
+ /// Calls the reflected public normalizer after assigning the public mode value.
+ /// The material to normalize.
+ /// The requested mode value.
+ private static void ConfigureMode(Material material, int mode)
+ {
+ material.SetInteger("_RenderingMode", mode);
+ Type type = FindLoadedType("PureBase.Editor.PureBaseMaterialRenderingMode");
+ Assert.That(type, Is.Not.Null, "PureBaseMaterialRenderingMode is required for rendering observations.");
+ var apply = type.GetMethod("Apply", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static, null, new[] { typeof(Material) }, null);
+ Assert.That(apply, Is.Not.Null, "PureBaseMaterialRenderingMode.Apply(Material) is required for rendering observations.");
+ apply.Invoke(null, new object[] { material });
+ }
+
+ /// Renders a full-frame quad through a temporary camera and returns its center pixel.
+ /// The transient material to render.
+ /// The camera clear color.
+ /// The center readback pixel.
+ private static Color RenderCenterPixel(Material material, Color background)
+ {
+ GameObject cameraObject = null;
+ GameObject quadObject = null;
+ RenderTexture renderTexture = null;
+ Texture2D texture = null;
+ Camera camera = null;
+ try
+ {
+ cameraObject = new GameObject("PureBaseRenderingModeCamera");
+ quadObject = GameObject.CreatePrimitive(PrimitiveType.Quad);
+ renderTexture = new RenderTexture(RenderSize, RenderSize, 24, RenderTextureFormat.ARGBFloat);
+ texture = new Texture2D(RenderSize, RenderSize, TextureFormat.RGBAFloat, false, true);
+ camera = cameraObject.AddComponent();
+ ConfigureCenterPixelCamera(camera, renderTexture, background);
+ quadObject.GetComponent().sharedMaterial = material;
+ camera.Render();
+ return ReadCenterPixel(renderTexture, texture);
+ }
+ finally
+ {
+ ReleaseQuadReadbackResources(cameraObject, quadObject, camera, renderTexture, texture);
+ }
+ }
+
+ /// Configures the temporary camera used for one center-pixel readback.
+ private static void ConfigureCenterPixelCamera(Camera camera, RenderTexture renderTexture, Color background)
+ {
+ camera.orthographic = true;
+ camera.orthographicSize = 0.5f;
+ camera.transform.position = new Vector3(0.0f, 0.0f, -2.0f);
+ camera.clearFlags = CameraClearFlags.SolidColor;
+ camera.backgroundColor = background;
+ camera.targetTexture = renderTexture;
+ }
+
+ /// Releases one temporary quad readback fixture in its original ownership order.
+ private static void ReleaseQuadReadbackResources(GameObject cameraObject, GameObject quadObject, Camera camera, RenderTexture renderTexture, Texture2D texture)
+ {
+ if (camera != null)
+ camera.targetTexture = null;
+ if (texture != null)
+ UnityEngine.Object.DestroyImmediate(texture);
+ if (renderTexture != null)
+ {
+ renderTexture.Release();
+ UnityEngine.Object.DestroyImmediate(renderTexture);
+ }
+ if (quadObject != null)
+ UnityEngine.Object.DestroyImmediate(quadObject);
+ if (cameraObject != null)
+ UnityEngine.Object.DestroyImmediate(cameraObject);
+ }
+
+ /// Renders two Transparent quads at controlled depths and returns the center pixel after Unity's transparent sorting.
+ /// The material assigned to the camera-nearest quad.
+ /// The material assigned to the camera-farthest quad.
+ /// The sorted layered center readback.
+ private static Color RenderLayeredCenterPixel(Material frontMaterial, Material rearMaterial)
+ {
+ GameObject cameraObject = null;
+ GameObject frontObject = null;
+ GameObject rearObject = null;
+ RenderTexture renderTexture = null;
+ Texture2D texture = null;
+ try
+ {
+ cameraObject = new GameObject("PureBaseRenderingModeDepthCamera");
+ frontObject = GameObject.CreatePrimitive(PrimitiveType.Quad);
+ rearObject = GameObject.CreatePrimitive(PrimitiveType.Quad);
+ renderTexture = new RenderTexture(RenderSize, RenderSize, 24, RenderTextureFormat.ARGBFloat);
+ texture = new Texture2D(RenderSize, RenderSize, TextureFormat.RGBAFloat, false, true);
+ Camera camera = cameraObject.AddComponent();
+ camera.orthographic = true;
+ camera.orthographicSize = 0.5f;
+ camera.transform.position = new Vector3(0.0f, 0.0f, -2.0f);
+ camera.clearFlags = CameraClearFlags.SolidColor;
+ camera.backgroundColor = Color.clear;
+ camera.targetTexture = renderTexture;
+ frontObject.transform.position = Vector3.zero;
+ rearObject.transform.position = new Vector3(0.0f, 0.0f, 0.1f);
+ frontObject.GetComponent().sharedMaterial = frontMaterial;
+ rearObject.GetComponent().sharedMaterial = rearMaterial;
+ camera.Render();
+ return ReadCenterPixel(renderTexture, texture);
+ }
+ finally
+ {
+ Camera camera = cameraObject != null ? cameraObject.GetComponent() : null;
+ if (camera != null)
+ camera.targetTexture = null;
+ if (texture != null)
+ UnityEngine.Object.DestroyImmediate(texture);
+ if (renderTexture != null)
+ {
+ renderTexture.Release();
+ UnityEngine.Object.DestroyImmediate(renderTexture);
+ }
+ if (rearObject != null)
+ UnityEngine.Object.DestroyImmediate(rearObject);
+ if (frontObject != null)
+ UnityEngine.Object.DestroyImmediate(frontObject);
+ if (cameraObject != null)
+ UnityEngine.Object.DestroyImmediate(cameraObject);
+ }
+ }
+
+ /// Draws Transparent before an opaque marker at a farther depth to make Transparent depth-write behavior observable.
+ /// The configured Transparent material drawn first.
+ /// The opaque marker material drawn after Transparent.
+ /// The center pixel after the controlled explicit draw order.
+ private static Color RenderTransparentThenOpaqueDepthProbe(Material transparentMaterial, Material markerMaterial)
+ {
+ GameObject cameraObject = null;
+ GameObject quadObject = null;
+ RenderTexture renderTexture = null;
+ Texture2D texture = null;
+ CommandBuffer commandBuffer = null;
+ Camera camera = null;
+ try
+ {
+ cameraObject = new GameObject("PureBaseRenderingModeExplicitDepthCamera");
+ quadObject = GameObject.CreatePrimitive(PrimitiveType.Quad);
+ renderTexture = new RenderTexture(RenderSize, RenderSize, 24, RenderTextureFormat.ARGBFloat);
+ texture = new Texture2D(RenderSize, RenderSize, TextureFormat.RGBAFloat, false, true);
+ camera = cameraObject.AddComponent();
+ ConfigureExplicitDepthProbeCamera(camera, renderTexture);
+ renderTexture.Create();
+ commandBuffer = CreateExplicitDepthProbeCommandBuffer(quadObject, transparentMaterial, markerMaterial);
+ camera.AddCommandBuffer(CameraEvent.BeforeImageEffects, commandBuffer);
+ camera.Render();
+ return ReadCenterPixel(renderTexture, texture);
+ }
+ finally
+ {
+ ReleaseExplicitDepthProbeResources(cameraObject, quadObject, camera, commandBuffer, renderTexture, texture);
+ }
+ }
+
+ /// Configures the camera used by the explicit ForwardBase depth probe.
+ private static void ConfigureExplicitDepthProbeCamera(Camera camera, RenderTexture renderTexture)
+ {
+ camera.enabled = false;
+ camera.cullingMask = 0;
+ camera.orthographic = true;
+ camera.orthographicSize = 0.5f;
+ camera.transform.position = new Vector3(0.0f, 0.0f, -2.0f);
+ camera.clearFlags = CameraClearFlags.SolidColor;
+ camera.backgroundColor = Color.clear;
+ camera.targetTexture = renderTexture;
+ }
+
+ /// Creates the command buffer that draws Transparent before the farther opaque marker.
+ private static CommandBuffer CreateExplicitDepthProbeCommandBuffer(GameObject quadObject, Material transparentMaterial, Material markerMaterial)
+ {
+ int transparentPass = transparentMaterial.FindPass("ForwardBase");
+ Assert.That(transparentPass, Is.GreaterThanOrEqualTo(0), "The Transparent depth probe requires ForwardBase.");
+ var commandBuffer = new CommandBuffer { name = "PureBase Rendering Mode Explicit Depth Probe" };
+ Mesh quadMesh = quadObject.GetComponent().sharedMesh;
+ commandBuffer.DrawMesh(quadMesh, Matrix4x4.identity, transparentMaterial, 0, transparentPass);
+ commandBuffer.DrawMesh(quadMesh, Matrix4x4.Translate(new Vector3(0.0f, 0.0f, 0.1f)), markerMaterial, 0, 0);
+ return commandBuffer;
+ }
+
+ /// Releases the explicit depth probe command buffer and transient render resources.
+ private static void ReleaseExplicitDepthProbeResources(GameObject cameraObject, GameObject quadObject, Camera camera, CommandBuffer commandBuffer, RenderTexture renderTexture, Texture2D texture)
+ {
+ if (camera != null && commandBuffer != null)
+ camera.RemoveCommandBuffer(CameraEvent.BeforeImageEffects, commandBuffer);
+ if (commandBuffer != null)
+ commandBuffer.Release();
+ ReleaseQuadReadbackResources(cameraObject, quadObject, camera, renderTexture, texture);
+ }
+
+ /// Renders Transparent Toon with a controlled one- or two-directional-light setup and a nonzero-alpha destination.
+ /// The configured Transparent Toon material.
+ /// The number of directional lights to render.
+ /// The center pixel after BIRP ForwardBase and ForwardAdd work.
+ private static Color RenderTransparentToonPixel(Material material, int lightCount)
+ {
+ const int renderingLayer = 31;
+ int cullingMask = 1 << renderingLayer;
+ GameObject cameraObject = null;
+ GameObject quadObject = null;
+ RenderTexture renderTexture = null;
+ Texture2D texture = null;
+ var lightObjects = new List();
+ Camera camera = null;
+ try
+ {
+ cameraObject = new GameObject("PureBaseRenderingModeToonCamera");
+ quadObject = GameObject.CreatePrimitive(PrimitiveType.Quad);
+ renderTexture = new RenderTexture(RenderSize, RenderSize, 24, RenderTextureFormat.ARGBFloat);
+ texture = new Texture2D(RenderSize, RenderSize, TextureFormat.RGBAFloat, false, true);
+ camera = cameraObject.AddComponent();
+ ConfigureTransparentToonCamera(camera, renderTexture, cullingMask);
+ quadObject.layer = renderingLayer;
+ quadObject.GetComponent().sharedMaterial = material;
+ CreateTransparentToonLights(lightObjects, lightCount, renderingLayer, cullingMask);
+ camera.Render();
+ return ReadCenterPixel(renderTexture, texture);
+ }
+ finally
+ {
+ ReleaseTransparentToonResources(lightObjects, cameraObject, quadObject, camera, renderTexture, texture);
+ }
+ }
+
+ /// Configures the temporary camera used for Transparent Toon light accumulation.
+ private static void ConfigureTransparentToonCamera(Camera camera, RenderTexture renderTexture, int cullingMask)
+ {
+ camera.orthographic = true;
+ camera.orthographicSize = 0.5f;
+ camera.cullingMask = cullingMask;
+ camera.transform.position = new Vector3(0.0f, 0.0f, -2.0f);
+ camera.clearFlags = CameraClearFlags.SolidColor;
+ camera.backgroundColor = new Color(0.0f, 0.0f, 0.0f, 0.6f);
+ camera.targetTexture = renderTexture;
+ }
+
+ /// Creates the directional lights used to isolate ForwardAdd alpha behavior.
+ private static void CreateTransparentToonLights(List lightObjects, int lightCount, int renderingLayer, int cullingMask)
+ {
+ for (int index = 0; index < lightCount; index++)
+ {
+ var lightObject = new GameObject("PureBaseRenderingModeToonLight" + index);
+ lightObjects.Add(lightObject);
+ lightObject.layer = renderingLayer;
+ Light light = lightObject.AddComponent();
+ light.type = LightType.Directional;
+ 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);
+ }
+ }
+
+ /// Releases Transparent Toon lights and temporary render resources in their original order.
+ private static void ReleaseTransparentToonResources(List lightObjects, GameObject cameraObject, GameObject quadObject, Camera camera, RenderTexture renderTexture, Texture2D texture)
+ {
+ foreach (GameObject lightObject in lightObjects)
+ UnityEngine.Object.DestroyImmediate(lightObject);
+ ReleaseQuadReadbackResources(cameraObject, quadObject, camera, renderTexture, texture);
+ }
+
+ /// Returns the Euclidean magnitude of a color's RGB channels.
+ /// The color to measure.
+ /// The nonnegative RGB magnitude.
+ private static float RgbMagnitude(Color color)
+ {
+ return Mathf.Sqrt(color.r * color.r + color.g * color.g + color.b * color.b);
+ }
+
+ /// Asserts that each color component is finite.
+ /// The observed color.
+ /// The observation label.
+ private static void AssertFinite(Color color, string label)
+ {
+ Assert.That(float.IsNaN(color.r) || float.IsInfinity(color.r), Is.False, label + " red is non-finite.");
+ Assert.That(float.IsNaN(color.g) || float.IsInfinity(color.g), Is.False, label + " green is non-finite.");
+ Assert.That(float.IsNaN(color.b) || float.IsInfinity(color.b), Is.False, label + " blue is non-finite.");
+ Assert.That(float.IsNaN(color.a) || float.IsInfinity(color.a), Is.False, label + " alpha is non-finite.");
+ }
+
+ /// Asserts that one scalar readback metric is finite.
+ /// The observed scalar value.
+ /// The observation label.
+ private static void AssertFinite(float value, string label)
+ {
+ Assert.That(float.IsNaN(value) || float.IsInfinity(value), Is.False, label + " is non-finite.");
+ }
+
+ /// Requires one imported public shader with no compiler errors.
+ /// The public shader name.
+ /// The imported shader.
+ private static Shader RequireProductShader(string shaderName)
+ {
+ Shader shader = Shader.Find(shaderName);
+ Assert.That(shader, Is.Not.Null, "Product shader '" + shaderName + "' was not imported.");
+ Assert.That(ShaderUtil.ShaderHasError(shader), Is.False, "Product shader '" + shaderName + "' has compiler errors.");
+ return shader;
+ }
+
+ /// Requires the public material property before performing a mode observation.
+ /// The material to inspect.
+ private static void RequireRenderingModeProperty(Material material)
+ {
+ Assert.That(material.HasProperty("_RenderingMode"), Is.True, "Rendering observations require the public _RenderingMode property.");
+ }
+
+ /// Finds a loaded type without adding a compile-time dependency on the future Editor assembly.
+ /// The fully-qualified type name.
+ /// The loaded type, or .
+ private static Type FindLoadedType(string fullName)
+ {
+ foreach (System.Reflection.Assembly assembly in AppDomain.CurrentDomain.GetAssemblies())
+ {
+ Type type = assembly.GetType(fullName, false);
+ if (type != null)
+ return type;
+ }
+
+ return null;
+ }
+
+ /// Stores the measured silhouette caused by one actual ShadowCaster render.
+ private sealed class ShadowReadback
+ {
+ /// Initializes one immutable ShadowCaster measurement.
+ /// The largest RGB difference between unshadowed and shadowed receiver pixels.
+ /// The number of receiver pixels changed beyond the noise threshold.
+ public ShadowReadback(float maxAbsoluteRgbDelta, int changedPixelCount)
+ {
+ this.maxAbsoluteRgbDelta = maxAbsoluteRgbDelta;
+ this.changedPixelCount = changedPixelCount;
+ }
+
+ /// Stores the largest RGB difference between unshadowed and shadowed receiver pixels.
+ public readonly float maxAbsoluteRgbDelta;
+
+ /// Stores the number of receiver pixels changed beyond the noise threshold.
+ public readonly int changedPixelCount;
+
+ /// Formats the shadow measurement for assertion diagnostics.
+ /// The mode label associated with this measurement.
+ /// The formatted measurement.
+ public string Describe(string label) =>
+ label + ": maxAbsoluteRgbDelta=" + maxAbsoluteRgbDelta + ", changedPixels=" + changedPixelCount;
+ }
+ }
+}
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.cs.meta b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.cs.meta
new file mode 100644
index 00000000..1dc14cb0
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: a1b2ef8478838d44dbdf3b48f86df9c7
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.cs b/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.cs
index 96ea3329..caee352a 100644
--- a/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.cs
+++ b/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.cs
@@ -1034,6 +1034,85 @@ public void UnloadedCanonicalSceneRestoresOriginalSetupAfterException()
AssertCanonicalSceneSnapshotRestoration(false, true);
}
+ /// Ensures canonical static-lightmap observations ignore the additive persisted owner scene.
+ [Test]
+ public void CanonicalStaticLightmapCountIgnoresLoadedPersistedOwnerScene()
+ {
+ SceneRegressionBaseline baseline = LoadBaseline();
+ Scene ownerScene = default;
+ Scene validationScene = default;
+ var fixtureScope = new ControlledFixtureSceneScope(
+ TestOwnerScenePath,
+ ScenePath
+ );
+ try
+ {
+ ownerScene = fixtureScope.GetLoadedFixture(TestOwnerScenePath);
+ validationScene = fixtureScope.GetLoadedFixture(ScenePath);
+ fixtureScope.SetActiveFixture(validationScene);
+ Assert.That(
+ ownerScene.isDirty,
+ Is.False,
+ "The controlled lightmap-count test cannot discard a dirty persisted owner scene."
+ );
+ Assert.That(
+ EditorSceneManager.CloseScene(ownerScene, true),
+ Is.True,
+ "The persisted owner scene could not be closed before the canonical-only observation."
+ );
+
+ int canonicalOnlyGlobalLightmapCount = LightmapSettings.lightmaps.Length;
+ int canonicalOnlyStaticLightmapCount = CountAssignedStaticLightmaps(
+ GetStaticRenderers(validationScene)
+ );
+ TestContext.Progress.WriteLine(
+ "canonical-only scenes="
+ + DescribeLoadedScenePaths()
+ + ", globalLightmaps="
+ + canonicalOnlyGlobalLightmapCount
+ + ", canonicalStaticLightmaps="
+ + canonicalOnlyStaticLightmapCount
+ );
+ Assert.That(
+ canonicalOnlyGlobalLightmapCount,
+ Is.EqualTo(baseline.staticLightmapCount),
+ "The canonical-only fixture must expose the reviewed global lightmap count."
+ );
+ Assert.That(
+ canonicalOnlyStaticLightmapCount,
+ Is.EqualTo(baseline.staticLightmapCount)
+ );
+
+ ownerScene = EditorSceneManager.OpenScene(TestOwnerScenePath, OpenSceneMode.Additive);
+ int ownerAndCanonicalGlobalLightmapCount = LightmapSettings.lightmaps.Length;
+ int ownerAndCanonicalStaticLightmapCount = CountAssignedStaticLightmaps(
+ GetStaticRenderers(validationScene)
+ );
+ TestContext.Progress.WriteLine(
+ "persisted-owner-plus-canonical scenes="
+ + DescribeLoadedScenePaths()
+ + ", globalLightmaps="
+ + ownerAndCanonicalGlobalLightmapCount
+ + ", canonicalStaticLightmaps="
+ + ownerAndCanonicalStaticLightmapCount
+ );
+ Assert.That(
+ ownerAndCanonicalGlobalLightmapCount,
+ Is.EqualTo(baseline.staticLightmapCount * 2),
+ "The owner-specific LightingData fixture must expose the additive global-count discriminator."
+ );
+ Assert.That(
+ ownerAndCanonicalStaticLightmapCount,
+ Is.EqualTo(baseline.staticLightmapCount),
+ "The canonical static-lightmap count must ignore additive owner-scene entries."
+ );
+ }
+ finally
+ {
+ fixtureScope.Dispose();
+ }
+ }
+
/// Validates the committed scene and reviewed baseline while restoring all editor state.
[Test]
public void CanonicalSceneMatchesCommittedBirpBaseline()
@@ -1221,17 +1300,8 @@ public static SceneRegressionObservation CaptureObservation(Scene scene)
ValidateFixture(scene);
var observation = new SceneRegressionObservation();
List staticRenderers = GetStaticRenderers(scene);
- observation.staticLightmapCount =
- LightmapSettings.lightmaps == null ? 0 : LightmapSettings.lightmaps.Length;
+ observation.staticLightmapCount = CountAssignedStaticLightmaps(staticRenderers);
observation.staticRendererAssignmentCount = staticRenderers.Count;
- foreach (MeshRenderer renderer in staticRenderers)
- {
- Assert.That(
- renderer.lightmapIndex,
- Is.GreaterThanOrEqualTo(0),
- $"Static renderer '{renderer.name}' is not assigned to a committed lightmap."
- );
- }
CaptureSceneReadback(scene, observation);
observation.metaAlbedo = CaptureMetaAlbedo(GetProductMaterials(scene));
@@ -1446,6 +1516,61 @@ private static List GetStaticRenderers(Scene scene)
return renderers;
}
+ /// Counts the unique valid static-lightmap assignments used by canonical scene renderers.
+ /// The enabled static renderers from the canonical validation scene.
+ /// The number of committed static lightmaps referenced by the canonical scene.
+ private static int CountAssignedStaticLightmaps(
+ IReadOnlyList staticRenderers
+ )
+ {
+ LightmapData[] lightmaps = LightmapSettings.lightmaps;
+ Assert.That(lightmaps, Is.Not.Null, "The current lightmap settings are unavailable.");
+
+ var assignedIndices = new HashSet();
+ foreach (MeshRenderer renderer in staticRenderers)
+ {
+ int lightmapIndex = renderer.lightmapIndex;
+ Assert.That(
+ lightmapIndex,
+ Is.GreaterThanOrEqualTo(0),
+ $"Static renderer '{renderer.name}' is not assigned to a committed lightmap."
+ );
+ Assert.That(
+ lightmapIndex,
+ Is.LessThan(lightmaps.Length),
+ $"Static renderer '{renderer.name}' references a lightmap outside the current settings."
+ );
+ Assert.That(
+ lightmaps[lightmapIndex],
+ Is.Not.Null,
+ $"Static renderer '{renderer.name}' references an unavailable lightmap."
+ );
+ Assert.That(
+ lightmaps[lightmapIndex].lightmapColor,
+ Is.Not.Null,
+ $"Static renderer '{renderer.name}' references a lightmap without color data."
+ );
+ assignedIndices.Add(lightmapIndex);
+ }
+
+ return assignedIndices.Count;
+ }
+
+ /// Formats the currently loaded scene paths for controlled test diagnostics.
+ /// The loaded scene paths in scene-manager order.
+ private static string DescribeLoadedScenePaths()
+ {
+ var paths = new List();
+ for (int sceneIndex = 0; sceneIndex < SceneManager.sceneCount; sceneIndex++)
+ {
+ Scene scene = SceneManager.GetSceneAt(sceneIndex);
+ if (scene.isLoaded)
+ paths.Add(string.IsNullOrEmpty(scene.path) ? "" : scene.path);
+ }
+
+ return string.Join(", ", paths);
+ }
+
/// Renders the scene through a temporary camera without changing the persisted camera target.
/// The canonical validation scene.
/// The observation to populate.
@@ -2761,6 +2886,290 @@ private static Scene GetOrOpenPersistedOwnerScene()
return EditorSceneManager.OpenScene(TestOwnerScenePath, OpenSceneMode.Additive);
}
+ /// Loads controlled fixtures defensively and restores only their original scene-manager entries.
+ private sealed class ControlledFixtureSceneScope : IDisposable
+ {
+ private readonly FixtureSceneState[] fixtureStates;
+ private readonly Scene originalActiveScene;
+ private readonly string originalActiveScenePath;
+
+ /// Captures the controlled fixture entries and the original active scene.
+ /// The fixture paths this scope may load, close, or remove.
+ public ControlledFixtureSceneScope(params string[] fixturePaths)
+ {
+ originalActiveScene = SceneManager.GetActiveScene();
+ originalActiveScenePath = originalActiveScene.path;
+ fixtureStates = new FixtureSceneState[fixturePaths.Length];
+ for (int fixtureIndex = 0; fixtureIndex < fixturePaths.Length; fixtureIndex++)
+ {
+ fixtureStates[fixtureIndex] = FixtureSceneState.Capture(
+ fixturePaths[fixtureIndex],
+ originalActiveScene
+ );
+ }
+ }
+
+ /// Gets a valid, loaded controlled fixture scene.
+ /// The controlled fixture path to load.
+ /// The current loaded scene instance for the fixture path.
+ public Scene GetLoadedFixture(string fixturePath)
+ {
+ foreach (FixtureSceneState fixtureState in fixtureStates)
+ {
+ if (string.Equals(fixtureState.Path, fixturePath, StringComparison.Ordinal))
+ return fixtureState.GetOrOpenLoadedScene();
+ }
+
+ throw new ArgumentOutOfRangeException(
+ nameof(fixturePath),
+ fixturePath,
+ "The fixture path is outside this controlled scene scope."
+ );
+ }
+
+ /// Makes a validated fixture active unless it already owns the active-scene context.
+ /// The valid loaded fixture scene to activate.
+ public void SetActiveFixture(Scene fixtureScene)
+ {
+ Assert.That(fixtureScene.IsValid(), Is.True, "The controlled fixture scene was invalid.");
+ Assert.That(fixtureScene.isLoaded, Is.True, "The controlled fixture scene was not loaded.");
+ if (SceneManager.GetActiveScene().Equals(fixtureScene))
+ return;
+ Assert.That(
+ SceneManager.SetActiveScene(fixtureScene),
+ Is.True,
+ "The canonical fixture could not become active before the canonical-only observation."
+ );
+ }
+
+ /// Restores the controlled fixture entries and the original active scene without rebuilding user scenes.
+ public void Dispose()
+ {
+ foreach (FixtureSceneState fixtureState in fixtureStates)
+ fixtureState.Restore();
+
+ Scene restoredActiveScene = string.IsNullOrEmpty(originalActiveScenePath)
+ ? originalActiveScene
+ : SceneManager.GetSceneByPath(originalActiveScenePath);
+ if (
+ restoredActiveScene.IsValid()
+ && restoredActiveScene.isLoaded
+ && !SceneManager.GetActiveScene().Equals(restoredActiveScene)
+ )
+ {
+ Assert.That(
+ SceneManager.SetActiveScene(restoredActiveScene),
+ Is.True,
+ "The original active scene could not be restored after the controlled fixture observation."
+ );
+ }
+
+ foreach (FixtureSceneState fixtureState in fixtureStates)
+ fixtureState.AssertRestored();
+ }
+
+ /// Ensures Unity has another loaded scene active before a controlled fixture is closed.
+ /// The fixture path about to be closed.
+ public static void SetActiveSceneOtherThan(string fixturePath)
+ {
+ Scene activeScene = SceneManager.GetActiveScene();
+ if (
+ activeScene.IsValid()
+ && activeScene.isLoaded
+ && !string.Equals(activeScene.path, fixturePath, StringComparison.Ordinal)
+ )
+ return;
+
+ for (int sceneIndex = 0; sceneIndex < SceneManager.sceneCount; sceneIndex++)
+ {
+ Scene candidateScene = SceneManager.GetSceneAt(sceneIndex);
+ if (
+ !candidateScene.isLoaded
+ || string.Equals(candidateScene.path, fixturePath, StringComparison.Ordinal)
+ )
+ continue;
+ Assert.That(
+ SceneManager.SetActiveScene(candidateScene),
+ Is.True,
+ $"No non-fixture scene could become active before closing '{fixturePath}'."
+ );
+ return;
+ }
+
+ throw new AssertionException(
+ $"Cannot close controlled fixture '{fixturePath}' because it is the only loaded scene."
+ );
+ }
+
+ /// Stores one controlled fixture's original scene-manager state.
+ private sealed class FixtureSceneState
+ {
+ private readonly bool wasActive;
+ private readonly FixtureScenePresence originalPresence;
+
+ private FixtureSceneState(
+ string path,
+ FixtureScenePresence originalPresence,
+ bool wasActive
+ )
+ {
+ Path = path;
+ this.originalPresence = originalPresence;
+ this.wasActive = wasActive;
+ }
+
+ /// Gets the controlled fixture path.
+ public string Path { get; }
+
+ /// Captures a controlled fixture's registration and live active-scene state.
+ /// The controlled fixture path.
+ /// The live active scene captured before this scope changes fixtures.
+ /// The captured fixture state.
+ public static FixtureSceneState Capture(string path, Scene activeScene)
+ {
+ Scene scene = SceneManager.GetSceneByPath(path);
+ FixtureScenePresence presence = !scene.IsValid()
+ ? FixtureScenePresence.Absent
+ : scene.isLoaded
+ ? FixtureScenePresence.Loaded
+ : FixtureScenePresence.Unloaded;
+ bool isActive = scene.IsValid() && scene.Equals(activeScene);
+
+ return new FixtureSceneState(path, presence, isActive);
+ }
+
+ /// Loads this fixture, removing and reopening only an existing unloaded entry when necessary.
+ /// The valid loaded fixture scene.
+ public Scene GetOrOpenLoadedScene()
+ {
+ Scene scene = SceneManager.GetSceneByPath(Path);
+ if (scene.IsValid() && scene.isLoaded)
+ return scene;
+ if (scene.IsValid())
+ {
+ Assert.That(
+ EditorSceneManager.CloseScene(scene, true),
+ Is.True,
+ $"The existing unloaded fixture entry '{Path}' could not be removed before reopening."
+ );
+ }
+
+ EditorSceneManager.OpenScene(Path, OpenSceneMode.Additive);
+ scene = SceneManager.GetSceneByPath(Path);
+ Assert.That(scene.IsValid(), Is.True, $"Fixture '{Path}' was invalid after reopening.");
+ Assert.That(scene.isLoaded, Is.True, $"Fixture '{Path}' was not loaded after reopening.");
+ return scene;
+ }
+
+ /// Restores this controlled fixture to its captured scene-manager entry state.
+ public void Restore()
+ {
+ switch (originalPresence)
+ {
+ case FixtureScenePresence.Loaded:
+ GetOrOpenLoadedScene();
+ break;
+ case FixtureScenePresence.Unloaded:
+ RestoreUnloadedEntry();
+ break;
+ case FixtureScenePresence.Absent:
+ RemoveFixtureEntry();
+ break;
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+ }
+
+ /// Verifies the fixture entry and captured active setup state after restoration.
+ public void AssertRestored()
+ {
+ Scene scene = SceneManager.GetSceneByPath(Path);
+ switch (originalPresence)
+ {
+ case FixtureScenePresence.Loaded:
+ Assert.That(scene.IsValid(), Is.True, $"Fixture '{Path}' was removed during restoration.");
+ Assert.That(scene.isLoaded, Is.True, $"Fixture '{Path}' was not restored as loaded.");
+ break;
+ case FixtureScenePresence.Unloaded:
+ Assert.That(scene.IsValid(), Is.True, $"Fixture '{Path}' was removed instead of restored as unloaded.");
+ Assert.That(scene.isLoaded, Is.False, $"Fixture '{Path}' was not restored as unloaded.");
+ break;
+ case FixtureScenePresence.Absent:
+ Assert.That(scene.IsValid(), Is.False, $"Fixture '{Path}' was left registered after restoration.");
+ break;
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+
+ if (wasActive)
+ {
+ Assert.That(
+ SceneManager.GetActiveScene().path,
+ Is.EqualTo(Path),
+ $"Fixture '{Path}' was originally active but was not restored as active."
+ );
+ }
+ }
+
+ /// Closes a currently loaded fixture while retaining its existing unloaded entry.
+ private void RestoreUnloadedEntry()
+ {
+ Scene scene = SceneManager.GetSceneByPath(Path);
+ if (!scene.IsValid())
+ scene = GetOrOpenLoadedScene();
+ if (!scene.isLoaded)
+ return;
+ SetActiveSceneOtherThan(Path);
+ Assert.That(
+ scene.isDirty,
+ Is.False,
+ $"The controlled fixture '{Path}' became dirty and cannot be closed without discarding changes."
+ );
+ Assert.That(
+ EditorSceneManager.CloseScene(scene, false),
+ Is.True,
+ $"Fixture '{Path}' could not be restored as an unloaded entry."
+ );
+ }
+
+ /// Removes a fixture that was not registered before this scope.
+ private void RemoveFixtureEntry()
+ {
+ Scene scene = SceneManager.GetSceneByPath(Path);
+ if (!scene.IsValid())
+ return;
+ if (scene.isLoaded)
+ {
+ SetActiveSceneOtherThan(Path);
+ Assert.That(
+ scene.isDirty,
+ Is.False,
+ $"The controlled fixture '{Path}' became dirty and cannot be removed without discarding changes."
+ );
+ }
+
+ Assert.That(
+ EditorSceneManager.CloseScene(scene, true),
+ Is.True,
+ $"Fixture '{Path}' could not be removed after the controlled observation."
+ );
+ }
+ }
+
+ /// Defines the captured registration state of a controlled fixture.
+ private enum FixtureScenePresence
+ {
+ /// The fixture was not registered in the scene manager.
+ Absent,
+
+ /// The fixture was loaded.
+ Loaded,
+
+ /// The fixture was registered but unloaded.
+ Unloaded,
+ }
+ }
+
/// Changes the active scene's lighting state so snapshot restoration must reapply its captured values.
private static void MutateSceneOwnedLightingSettings()
{
diff --git a/Tests/Fixtures/Materials/PureBaseLegacyCutout.mat b/Tests/Fixtures/Materials/PureBaseLegacyCutout.mat
new file mode 100644
index 00000000..d7b64472
--- /dev/null
+++ b/Tests/Fixtures/Materials/PureBaseLegacyCutout.mat
@@ -0,0 +1,51 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!21 &2100000
+Material:
+ serializedVersion: 8
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: PureBaseLegacyCutout
+ m_Shader: {fileID: -7482078289662181024, guid: 4f672202ea09a864394c11a7a8e6dc14, type: 3}
+ m_Parent: {fileID: 0}
+ m_ModifiedSerializedProperties: 0
+ m_ValidKeywords: [PUREBASE_LEGACY_UNRELATED]
+ m_InvalidKeywords: []
+ m_LightmapFlags: 4
+ m_EnableInstancingVariants: 0
+ m_DoubleSidedGI: 0
+ m_CustomRenderQueue: 2467
+ stringTagMap: {RenderType: LegacyCutout}
+ disabledShaderPasses:
+ - Meta
+ m_LockedProperties:
+ m_SavedProperties:
+ serializedVersion: 3
+ m_TexEnvs:
+ - _BaseTexture:
+ m_Texture: {fileID: 0}
+ m_Scale: {x: 1, y: 1}
+ m_Offset: {x: 0, y: 0}
+ - _NormalMap:
+ m_Texture: {fileID: 0}
+ m_Scale: {x: 1, y: 1}
+ m_Offset: {x: 0, y: 0}
+ - _SharedGradients:
+ m_Texture: {fileID: 0}
+ m_Scale: {x: 1, y: 1}
+ m_Offset: {x: 0, y: 0}
+ - _SharedMask:
+ m_Texture: {fileID: 0}
+ m_Scale: {x: 1, y: 1}
+ m_Offset: {x: 0, y: 0}
+ m_Ints: []
+ m_Floats:
+ - _Cull: 2
+ - _Cutoff: 0.5
+ - _NormalScale: 1
+ - _PureBaseShaderLabSentinel: 0
+ m_Colors:
+ - _BaseColor: {r: 0.24, g: 0.72, b: 0.32, a: 1}
+ m_BuildTextureStacks: []
diff --git a/Tests/Fixtures/Materials/PureBaseLegacyCutout.mat.meta b/Tests/Fixtures/Materials/PureBaseLegacyCutout.mat.meta
new file mode 100644
index 00000000..9fe70723
--- /dev/null
+++ b/Tests/Fixtures/Materials/PureBaseLegacyCutout.mat.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 08da6113bfa73184babc243e8b534c74
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Fixtures/RenderingMode.meta b/Tests/Fixtures/RenderingMode.meta
new file mode 100644
index 00000000..44cfda18
--- /dev/null
+++ b/Tests/Fixtures/RenderingMode.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 9c7ac33346090144bac5e3d144fb391a
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Fixtures/RenderingMode/PureBaseUnsupportedRenderingMode.shader b/Tests/Fixtures/RenderingMode/PureBaseUnsupportedRenderingMode.shader
new file mode 100644
index 00000000..0f0c16f1
--- /dev/null
+++ b/Tests/Fixtures/RenderingMode/PureBaseUnsupportedRenderingMode.shader
@@ -0,0 +1,70 @@
+/*
+ * 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.
+ */
+
+// Provides a supported non-PureBase shader with properties covering material atomicity test types.
+
+Shader "PureBaseTests/Unsupported Rendering Mode"
+{
+ Properties
+ {
+ _RenderingMode ("Rendering Mode", Int) = 1
+ _FloatProperty ("Float", Float) = 0
+ _RangeProperty ("Range", Range(0, 1)) = 0.5
+ _IntProperty ("Integer", Integer) = 0
+ _ColorProperty ("Color", Color) = (1, 1, 1, 1)
+ _VectorProperty ("Vector", Vector) = (0, 0, 0, 0)
+ _TextureProperty ("Texture", 2D) = "white" {}
+ }
+
+ SubShader
+ {
+ Tags { "RenderType" = "Opaque" }
+
+ Pass
+ {
+ CGPROGRAM
+ #pragma vertex vert
+ #pragma fragment frag
+
+ #include "UnityCG.cginc"
+
+ struct appdata
+ {
+ float4 vertex : POSITION;
+ };
+
+ struct v2f
+ {
+ float4 vertex : SV_POSITION;
+ };
+
+ fixed4 _ColorProperty;
+
+ v2f vert(appdata input)
+ {
+ v2f output;
+ output.vertex = UnityObjectToClipPos(input.vertex);
+ return output;
+ }
+
+ fixed4 frag(v2f input) : SV_Target
+ {
+ return _ColorProperty;
+ }
+ ENDCG
+ }
+ }
+}
diff --git a/Tests/Fixtures/RenderingMode/PureBaseUnsupportedRenderingMode.shader.meta b/Tests/Fixtures/RenderingMode/PureBaseUnsupportedRenderingMode.shader.meta
new file mode 100644
index 00000000..1dc37e01
--- /dev/null
+++ b/Tests/Fixtures/RenderingMode/PureBaseUnsupportedRenderingMode.shader.meta
@@ -0,0 +1,9 @@
+fileFormatVersion: 2
+guid: 657dd990b3d8e18438b8d30c6be0ef7b
+ShaderImporter:
+ externalObjects: {}
+ defaultTextures: []
+ nonModifiableTextures: []
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Fixtures/Scenes/PureBaseValidation/OwnerLightingData.asset b/Tests/Fixtures/Scenes/PureBaseValidation/OwnerLightingData.asset
new file mode 100644
index 00000000..f801523b
Binary files /dev/null and b/Tests/Fixtures/Scenes/PureBaseValidation/OwnerLightingData.asset differ
diff --git a/Tests/Fixtures/Scenes/PureBaseValidation/OwnerLightingData.asset.meta b/Tests/Fixtures/Scenes/PureBaseValidation/OwnerLightingData.asset.meta
new file mode 100644
index 00000000..b4ea9686
--- /dev/null
+++ b/Tests/Fixtures/Scenes/PureBaseValidation/OwnerLightingData.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 40df5d436b881564c844c61064e7d0be
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 112000000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/README.md b/Tests/README.md
index 1a028394..c35e8810 100644
--- a/Tests/README.md
+++ b/Tests/README.md
@@ -93,6 +93,18 @@ The canonical numeric baseline is:
Daily reads this baseline. Daily never creates or replaces it.
+## Rendering-mode coverage
+
+The rendering-mode contract covered by the package validation inputs is:
+
+| Mode | Covered behavior |
+| --- | --- |
+| Opaque | Uncut and unblended rendering, queue `2000`, `One Zero`, and `ZWrite 1`; lighting contributions enabled. |
+| Cutout | Coverage clipping, the default keyword-free state, queue override `-1` resolving to `AlphaTest 2450`, and lighting contributions enabled. |
+| Transparent | Alpha blending with base `SrcAlpha OneMinusSrcAlpha` and additional-light `SrcAlpha One`, queue `3000`, `ZWrite 0`, and disabled `ShadowCaster`/`Meta`. |
+
+The coverage checks also verify that the final alpha from `postpixel` controls the `ForwardBase` and `ForwardAdd` source alpha. All source shaders retain four pass declarations. Editor migration is explicit: Inspector opening or refresh does not migrate or dirty legacy materials, while mode changes and `Assets/PureBase/Resync Rendering Mode` synchronize derived state.
+
## Observation, apply, and regeneration
Observation, reviewed apply, and regeneration are explicit write-capable operations separate from the normal Daily lane.
diff --git a/Tests/Release/ConsumerProject/Assets/Editor/PureBase.Release.Consumer.Tests.asmdef b/Tests/Release/ConsumerProject/Assets/Editor/PureBase.Release.Consumer.Tests.asmdef
index 3338dd56..999c848c 100644
--- a/Tests/Release/ConsumerProject/Assets/Editor/PureBase.Release.Consumer.Tests.asmdef
+++ b/Tests/Release/ConsumerProject/Assets/Editor/PureBase.Release.Consumer.Tests.asmdef
@@ -1,7 +1,9 @@
{
"name": "PureBase.Release.Consumer.Tests",
"rootNamespace": "PureBase.Release.Consumer.Tests",
- "references": [],
+ "references": [
+ "PureBase.Editor"
+ ],
"includePlatforms": [
"Editor"
],
@@ -16,4 +18,4 @@
"optionalUnityReferences": [
"TestAssemblies"
]
-}
\ No newline at end of file
+}
diff --git a/Tests/Release/ConsumerProject/Assets/Editor/PureBaseConsumerRenderingModeTests.cs b/Tests/Release/ConsumerProject/Assets/Editor/PureBaseConsumerRenderingModeTests.cs
new file mode 100644
index 00000000..e854af14
--- /dev/null
+++ b/Tests/Release/ConsumerProject/Assets/Editor/PureBaseConsumerRenderingModeTests.cs
@@ -0,0 +1,405 @@
+/*
+ * 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.
+ */
+
+// Validates the shipped rendering-mode ABI, material state table, and postpixel alpha release probe.
+
+using System;
+using System.IO;
+using System.Text.RegularExpressions;
+using NUnit.Framework;
+using PureBase.Editor;
+using UnityEngine;
+using UnityEngine.Rendering;
+
+namespace PureBase.Release.Consumer.Tests
+{
+ /// Defines cold-consumer rendering-mode and postpixel-alpha contracts before the package implementation exists.
+ public sealed class PureBaseConsumerRenderingModeTests
+ {
+ /// Identifies the only release module selected by the postpixel alpha consumer invocation.
+ private const string PostPixelAlphaProbeId = "jp.penguin.purebase.release.fixture.products.postpixel";
+
+ /// Lists every local keyword owned by the rendering-mode contract.
+ private static readonly string[] RenderingModeKeywords =
+ {
+ "PUREBASE_RENDERING_OPAQUE",
+ "PUREBASE_RENDERING_TRANSPARENT",
+ };
+
+ /// Lists every hidden state property synchronized by the public normalizer.
+ private static readonly string[] HiddenStatePropertyNames =
+ {
+ "_SrcBlend",
+ "_DstBlend",
+ "_ZWrite",
+ "_AddSrcBlend",
+ "_AddDstBlend",
+ };
+
+ /// Lists the four declared source passes retained regardless of material contribution state.
+ private static readonly string[] SourcePassNames =
+ {
+ "ForwardBase",
+ "ForwardAdd",
+ "ShadowCaster",
+ "Meta",
+ };
+
+ /// Matches the source declaration for the public integer rendering-mode selector.
+ private const string RenderingModePropertySourcePattern =
+ @"SC_uint\s*\(\s*_RenderingMode\s*,\s*1(?:\.0+)?\s*,\s*\[\s*PureBaseRenderingMode\s*\]\s*,\s*""[^""\r\n]*""\s*,\s*""[^""\r\n]*""\s*\)";
+
+ /// Matches the generated ForwardBase fragment function declaration.
+ private const string FragmentFunctionDeclarationPattern =
+ @"(?m)^[ \t]*(?:half|float|fixed)[1-4]?\s+frag\s*\(";
+
+ /// Requires the dedicated cold-import invocation to select the alpha probe for Transparent Toon observations.
+ [Test]
+ public void PostPixelAlphaConsumerInvocationSelectsTheTransparentToonProbeContract()
+ {
+ ConsumerValidationContract contract = ConsumerValidationSupport.LoadContract();
+ ConsumerProductContract product = AssertPostPixelAlphaProductContract(contract);
+ Shader shader = ConsumerValidationSupport.ImportProductShader(
+ product,
+ contract.runLabel
+ );
+ CollectionAssert.AreEqual(SourcePassNames, ConsumerValidationSupport.GetPassNames(shader));
+ string generatedSource = ConsumerValidationSupport.LoadGeneratedSource(product, contract.runLabel);
+ PureBaseConsumerModuleFreeImportTests.AssertGlobalFragments(
+ contract,
+ product,
+ generatedSource
+ );
+ PureBaseConsumerModuleFreeImportTests.AssertPassContracts(
+ contract,
+ product,
+ generatedSource,
+ false
+ );
+ AssertTransparentToonAlphaProbeContract(contract, product, generatedSource);
+ }
+
+ /// Validates and returns the sole Toon product selected for the postpixel alpha probe invocation.
+ /// The loaded consumer validation contract.
+ /// The selected Toon product contract.
+ private static ConsumerProductContract AssertPostPixelAlphaProductContract(
+ ConsumerValidationContract contract
+ )
+ {
+ Assert.That(contract.runKind, Is.EqualTo("product-phase"));
+ Assert.That(contract.hasSelectedModule, Is.True);
+ Assert.That(contract.selectedModule, Is.Not.Null);
+ Assert.That(contract.selectedModule.phase, Is.EqualTo("postpixel"));
+ Assert.That(contract.selectedModule.moduleUniqueId, Is.EqualTo(PostPixelAlphaProbeId));
+ Assert.That(contract.products, Is.Not.Null.And.Length.EqualTo(1));
+ Assert.That(contract.products[0].shaderName, Is.EqualTo("PureBase/Toon"));
+ return contract.products[0];
+ }
+
+ /// Checks that the generated Toon ForwardBase fragment applies the alpha probe after rendering-mode output alpha handling and before return.
+ /// The loaded consumer validation contract.
+ /// The selected Toon product contract.
+ /// The generated Toon shader source.
+ private static void AssertTransparentToonAlphaProbeContract(
+ ConsumerValidationContract contract,
+ ConsumerProductContract product,
+ string generatedSource
+ )
+ {
+ string forwardBaseSource = ConsumerValidationSupport.GetPassSource(
+ generatedSource,
+ "ForwardBase",
+ "ForwardAdd",
+ contract.runLabel,
+ product.shaderName
+ );
+ string fragmentBody = GetFragmentBody(
+ forwardBaseSource,
+ contract.runLabel,
+ product.shaderName
+ );
+ Match modeAlphaOperation = Regex.Match(
+ fragmentBody,
+ @"\bPureBaseApplyRenderingModeOutputAlpha\s*\("
+ );
+ Match alphaProbeMatch = Regex.Match(
+ fragmentBody,
+ @"\bsd\.col\.a\s*=\s*half\s*\(\s*0\.25\s*\)\s*;"
+ );
+ Assert.That(
+ alphaProbeMatch.Success,
+ Is.True,
+ "The ForwardBase fragment must contain the transparent toon alpha probe contract."
+ );
+ int alphaProbe = alphaProbeMatch.Index;
+ Match returnStatement = Regex.Match(
+ fragmentBody.Substring(alphaProbe),
+ @"\breturn\b"
+ );
+ Assert.That(modeAlphaOperation.Success, Is.True);
+ Assert.That(alphaProbe, Is.GreaterThan(modeAlphaOperation.Index));
+ Assert.That(returnStatement.Success, Is.True);
+ Assert.That(
+ alphaProbe + returnStatement.Index,
+ Is.GreaterThan(alphaProbe),
+ "The postpixel alpha probe must execute before the fragment return."
+ );
+ }
+
+ /// Returns the body of the generated ForwardBase fragment function without imported helper declarations.
+ /// The generated ForwardBase pass source.
+ /// The current consumer invocation label.
+ /// The public shader name used in diagnostics.
+ /// The text between the fragment function's outer braces.
+ private static string GetFragmentBody(string passSource, string runLabel, string shaderName)
+ {
+ Match declaration = Regex.Match(passSource, FragmentFunctionDeclarationPattern);
+ Assert.That(
+ declaration.Success,
+ Is.True,
+ $"Consumer run '{runLabel}' product '{shaderName}' did not contain a generated ForwardBase frag function."
+ );
+ int openingBrace = passSource.IndexOf(
+ '{',
+ declaration.Index + declaration.Length
+ );
+ Assert.That(
+ openingBrace,
+ Is.GreaterThanOrEqualTo(0),
+ $"Consumer run '{runLabel}' product '{shaderName}' generated frag function has no opening brace."
+ );
+
+ int braceDepth = 1;
+ for (int index = openingBrace + 1; index < passSource.Length; index++)
+ {
+ if (passSource[index] == '{')
+ {
+ braceDepth++;
+ }
+ else if (passSource[index] == '}' && --braceDepth == 0)
+ {
+ return passSource.Substring(openingBrace + 1, index - openingBrace - 1);
+ }
+ }
+
+ Assert.Fail(
+ $"Consumer run '{runLabel}' product '{shaderName}' generated frag function has no closing brace."
+ );
+ return string.Empty;
+ }
+
+ /// Requires every public shader to implement the complete rendering-mode ABI and state table through the shipped Editor assembly.
+ [Test]
+ public void ColdImportedPublicNormalizerMatchesTheFourByThreeStateTable()
+ {
+ ConsumerValidationContract contract = ConsumerValidationSupport.LoadContract();
+ Assert.That(contract.runKind, Is.EqualTo("module-free"));
+ Assert.That(contract.hasSelectedModule, Is.False);
+ PureBaseConsumerModuleFreeImportTests.AssertRequiredProductSet(contract);
+
+ foreach (ConsumerProductContract product in contract.products)
+ {
+ Shader shader = ConsumerValidationSupport.ImportProductShader(product, contract.runLabel);
+ AssertRenderingModeAbi(product, shader, contract.runLabel);
+ var material = new Material(shader);
+ try
+ {
+ AssertCutoutDefaults(material, product.shaderName);
+ foreach (int mode in new[] { 0, 1, 2 })
+ {
+ material.SetInteger("_RenderingMode", mode);
+ PureBaseMaterialRenderingMode.Apply(material);
+ AssertModeState(material, product.shaderName, mode);
+ }
+
+ AssertInvalidModeIsAtomic(material, product.shaderName, -1);
+ AssertInvalidModeIsAtomic(material, product.shaderName, 3);
+ }
+ finally
+ {
+ UnityEngine.Object.DestroyImmediate(material);
+ }
+ }
+ }
+
+ /// Checks the visible integer selector, hidden state fields, local keywords, declared passes, and source declaration for one product.
+ /// The runner-provided product contract.
+ /// The imported public shader.
+ /// The current consumer invocation label.
+ private static void AssertRenderingModeAbi(
+ ConsumerProductContract product,
+ Shader shader,
+ string runLabel
+ )
+ {
+ CollectionAssert.AreEqual(SourcePassNames, ConsumerValidationSupport.GetPassNames(shader));
+ CollectionAssert.Contains(
+ ConsumerValidationSupport.GetVisiblePropertyNames(shader),
+ "_RenderingMode"
+ );
+ int modeIndex = shader.FindPropertyIndex("_RenderingMode");
+ Assert.That(modeIndex, Is.GreaterThanOrEqualTo(0));
+ Assert.That(shader.GetPropertyType(modeIndex), Is.EqualTo(ShaderPropertyType.Int));
+ CollectionAssert.Contains(shader.GetPropertyAttributes(modeIndex), "PureBaseRenderingMode");
+ foreach (string propertyName in HiddenStatePropertyNames)
+ {
+ Assert.That(shader.FindPropertyIndex(propertyName), Is.GreaterThanOrEqualTo(0));
+ CollectionAssert.DoesNotContain(
+ ConsumerValidationSupport.GetVisiblePropertyNames(shader),
+ propertyName
+ );
+ }
+
+ string generatedSource = ConsumerValidationSupport.LoadGeneratedSource(product, runLabel);
+ StringAssert.Contains(
+ "#pragma shader_feature_local _ PUREBASE_RENDERING_OPAQUE PUREBASE_RENDERING_TRANSPARENT",
+ generatedSource
+ );
+ Assert.That(
+ generatedSource.IndexOf("PUREBASE_RENDERING_CUTOUT", StringComparison.Ordinal),
+ Is.LessThan(0),
+ product.shaderName + " must keep Cutout keyword-free."
+ );
+ string propertySourcePath = Path.ChangeExtension(product.shaderAssetPath, null)
+ + "_properties.hlsl";
+ string projectRoot = Directory.GetParent(Application.dataPath).FullName;
+ string propertySource = File.ReadAllText(
+ Path.Combine(projectRoot, propertySourcePath.Replace('/', Path.DirectorySeparatorChar))
+ );
+ Assert.That(
+ Regex.IsMatch(propertySource, RenderingModePropertySourcePattern),
+ Is.True,
+ product.shaderName + " must declare _RenderingMode through SC_uint with default 1."
+ );
+ }
+
+ /// Checks the static Cutout-compatible default state before an explicit normalization mutates the material.
+ /// The new transient material.
+ /// The material's public shader name.
+ private static void AssertCutoutDefaults(Material material, string shaderName)
+ {
+ Assert.That(material.GetInteger("_RenderingMode"), Is.EqualTo(1));
+ AssertModeState(material, shaderName, 1);
+ }
+
+ /// Checks all derived state fields for one supported material mode without conflating source pass presence with material pass enablement.
+ /// The normalized transient material.
+ /// The material's public shader name.
+ /// The public rendering-mode value.
+ private static void AssertModeState(Material material, string shaderName, int mode)
+ {
+ Assert.That(material.GetInteger("_RenderingMode"), Is.EqualTo(mode), shaderName + " rendering mode.");
+ var expectedState = GetExpectedModeState(mode);
+ AssertDerivedModeState(material, expectedState);
+ }
+
+ /// Returns the complete derived render-state, keyword, and contribution-pass expectations for one supported rendering mode.
+ /// The public rendering-mode value.
+ /// The expected state for the requested mode.
+ private static (
+ int sourceBlend,
+ int destinationBlend,
+ int depthWrite,
+ int additiveSourceBlend,
+ int additiveDestinationBlend,
+ string renderType,
+ int renderQueue,
+ bool opaqueKeyword,
+ bool transparentKeyword,
+ bool contributionPasses
+ ) GetExpectedModeState(int mode)
+ {
+ switch (mode)
+ {
+ case 0:
+ return (
+ (int)BlendMode.One, (int)BlendMode.Zero, 1, (int)BlendMode.One,
+ (int)BlendMode.One, "Opaque", 2000, true, false, true
+ );
+ case 1:
+ return (
+ (int)BlendMode.One, (int)BlendMode.Zero, 1, (int)BlendMode.One,
+ (int)BlendMode.One, "TransparentCutout", (int)RenderQueue.AlphaTest, false, false, true
+ );
+ case 2:
+ return (
+ (int)BlendMode.SrcAlpha, (int)BlendMode.OneMinusSrcAlpha, 0,
+ (int)BlendMode.SrcAlpha, (int)BlendMode.One, "Transparent", 3000, false, true, false
+ );
+ default:
+ throw new ArgumentOutOfRangeException(nameof(mode));
+ }
+ }
+
+ /// Compares a normalized material's derived state to one supported rendering-mode expectation.
+ /// The normalized transient material.
+ /// The expected derived state.
+ private static void AssertDerivedModeState(
+ Material material,
+ (
+ int sourceBlend,
+ int destinationBlend,
+ int depthWrite,
+ int additiveSourceBlend,
+ int additiveDestinationBlend,
+ string renderType,
+ int renderQueue,
+ bool opaqueKeyword,
+ bool transparentKeyword,
+ bool contributionPasses
+ ) expectedState
+ )
+ {
+ Assert.That(material.GetFloat("_SrcBlend"), Is.EqualTo((float)expectedState.sourceBlend));
+ Assert.That(material.GetFloat("_DstBlend"), Is.EqualTo((float)expectedState.destinationBlend));
+ Assert.That(material.GetFloat("_ZWrite"), Is.EqualTo((float)expectedState.depthWrite));
+ Assert.That(material.GetFloat("_AddSrcBlend"), Is.EqualTo((float)expectedState.additiveSourceBlend));
+ Assert.That(material.GetFloat("_AddDstBlend"), Is.EqualTo((float)expectedState.additiveDestinationBlend));
+ Assert.That(material.GetTag("RenderType", false), Is.EqualTo(expectedState.renderType));
+ Assert.That(material.renderQueue, Is.EqualTo(expectedState.renderQueue));
+ Assert.That(material.IsKeywordEnabled(RenderingModeKeywords[0]), Is.EqualTo(expectedState.opaqueKeyword));
+ Assert.That(material.IsKeywordEnabled(RenderingModeKeywords[1]), Is.EqualTo(expectedState.transparentKeyword));
+ Assert.That(material.GetShaderPassEnabled("ShadowCaster"), Is.EqualTo(expectedState.contributionPasses));
+ Assert.That(material.GetShaderPassEnabled("Meta"), Is.EqualTo(expectedState.contributionPasses));
+ }
+
+ /// Requires invalid public mode values to leave all derived state from the prior valid mode unchanged.
+ /// The reusable transient material.
+ /// The material's public shader name.
+ /// The unsupported public mode value.
+ private static void AssertInvalidModeIsAtomic(Material material, string shaderName, int invalidMode)
+ {
+ material.SetInteger("_RenderingMode", 0);
+ PureBaseMaterialRenderingMode.Apply(material);
+ material.SetInteger("_RenderingMode", invalidMode);
+ Assert.Throws(
+ () => PureBaseMaterialRenderingMode.Apply(material)
+ );
+ Assert.That(material.GetInteger("_RenderingMode"), Is.EqualTo(invalidMode));
+ Assert.That(material.GetFloat("_SrcBlend"), Is.EqualTo((float)BlendMode.One));
+ Assert.That(material.GetFloat("_DstBlend"), Is.EqualTo((float)BlendMode.Zero));
+ Assert.That(material.GetFloat("_ZWrite"), Is.EqualTo(1.0f));
+ Assert.That(material.GetFloat("_AddSrcBlend"), Is.EqualTo((float)BlendMode.One));
+ Assert.That(material.GetFloat("_AddDstBlend"), Is.EqualTo((float)BlendMode.One));
+ Assert.That(material.GetTag("RenderType", false), Is.EqualTo("Opaque"));
+ Assert.That(material.renderQueue, Is.EqualTo(2000));
+ Assert.That(material.IsKeywordEnabled(RenderingModeKeywords[0]), Is.True);
+ Assert.That(material.IsKeywordEnabled(RenderingModeKeywords[1]), Is.False);
+ Assert.That(material.GetShaderPassEnabled("ShadowCaster"), Is.True);
+ Assert.That(material.GetShaderPassEnabled("Meta"), Is.True, shaderName + " invalid mode must not disable Meta.");
+ }
+ }
+}
diff --git a/Tests/Release/ConsumerProject/Assets/Editor/PureBaseConsumerRenderingModeTests.cs.meta b/Tests/Release/ConsumerProject/Assets/Editor/PureBaseConsumerRenderingModeTests.cs.meta
new file mode 100644
index 00000000..4daed847
--- /dev/null
+++ b/Tests/Release/ConsumerProject/Assets/Editor/PureBaseConsumerRenderingModeTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ed93fb13805e8864189d4e85e5b552fc
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Release/Modules/RenderingMode.meta b/Tests/Release/Modules/RenderingMode.meta
new file mode 100644
index 00000000..6fa17069
--- /dev/null
+++ b/Tests/Release/Modules/RenderingMode.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: f5ab3e3475b8ad74bb471bb55ae6cc42
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Release/Modules/RenderingMode/PostPixelAlpha.meta b/Tests/Release/Modules/RenderingMode/PostPixelAlpha.meta
new file mode 100644
index 00000000..13295a9f
--- /dev/null
+++ b/Tests/Release/Modules/RenderingMode/PostPixelAlpha.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: a24e069f9665ee94ea7acc1ee380efcd
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Release/Modules/Standard/PostPixel/phase_postpixel.hlsl b/Tests/Release/Modules/Standard/PostPixel/phase_postpixel.hlsl
index b9a2f9d9..401a25a2 100644
--- a/Tests/Release/Modules/Standard/PostPixel/phase_postpixel.hlsl
+++ b/Tests/Release/Modules/Standard/PostPixel/phase_postpixel.hlsl
@@ -17,4 +17,5 @@
// Defines the product-safe postpixel-phase source sentinel.
#define PUREBASE_ALL_PRODUCT_PHASE_SENTINEL_POSTPIXEL 1
-sd.col.rgb += half3(0, 0, 0);
\ No newline at end of file
+sd.col.rgb += half3(0, 0, 0);
+sd.col.a = half(0.25);
\ No newline at end of file
diff --git a/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1 b/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1
index 9a2318ce..79a9e8a7 100644
--- a/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1
+++ b/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1
@@ -706,7 +706,7 @@ try {
$conflictFailure = $_
}
Assert-Harness -Condition ($null -ne $conflictFailure) -Message 'Incompatible runner switches unexpectedly passed.'
- Assert-Harness -Condition ($conflictFailure.Exception.Message -eq '-ModuleFreeOnly cannot be combined with -CompareWarmAndColdStandardMorph because the latter requires the four-row standard-morph comparison: module-free import, module-free Toon runtime observation, warm, and cold.') -Message 'Incompatible runner switches did not report the deterministic conflict error before Unity validation.'
+ Assert-Harness -Condition ($conflictFailure.Exception.Message -eq '-ModuleFreeOnly cannot be combined with -CompareWarmAndColdStandardMorph because the latter requires the five-row standard-morph comparison: module-free import, rendering-mode contract, module-free Toon runtime observation, warm, and cold.') -Message 'Incompatible runner switches did not report the deterministic conflict error before Unity validation.'
Assert-Harness -Condition (-not (Test-Path -LiteralPath $conflictArtifactDirectory)) -Message 'Incompatible runner switches created an artifact directory before failing.'
$toonBaseConflictArtifactDirectory = Join-Path ([System.IO.Path]::GetTempPath()) ('PureBaseReleaseToonBaseConflict-' + [guid]::NewGuid().ToString('N'))
@@ -732,11 +732,14 @@ try {
Assert-Harness -Condition ($bakeOnlyMatrix.Count -eq 1 -and $bakeOnlyMatrix[0].label -eq 'progressive-cpu-bake') -Message 'Bake-only matrix selection did not return exactly the progressive-cpu-bake row.'
$initialMatrix = New-InitialValidationMatrix
- Assert-Harness -Condition ($initialMatrix.Count -eq 2 -and $initialMatrix[0].label -eq 'module-free-clean-import' -and $initialMatrix[1].label -eq 'module-free-toon-runtime-observation') -Message 'The module-free Toon runtime observation row did not follow the unchanged module-free clean-import row.'
+ Assert-Harness -Condition ($initialMatrix.Count -eq 3 -and [string]::Join('|', @($initialMatrix | ForEach-Object { [string]$_.label })) -eq 'module-free-clean-import|rendering-mode-contract|module-free-toon-runtime-observation') -Message 'The initial validation matrix must order module-free import, rendering-mode contract, and module-free Toon runtime observation rows.'
$moduleFreeEntry = $initialMatrix[0]
Assert-Harness -Condition ($moduleFreeEntry.contract.runLabel -eq 'module-free-clean-import' -and $moduleFreeEntry.contract.runKind -eq 'module-free' -and -not $moduleFreeEntry.contract.hasSelectedModule -and $null -eq $moduleFreeEntry.contract.selectedModule -and @($moduleFreeEntry.contract.runtimeSamples).Count -eq 0) -Message 'The existing module-free clean-import contract changed while adding the Toon runtime observation.'
Assert-Harness -Condition ($moduleFreeEntry.filter -eq 'PureBase.Release.Consumer.Tests.PureBaseConsumerModuleFreeImportTests.ModuleFreeProductsCompileWithConfiguredPassPropertyAndSourceContracts' -and @($moduleFreeEntry.selections.Keys).Count -eq 0 -and -not $moduleFreeEntry.skipColdLibraryReset) -Message 'The existing module-free clean-import matrix row changed while adding the Toon runtime observation.'
- $moduleFreeToonRuntimeEntry = $initialMatrix[1]
+ $renderingModeEntry = $initialMatrix[1]
+ Assert-Harness -Condition ($renderingModeEntry.contract.runLabel -eq 'module-free-clean-import' -and $renderingModeEntry.contract.runKind -eq 'module-free' -and -not $renderingModeEntry.contract.hasSelectedModule -and $null -eq $renderingModeEntry.contract.selectedModule -and @($renderingModeEntry.contract.runtimeSamples).Count -eq 0) -Message 'The rendering-mode contract must retain the module-free import contract.'
+ Assert-Harness -Condition ($renderingModeEntry.filter -eq 'PureBase.Release.Consumer.Tests.PureBaseConsumerRenderingModeTests.ColdImportedPublicNormalizerMatchesTheFourByThreeStateTable' -and @($renderingModeEntry.selections.Keys).Count -eq 0 -and -not $renderingModeEntry.skipColdLibraryReset) -Message 'The rendering-mode contract row did not use the deterministic cold module-free test configuration.'
+ $moduleFreeToonRuntimeEntry = $initialMatrix[2]
$moduleFreeToonRuntimeContract = $moduleFreeToonRuntimeEntry.contract
$moduleFreeToonRuntimeSample = $moduleFreeToonRuntimeContract.runtimeSamples[0]
Assert-Harness -Condition ($moduleFreeToonRuntimeEntry.filter -eq 'PureBase.Release.Consumer.Tests.PureBaseConsumerRuntimeTests.ConfiguredRuntimeSamplesProduceExpectedBirpReadbacks' -and @($moduleFreeToonRuntimeEntry.selections.Keys).Count -eq 0 -and -not $moduleFreeToonRuntimeEntry.skipColdLibraryReset) -Message 'The module-free Toon runtime observation row did not use the deterministic cold runtime test configuration.'
@@ -747,11 +750,11 @@ try {
}
Assert-Harness -Condition ($moduleFreeToonRuntimeSample.red.minimum -eq 0.0 -and $moduleFreeToonRuntimeSample.red.maximum -eq 1000.0 -and $moduleFreeToonRuntimeSample.green.minimum -eq 0.0 -and $moduleFreeToonRuntimeSample.green.maximum -eq 1000.0 -and $moduleFreeToonRuntimeSample.blue.minimum -eq 0.0 -and $moduleFreeToonRuntimeSample.blue.maximum -eq 1000.0 -and $moduleFreeToonRuntimeSample.alpha.minimum -eq 0.99 -and $moduleFreeToonRuntimeSample.alpha.maximum -eq 1.01) -Message 'The module-free Toon runtime observation ranges changed from their finite structural baseline.'
$moduleFreeOnlyInitialMatrix = New-InitialValidationMatrix -ModuleFreeOnly
- Assert-Harness -Condition ($moduleFreeOnlyInitialMatrix.Count -eq 1 -and $moduleFreeOnlyInitialMatrix[0].label -eq 'module-free-clean-import') -Message 'Module-free-only validation no longer selects exactly the unchanged module-free clean-import row.'
+ Assert-Harness -Condition ($moduleFreeOnlyInitialMatrix.Count -eq 2 -and [string]::Join('|', @($moduleFreeOnlyInitialMatrix | ForEach-Object { [string]$_.label })) -eq 'module-free-clean-import|rendering-mode-contract') -Message 'Module-free-only validation must select module-free import and rendering-mode contract rows in order.'
$comparisonMatrix = New-InitialValidationMatrix
$comparisonContracts = Add-StandardMorphComparisonMatrixRows -Matrix $comparisonMatrix
$comparisonLabels = @($comparisonMatrix | ForEach-Object { [string]$_.label })
- Assert-Harness -Condition ($comparisonMatrix.Count -eq 4 -and [string]::Join('|', $comparisonLabels) -eq 'module-free-clean-import|module-free-toon-runtime-observation|standard-morph-warm-library-duplicate-evidence|standard-morph-cold-library-legacy-counts' -and $comparisonContracts.warmContract.runLabel -eq $comparisonLabels[2] -and $comparisonContracts.coldContract.runLabel -eq $comparisonLabels[3]) -Message 'Standard-morph comparison matrix must retain the module-free import and Toon runtime observation rows before the warm and cold rows.'
+ Assert-Harness -Condition ($comparisonMatrix.Count -eq 5 -and [string]::Join('|', $comparisonLabels) -eq 'module-free-clean-import|rendering-mode-contract|module-free-toon-runtime-observation|standard-morph-warm-library-duplicate-evidence|standard-morph-cold-library-legacy-counts' -and $comparisonContracts.warmContract.runLabel -eq $comparisonLabels[3] -and $comparisonContracts.coldContract.runLabel -eq $comparisonLabels[4]) -Message 'Standard-morph comparison matrix must retain module-free import, rendering-mode contract, and Toon runtime observation rows before the warm and cold rows.'
Assert-Harness -Condition ($moduleFreeToonRuntimeEntry.requiresColdLibraryReset) -Message 'The module-free Toon runtime observation row did not explicitly require a cold Library reset.'
$moduleFreeToonRuntimeCase = Invoke-HarnessCase -Label $moduleFreeToonRuntimeEntry.label -Contract $moduleFreeToonRuntimeContract -Selections $moduleFreeToonRuntimeEntry.selections -RequireColdLibraryReset:$moduleFreeToonRuntimeEntry.requiresColdLibraryReset -SkipColdLibraryReset:$moduleFreeToonRuntimeEntry.skipColdLibraryReset -ManifestHashes @('bootstrap', 'bootstrap', 'row', 'row', 'row') -TestFilter $moduleFreeToonRuntimeEntry.filter
Assert-Harness -Condition ($null -eq $moduleFreeToonRuntimeCase.failure) -Message 'Module-free Toon runtime observation harness case unexpectedly failed.'
@@ -878,7 +881,7 @@ try {
$expectedFirstBootstrapAddedCount = @(Get-ExpectedFirstBootstrapAddedPaths).Count
$expectedFirstBootstrapChangedCount = @(Get-ExpectedFirstBootstrapChangedPaths).Count
$expectedFirstBootstrapAcceptedCount = $expectedFirstBootstrapAddedCount + $expectedFirstBootstrapChangedCount
- Assert-Harness -Condition ($expectedFirstBootstrapAddedCount -eq 31 -and $expectedFirstBootstrapChangedCount -eq 2 -and $expectedFirstBootstrapAcceptedCount -eq 33) -Message 'First-bootstrap expected transition counts do not match the hosted consumer contract.'
+ Assert-Harness -Condition ($expectedFirstBootstrapAddedCount -eq 25 -and $expectedFirstBootstrapChangedCount -eq 2 -and $expectedFirstBootstrapAcceptedCount -eq 27) -Message 'First-bootstrap expected transition counts do not match the hosted consumer contract.'
foreach ($successfulLabel in @('module-free-clean-import', 'progressive-cpu-bake')) {
$case = Invoke-HarnessCase -Label $successfulLabel -ManifestHashes @('bootstrap', 'bootstrap', 'row', 'row')
diff --git a/Tests/Release/Run-PureBaseReleaseValidation.ps1 b/Tests/Release/Run-PureBaseReleaseValidation.ps1
index 2c795764..c0ee0398 100644
--- a/Tests/Release/Run-PureBaseReleaseValidation.ps1
+++ b/Tests/Release/Run-PureBaseReleaseValidation.ps1
@@ -1637,25 +1637,33 @@ function New-ProductContract {
$passName = $ProductPasses[$passIndex]
$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') }
+ default { @() }
+ }
if ($selectedSentinelCount -lt 0) {
throw "Product pass sentinel count for '$ShaderName' pass '$passName' cannot be negative."
}
if ($selectedSentinelCount -gt 0 -and [string]::IsNullOrEmpty($Sentinel)) {
throw "Product pass sentinel count for '$ShaderName' pass '$passName' requires a sentinel."
}
+ if ($selectedSentinelCount -gt 0) {
+ $requiredFragments += $Sentinel
+ }
$passContracts += [ordered]@{
passName = $passName
nextPassName = $nextPassName
- requiredFragments = if ($selectedSentinelCount -gt 0) { @($Sentinel) } else { @() }
+ requiredFragments = $requiredFragments
forbiddenFragments = @()
selectedSentinelCount = $selectedSentinelCount
}
}
$expectedVisiblePropertyNames = switch ($ShaderName) {
- 'PureBase/Unlit' { @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_Cutoff', '_Cull') }
- 'PureBase/Toon' { @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_Cutoff', '_Cull', '_NormalMap', '_NormalScale') }
- 'PureBase/PBR' { @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_Cutoff', '_Cull', '_NormalMap', '_NormalScale', '_Metallic', '_Roughness') }
- 'PureBase/Hybrid' { @('_BaseTexture', '_BaseColor', '_SharedMask', '_SharedGradients', '_Cutoff', '_Cull', '_NormalMap', '_NormalScale', '_Metallic', '_Roughness') }
+ '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') }
default { throw "Unsupported PureBase product '$ShaderName'." }
}
return [ordered]@{
@@ -1663,7 +1671,7 @@ function New-ProductContract {
shaderAssetPath = Get-ProductShaderAssetPath -ShaderName $ShaderName
expectedPassNames = $ProductPasses
expectedVisiblePropertyNames = $expectedVisiblePropertyNames
- requiredSourceFragments = @()
+ requiredSourceFragments = @('#pragma shader_feature_local _ PUREBASE_RENDERING_OPAQUE PUREBASE_RENDERING_TRANSPARENT')
forbiddenSourceFragments = @()
passContracts = $passContracts
}
@@ -1756,6 +1764,7 @@ function New-InitialValidationMatrix {
$matrix = New-Object System.Collections.Generic.List[object]
$matrix.Add([ordered]@{ label = 'module-free-clean-import'; contract = New-ModuleFreeContract; filter = 'PureBase.Release.Consumer.Tests.PureBaseConsumerModuleFreeImportTests.ModuleFreeProductsCompileWithConfiguredPassPropertyAndSourceContracts'; selections = @{}; skipColdLibraryReset = $false })
+ $matrix.Add([ordered]@{ label = 'rendering-mode-contract'; contract = New-ModuleFreeContract; filter = 'PureBase.Release.Consumer.Tests.PureBaseConsumerRenderingModeTests.ColdImportedPublicNormalizerMatchesTheFourByThreeStateTable'; selections = @{}; skipColdLibraryReset = $false })
if (-not $ModuleFreeOnly) {
$matrix.Add([ordered]@{ label = 'module-free-toon-runtime-observation'; contract = New-ModuleFreeToonRuntimeObservationContract; filter = 'PureBase.Release.Consumer.Tests.PureBaseConsumerRuntimeTests.ConfiguredRuntimeSamplesProduceExpectedBirpReadbacks'; selections = @{}; requiresColdLibraryReset = $true; skipColdLibraryReset = $false })
}
@@ -2492,10 +2501,10 @@ function Remove-ConsumerProject {
$packageRoot = Get-PackageGitRoot
if ($ModuleFreeOnly -and $CompareWarmAndColdStandardMorph) {
- throw '-ModuleFreeOnly cannot be combined with -CompareWarmAndColdStandardMorph because the latter requires the four-row standard-morph comparison: module-free import, module-free Toon runtime observation, warm, and cold.'
+ throw '-ModuleFreeOnly cannot be combined with -CompareWarmAndColdStandardMorph because the latter requires the five-row standard-morph comparison: module-free import, rendering-mode contract, module-free Toon runtime observation, warm, and cold.'
}
if ($ToonBaseOnly -and $CompareWarmAndColdStandardMorph) {
- throw '-ToonBaseOnly cannot be combined with -CompareWarmAndColdStandardMorph because the latter requires the four-row standard-morph comparison: module-free import, module-free Toon runtime observation, warm, and cold.'
+ throw '-ToonBaseOnly cannot be combined with -CompareWarmAndColdStandardMorph because the latter requires the five-row standard-morph comparison: module-free import, rendering-mode contract, module-free Toon runtime observation, warm, and cold.'
}
if ($ToonBaseOnly -and $ModuleFreeOnly) {
throw '-ToonBaseOnly cannot be combined with -ModuleFreeOnly because it requires the Toon base product-phase row.'
@@ -2602,6 +2611,9 @@ try {
}
$matrix.Add([ordered]@{ label = 'unlit-forward-add-fog'; contract = New-FogContract; filter = 'PureBase.Release.Consumer.Tests.PureBaseConsumerUnlitForwardAddFogTests.SelectedForwardAddSignalAttenuatesTowardBlackWithControlledFog'; selections = @{ 'PureBase/Unlit' = @('jp.penguin.purebase.release.fixture.unlit.forwardaddfog') }; skipColdLibraryReset = $false })
$matrix.Add([ordered]@{ label = 'module-order'; contract = New-ModuleOrderContract; filter = 'PureBase.Release.Consumer.Tests.PureBaseConsumerModuleOrderTests.ConfiguredModuleOrderAppearsOnlyInExpectedProductPasses'; selections = @{ 'PureBase/Unlit' = @('jp.penguin.purebase.release.fixture.module-order.alpha', 'jp.penguin.purebase.release.fixture.module-order.zeta'); 'PureBase/Toon' = @('jp.penguin.purebase.release.fixture.module-order.alpha', 'jp.penguin.purebase.release.fixture.module-order.zeta'); 'PureBase/PBR' = @('jp.penguin.purebase.release.fixture.module-order.alpha', 'jp.penguin.purebase.release.fixture.module-order.zeta'); 'PureBase/Hybrid' = @('jp.penguin.purebase.release.fixture.module-order.alpha', 'jp.penguin.purebase.release.fixture.module-order.zeta') }; skipColdLibraryReset = $false })
+ $postPixelAlphaModule = [ordered]@{ label = 'rendering-mode-postpixel-alpha'; phase = 'postpixel'; uniqueId = 'jp.penguin.purebase.release.fixture.products.postpixel'; propertyName = ''; sentinel = '' }
+ $postPixelAlphaPassCounts = [ordered]@{ ForwardBase = 0; ForwardAdd = 0; ShadowCaster = 0; Meta = 0 }
+ $matrix.Add([ordered]@{ label = $postPixelAlphaModule.label; contract = New-PhaseContract -Module $postPixelAlphaModule -SelectedProducts @('PureBase/Toon') -PassSentinelCounts $postPixelAlphaPassCounts; filter = 'PureBase.Release.Consumer.Tests.PureBaseConsumerRenderingModeTests.PostPixelAlphaConsumerInvocationSelectsTheTransparentToonProbeContract'; selections = @{ 'PureBase/Toon' = @($postPixelAlphaModule.uniqueId) }; skipColdLibraryReset = $false })
$matrix.Add([ordered]@{ label = 'progressive-cpu-bake'; contract = New-BakeContract -ConsumerRoot $consumerRoot; filter = 'PureBase.Release.Consumer.Tests.PureBaseConsumerBakeEvidenceTests.ConfiguredValidationSceneBakesAndExportsEvidence'; selections = @{}; skipColdLibraryReset = $false })
}
}
@@ -2624,10 +2636,10 @@ try {
$outcomes += [ordered]@{ label = $entry.label; runDirectoryLabel = $entry.contract.runLabel; nunit = Invoke-ConsumerTest -UnityEditor $unityEditor -ConsumerRoot $consumerRoot -RunRoot $runRoot -ZipPath $zipPath -ShaderCoreManifestPath $shaderCoreManifestPath -Contract $entry.contract -TestFilter $entry.filter -Selections $entry.selections -RequireColdLibraryReset:$requireColdLibraryReset -SkipColdLibraryReset:$entry.skipColdLibraryReset -AllowObservationEvidence:$allowObservationEvidence }
}
if ($CompareWarmAndColdStandardMorph) {
- $expectedComparisonLabels = @('module-free-clean-import', 'module-free-toon-runtime-observation', 'standard-morph-warm-library-duplicate-evidence', 'standard-morph-cold-library-legacy-counts')
+ $expectedComparisonLabels = @('module-free-clean-import', 'rendering-mode-contract', 'module-free-toon-runtime-observation', 'standard-morph-warm-library-duplicate-evidence', 'standard-morph-cold-library-legacy-counts')
$actualComparisonLabels = @($matrix | ForEach-Object { [string]$_.label })
- if ($matrix.Count -ne 4 -or $null -eq $comparisonWarmContract -or $null -eq $comparisonColdContract -or [string]::Join('|', $actualComparisonLabels) -ne [string]::Join('|', $expectedComparisonLabels)) {
- throw 'Standard-morph comparison must execute exactly module-free import, module-free Toon runtime observation, warm, and cold rows.'
+ if ($matrix.Count -ne 5 -or $null -eq $comparisonWarmContract -or $null -eq $comparisonColdContract -or [string]::Join('|', $actualComparisonLabels) -ne [string]::Join('|', $expectedComparisonLabels)) {
+ throw 'Standard-morph comparison must execute exactly module-free import, rendering-mode contract, module-free Toon runtime observation, warm, and cold rows.'
}
$comparisonVerdict = Invoke-StandardMorphComparisonVerdict -RunRoot $runRoot -WarmContract $comparisonWarmContract -ColdContract $comparisonColdContract
}
diff --git a/package.json b/package.json
index 11dcb3a1..e81746f1 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "jp.penguin.purebase",
"displayName": "PureBase",
- "version": "0.1.0",
+ "version": "0.2.0-beta.1",
"author": {
"name": "Penguin"
},
@@ -14,7 +14,7 @@
"keywords": [
"Shader"
],
- "url": "https://github.com/Penguin-Repository/Pure-Base/releases/download/0.1.0/jp.penguin.purebase-0.1.0.zip",
+ "url": "https://github.com/Penguin-Repository/Pure-Base/releases/download/0.2.0-beta.1/jp.penguin.purebase-0.2.0-beta.1.zip",
"legacyFolders": {
"Assets\\PureBase": ""
}