diff --git a/.github/tests/HostedUnityReviewContracts.Tests.ps1 b/.github/tests/HostedUnityReviewContracts.Tests.ps1
index 0d3a477f..1d2b2ac4 100644
--- a/.github/tests/HostedUnityReviewContracts.Tests.ps1
+++ b/.github/tests/HostedUnityReviewContracts.Tests.ps1
@@ -729,9 +729,15 @@ Describe 'Hosted Unity review contracts' {
ForEach-Object { $_.Trim() }
)
$artifactRoot = '${{ runner.temp }}\PureBase-Release-Validation-${{ github.run_id }}-${{ github.run_attempt }}'
- $pathLines.Count | Should -Be 2
- $pathLines | Should -Contain "$artifactRoot\validated-package"
- $pathLines | Should -Contain "$artifactRoot\repository-state.json"
+ $expectedPathLines = @(
+ "$artifactRoot\validated-package"
+ "$artifactRoot\repository-state.json"
+ "$artifactRoot\ReleaseConsumer-*\**\runtime-readbacks.json"
+ "$artifactRoot\ReleaseConsumer-*\**\library-reset.json"
+ "$artifactRoot\ReleaseConsumer-*\**\*summary.json"
+ "$artifactRoot\ReleaseConsumer-*\**\*.log"
+ )
+ $pathLines | Should -Be $expectedPathLines
($pathLines -join "`n") | Should -Not -Match '\.\.'
}
diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml
index c0be016b..c7db432f 100644
--- a/.github/workflows/release-validation.yml
+++ b/.github/workflows/release-validation.yml
@@ -296,6 +296,10 @@ jobs:
path: |
${{ runner.temp }}\PureBase-Release-Validation-${{ github.run_id }}-${{ github.run_attempt }}\validated-package
${{ runner.temp }}\PureBase-Release-Validation-${{ github.run_id }}-${{ github.run_attempt }}\repository-state.json
+ ${{ runner.temp }}\PureBase-Release-Validation-${{ github.run_id }}-${{ github.run_attempt }}\ReleaseConsumer-*\**\runtime-readbacks.json
+ ${{ runner.temp }}\PureBase-Release-Validation-${{ github.run_id }}-${{ github.run_attempt }}\ReleaseConsumer-*\**\library-reset.json
+ ${{ runner.temp }}\PureBase-Release-Validation-${{ github.run_id }}-${{ github.run_attempt }}\ReleaseConsumer-*\**\*summary.json
+ ${{ runner.temp }}\PureBase-Release-Validation-${{ github.run_id }}-${{ github.run_attempt }}\ReleaseConsumer-*\**\*.log
if-no-files-found: error
retention-days: 14
compression-level: 0
diff --git a/CHANGELOG b/CHANGELOG
index efe542d2..0082170e 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,3 +1,14 @@
+2026/08/11
+Ver. 0.2.0-beta.2
+https://github.com/Penguin-Repository/Pure-Base/releases#release-0.2.0-beta.2
+
+- Added the module-free Toon host's stable direct-plus-SH direction and bright/dark SH-band behavior while preserving Shader-Core lightmap input and ForwardBase/ForwardAdd ownership.
+- Documented that Hybrid retains its existing PBR path, binary direct diffuse, Unity Standard indirect GI, reflection probes, and direct GGX specular without Toon SH direction or banding.
+- Preserved the public property ABI so existing materials need no migration.
+- Added Daily lighting-contract coverage for Toon direction and SH bands, lightmaps, ForwardAdd isolation, and Hybrid PBR boundaries.
+- Added lilToon 2.3.4 MIT and OpenLit 1.0.2 CC0 concept provenance without implying endorsement or contributor participation.
+- Updated the package version and release download identity to `0.2.0-beta.2`.
+
2026/08/11
Ver. 0.2.0-beta.1
https://github.com/Penguin-Repository/Pure-Base/releases#release-0.2.0-beta.1
diff --git a/Docs/pure-base-shader-contract.md b/Docs/pure-base-shader-contract.md
index 0461b2bd..7e7024c1 100644
--- a/Docs/pure-base-shader-contract.md
+++ b/Docs/pure-base-shader-contract.md
@@ -34,7 +34,7 @@ The package publishes exactly these shader paths:
| Shader path | Contracted model |
| --- | --- |
| `PureBase/Unlit` | Ignores host direct and indirect lighting in the final output, except for module effects. |
-| `PureBase/Toon` | Uses binary direct diffuse response with ambient and lightmap lighting. |
+| `PureBase/Toon` | Uses binary direct diffuse with a stable direct-plus-SH direction, bright/dark SH bands, and Shader-Core lightmap input. |
| `PureBase/PBR` | Uses a continuous direct metallic BRDF with Unity Standard indirect GI and reflection probes. |
| `PureBase/Hybrid` | Replaces only the PBR direct diffuse response with Toon-style binary diffuse while sharing PBR specular and IBL. |
@@ -149,7 +149,7 @@ Unlit returns the base surface without host direct, baked, ambient, or environme
### Toon
-Toon evaluates a binary direct diffuse response from the surface normal and light direction. `ForwardBase` combines the direct result with ambient and baked lightmap lighting. `ForwardAdd` contributes direct light only.
+Toon evaluates a binary direct diffuse response from the surface normal and light direction. Its `ForwardBase` direction combines the Shader-Core direct aggregate with the first-order SH direction, and its ambient result selects a fixed bright or dark SH band from that direction. Shader-Core continues to provide the lightmap input; when Shader-Core supplies the lightmap aggregate, Toon does not synthesize an additional baked-light contribution. `ForwardAdd` contributes direct light only.
### PBR
@@ -157,7 +157,19 @@ PBR evaluates a continuous metallic BRDF for direct lighting. Its `ForwardBase`
### Hybrid
-Hybrid uses the PBR metallic, specular, and indirect IBL implementation, but replaces only its direct diffuse response with the Toon-style binary response. It retains the PBR `ForwardBase` and `ForwardAdd` ownership rules.
+Hybrid preserves the mathematically identical binary direct-diffuse equation in its existing PBR path, but does not use Toon SH banding. Toon SH never feeds Hybrid's lighting direction. Hybrid retains Unity Standard indirect GI, reflection probes, the PBR direct-light direction, and direct GGX specular, together with the PBR `ForwardBase` and `ForwardAdd` ownership rules.
+
+## Toon Lighting Classification and Compatibility
+
+The following boundary is fixed for the module-free Toon host:
+
+| Classification | Lighting concepts |
+| --- | --- |
+| Host-essential | Binary diffuse, stable direct-plus-SH scene direction, bright/dark SH bands, Shader-Core lightmap input, and `ForwardBase`/`ForwardAdd` separation. |
+| Shader-Core module-owned | Configurable shadow bands, colors, and masks; direction overrides; lighting limits; monochrome and as-unlit controls; and other optional artistic controls. |
+| Out of scope | SRP or platform integrations, APV/LPPV, light volumes, LTCGI, and unrelated lilToon effects. |
+
+The fixed host behavior adds no public property, keyword, pass, variant, or dependency. The public property ABI is unchanged, so existing materials require no migration and receive the fixed Toon host behavior automatically.
## Module Boundary
diff --git a/Docs/technical-information.ja.md b/Docs/technical-information.ja.md
index 43f92859..f4a77545 100644
--- a/Docs/technical-information.ja.md
+++ b/Docs/technical-information.ja.md
@@ -36,7 +36,7 @@ Pure Base は Shader-Core を動かすための最小構成の土台です。多
| シェーダー名 | 特徴 |
| --- | --- |
| `PureBase/Unlit` | ライティングの影響を受けない基本色表示 |
-| `PureBase/Toon` | 明暗をはっきり分けるトゥーン表示 |
+| `PureBase/Toon` | 直接光とSHから安定した方向を作り、明暗2帯のSHとライトマップを使うトゥーン表示 |
| `PureBase/PBR` | Unity 標準に近い物理ベース表示 |
| `PureBase/Hybrid` | トゥーンの拡散反射と物理ベースの反射表現を組み合わせた表示 |
@@ -111,11 +111,21 @@ Pure Base は Shader-Core を動かすための最小構成の土台です。多
リムライト、MatCap、デカール、細部用テクスチャ、発光、ディゾルブ、距離によるフェード、視差表現、髪向け反射、クリアコート、グリッター、特定環境専用の連携などは、別の Shader-Core モジュールで追加する想定です。Pure Base 本体には含めません。
+## Toon ライティングの境界とマテリアル互換性
+
+追加モジュールなしの Toon ホストは、直接光を2値化する拡散反射、直接光の集計と SH から作る安定したシーン光方向、明るい帯と暗い帯に分けた SH、Shader-Core が渡すライトマップ入力、既存の `ForwardBase` と `ForwardAdd` の分離を担当します。設定できる影の帯・色・マスク、方向の上書き、ライティングの上限と下限、モノクロ・unlit 化の制御、その他の任意の演出的な制御は Shader-Core モジュールの担当です。
+
+SRP またはプラットフォーム固有の連携、APV/LPPV、ライトボリューム、LTCGI、lilToon の無関係な効果は Pure Base ホストの対象外です。Toon は既存の Shader-Core ライトマップ集計を使い、焼き込み光をもう一度加算しません。`ForwardAdd` はこれまでどおり追加の直接光だけを加算します。
+
+Hybrid は PBR の経路の中にある既存の2値化直接拡散反射の式をそのまま維持します。Toon の SH 帯は Hybrid のライティング方向へ渡しません。Hybrid は引き続き Unity Standard の間接 GI、反射プローブ、PBR の直接光方向、直接 GGX 鏡面反射を使用します。
+
+この固定されたホスト動作によって、公開項目、キーワード、パス、バリアント、依存関係は増えません。公開プロパティ ABI は変わらないため、既存のマテリアルを移行する必要はなく、自動的にこの動作を受け取ります。
+
## 公開の準備と実行
`package.json` が、公開名と版番号を決める唯一の情報源です。
-現在のパッケージ版は `0.2.0-beta.1` です。
+現在のパッケージ版は `0.2.0-beta.2` です。
手動の `Release` ワークフローへ渡す `version` は、すでにパッケージへ記載されている版番号と一致するかを確認するためだけに使われます。版番号の書き換えやコミットは行いません。
diff --git a/Docs/technical-information.md b/Docs/technical-information.md
index d310b3f5..f13c8207 100644
--- a/Docs/technical-information.md
+++ b/Docs/technical-information.md
@@ -36,7 +36,7 @@ Pure Base is a minimal Shader-Core host. It is not intended to become a feature-
| Shader path | Lighting model |
| --- | --- |
| `PureBase/Unlit` | Base color output without host lighting |
-| `PureBase/Toon` | Binary direct diffuse with ambient and lightmap support |
+| `PureBase/Toon` | Binary direct diffuse with a stable direct-plus-SH direction, bright/dark SH bands, and Shader-Core lightmap support |
| `PureBase/PBR` | Continuous metallic BRDF with Unity Standard indirect GI and reflection probes |
| `PureBase/Hybrid` | Toon-style binary direct diffuse with the PBR specular and IBL path |
@@ -111,11 +111,21 @@ The shared standard phase ABI is executed in this order:
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.
+## Toon lighting boundary and material compatibility
+
+The module-free Toon host owns a binary direct-diffuse response, a stable scene-light direction formed from the direct aggregate and SH, bright/dark SH bands, Shader-Core lightmap input, and the existing `ForwardBase`/`ForwardAdd` separation. Shader-Core modules own configurable shadow bands, colors, and masks; direction overrides; lighting limits; monochrome and as-unlit controls; and other optional artistic controls.
+
+SRP or platform integrations, APV/LPPV, light volumes, LTCGI, and unrelated lilToon effects are outside the Pure Base host scope. Toon uses the existing Shader-Core lightmap aggregate and does not add a second baked-light contribution. `ForwardAdd` remains additional direct light only.
+
+Hybrid retains its unchanged binary direct-diffuse equation inside the PBR path. Toon SH banding never supplies Hybrid's lighting direction. Hybrid continues to use Unity Standard indirect GI, reflection probes, its PBR direct-light direction, and direct GGX specular.
+
+This fixed host behavior adds no public property, keyword, pass, variant, or dependency. The public property ABI is unchanged, so existing materials need no migration and receive the behavior automatically.
+
## Release preparation and publication
`package.json` is the sole release identity and version declaration.
-The current package release is `0.2.0-beta.1`.
+The current package release is `0.2.0-beta.2`.
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.
diff --git a/NOTICE b/NOTICE
index b1183381..fe4e33b6 100644
--- a/NOTICE
+++ b/NOTICE
@@ -19,3 +19,40 @@ lil: An amazing Shader-Core (https://github.com/lilxyzw/Shader-Core) developer (
License
This project is licensed under the Apache-2.0 License - see the LICENSE file for details
+Third-Party Lighting Concept Provenance
+PureBase's minimal Toon lighting helper is a narrow adapted/reimplemented use of the identified lilToon 2.3.4/OpenLit 1.0.2 lighting concepts, including the corresponding SH bright/dark constants and operation structure. It is not a broad lilToon or OpenLit integration or a full upstream code copy. This acknowledgement does not imply endorsement, sponsorship, contributor status, or participation by lilToon or OpenLit developers.
+
+Inspected lilToon sources
+
+- `Shader/Includes/openlit_core.hlsl`: `ComputeLightDirection`, `ShadeSH9ToonDouble`, and `ComputeLights`
+- `Shader/Includes/lil_common_functions.hlsl`: SH direction and bright/dark decomposition
+- `Shader/Includes/lil_common_macro.hlsl`: ForwardAdd and lightmap ownership context
+
+lilToon
+Version inspected: 2.3.4
+Copyright (c) 2020-2024 lilxyzw
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+OpenLit
+Version inspected: 1.0.2
+License: [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/)
+OpenLit is used here as provenance for the lilToon 2.3.4 BIRP integration concepts named above. This acknowledgement does not imply endorsement, sponsorship, contributor status, or participation by OpenLit developers.
diff --git a/README.ja.md b/README.ja.md
index 6e0f2fa8..e0e4bfe8 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -28,12 +28,12 @@ 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 `0.2.0-beta.1` は、Shader-Core で使える4種類の基本シェーダーをまとめた Unity 向けパッケージです。
+Pure Base `0.2.0-beta.2` は、Shader-Core で使える4種類の基本シェーダーをまとめた Unity 向けパッケージです。
複雑な機能を最初から大量に備えるのではなく、必要な機能を Shader-Core の追加モジュールで組み合わせて使うための、軽くて分かりやすい土台を目指しています。
> [!IMPORTANT]
-> Pure Base は、Shader-Core、NonToon、lilToon とは別に作られている非公式プロジェクトです。
+> Pure Base は独立して開発されている非公式プロジェクトです。lilToon の開発者は Pure Base の開発に参加しておらず、Pure Base は lilToon の後継、継続、または派生パッケージではありません。
>
> 開発には生成AIを使用しています。
@@ -87,7 +87,7 @@ https://lilxyzw.github.io/vpm-repos/vpm.json
3. 追加する版を選び、プロジェクトへ導入します。
4. Shader-Core 0.1.9 が一緒に導入されることを確認します。
-このREADMEが対象とするパッケージ版は `0.2.0-beta.1` です。
+このREADMEが対象とするパッケージ版は `0.2.0-beta.2` です。
## 基本的な使い方
diff --git a/README.md b/README.md
index b6927379..bcc67949 100644
--- a/README.md
+++ b/README.md
@@ -28,12 +28,12 @@ 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 `0.2.0-beta.1` is a Unity package that provides four base shaders for Shader-Core.
+Pure Base `0.2.0-beta.2` 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.
> [!IMPORTANT]
-> Pure Base is an unofficial project developed independently from Shader-Core, NonToon, and lilToon.
+> Pure Base is an unofficial, independent project. lilToon developers did not participate in its development, and Pure Base is not a lilToon successor, continuation, or derivative package.
>
> Generative AI is used during development.
@@ -85,7 +85,7 @@ 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.
-The package version described here is `0.2.0-beta.1`.
+The package version described here is `0.2.0-beta.2`.
## Basic use
diff --git a/Shaders/Common/birp_host.hlsl b/Shaders/Common/birp_host.hlsl
index fc17b098..50c1ceba 100644
--- a/Shaders/Common/birp_host.hlsl
+++ b/Shaders/Common/birp_host.hlsl
@@ -21,6 +21,10 @@
#include "Packages/jp.penguin.purebase/Shaders/Common/rendering_mode.hlsl"
+#ifndef SCModelSelectAggregateLightDirection
+ #define SCModelSelectAggregateLightDirection(directAggregateDirection, shAr, shAg, shAb) (dot(directAggregateDirection, directAggregateDirection) == 0 ? half3(0, 0, 0) : normalize(directAggregateDirection))
+#endif
+
/// 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)
{
@@ -38,8 +42,17 @@ void SCCalculateLight(inout SCLightData lightSum, inout SCShadingData sd, inout
/// Publishes the aggregate light direction and applies the selected model's ambient SH response.
void SCCalculateEnvironmentLight(inout SCLightData lightSum, inout half3 env, inout SCShadingData sd, inout SCCustomData cd, SCVertexData vertex, half4 shAr, half4 shAg, half4 shAb, half4 shBr, half4 shBg, half4 shBb, half4 shC)
{
- sd.L = dot(lightSum.direction, lightSum.direction) == 0 ? half3(0, 0, 0) : normalize(lightSum.direction);
- env += SCModelEvaluateAmbient(sd, shAr, shAg, shAb, shBr, shBg, shBb, shC);
+ #if defined(PUREBASE_TOON_MODEL_INCLUDED) && !defined(LIGHTMAP_ON)
+ sd.L = SCModelSelectAggregateLightDirection(lightSum.direction, unity_SHAr, unity_SHAg, unity_SHAb);
+ #if !defined(UNITY_PASS_FORWARDADD)
+ env += SCModelEvaluateAmbient(sd, unity_SHAr, unity_SHAg, unity_SHAb, unity_SHBr, unity_SHBg, unity_SHBb, unity_SHC);
+ #endif
+ #else
+ sd.L = SCModelSelectAggregateLightDirection(lightSum.direction, shAr, shAg, shAb);
+ #if !defined(UNITY_PASS_FORWARDADD)
+ env += SCModelEvaluateAmbient(sd, shAr, shAg, shAb, shBr, shBg, shBb, shC);
+ #endif
+ #endif
}
#include "Packages/jp.lilxyzw.shadercore/ShaderLibrary/birp_lighting.hlsl"
@@ -68,11 +81,11 @@ half4 frag(v2f input, bool isFront : SV_IsFrontFace) : SV_Target
cd.mainLightAttenuation = saturate(mainLightAttenuation);
cd.mainLightDirection = half3(0, 0, 0);
SCCalculateAllLights(lightSum, env, sd, cd, vertex, input, SCModelSelectVertexLighting(SCVertexLighting(vertex.position)));
- env = SCModelSelectEnvironmentLighting(env);
#if defined(UNITY_PASS_FORWARDADD)
sd.lightColor = lightSum.color;
#else
+ env = SCModelSelectEnvironmentLighting(env);
sd.lightColor = lightSum.color + env;
#endif
diff --git a/Shaders/Common/toon_lighting.hlsl b/Shaders/Common/toon_lighting.hlsl
new file mode 100644
index 00000000..f5bc9d73
--- /dev/null
+++ b/Shaders/Common/toon_lighting.hlsl
@@ -0,0 +1,54 @@
+/*
+ * 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 minimal Toon direct factor, dominant direction, and two-band SH lighting for the BIRP host.
+// Derived from lilToon 2.3.4 OpenLit 1.0.2 BIRP concepts: ComputeLightDirection, ShadeSH9ToonDouble, and ComputeLights; see the reference package NOTICE.
+
+#ifndef PUREBASE_TOON_LIGHTING_INCLUDED
+#define PUREBASE_TOON_LIGHTING_INCLUDED
+
+/// Evaluates the binary Toon direct-light response after the Shader-Core light phase.
+half PureBaseToonEvaluateDirectFactor(float3 surfaceNormal, float3 lightDirection)
+{
+ return step(0, dot(surfaceNormal, lightDirection));
+}
+
+/// Builds a finite Toon band direction from direct-light and spherical-harmonics aggregates.
+float3 PureBaseToonComputeLightDirection(float3 directAggregateDirection, float4 shAr, float4 shAg, float4 shAb)
+{
+ float3 shDirection = (shAr.xyz + shAg.xyz + shAb.xyz) / 3;
+ float3 directionVector = directAggregateDirection + float3(shDirection.x, abs(shDirection.y), shDirection.z);
+ if (dot(directionVector, directionVector) <= 0.000001)
+ {
+ directionVector = float3(0.001, 0.002, 0.001);
+ }
+
+ return normalize(directionVector);
+}
+
+/// Evaluates the fixed bright and dark spherical-harmonics bands for a Toon surface.
+float3 PureBaseToonEvaluateTwoBandSh(float3 surfaceNormal, float3 L, float4 shAr, float4 shAg, float4 shAb, float4 shBr, float4 shBg, float4 shBb, float4 shC)
+{
+ float3 E = L * 0.666666;
+ float4 quadratic = E.xyzz * E.yzzx;
+ float3 base = float3(shAr.w, shAg.w, shAb.w) + float3(dot(shBr, quadratic), dot(shBg, quadratic), dot(shBb, quadratic)) + shC.rgb * (E.x * E.x - E.y * E.y);
+ float3 linearTerm = float3(dot(shAr.xyz, E), dot(shAg.xyz, E), dot(shAb.xyz, E));
+ float3 bright = max(base + linearTerm, 0);
+ float3 dark = max(base - linearTerm, 0);
+ return lerp(dark, bright, step(0, dot(surfaceNormal, L)));
+}
+
+#endif
diff --git a/Shaders/Common/toon_lighting.hlsl.meta b/Shaders/Common/toon_lighting.hlsl.meta
new file mode 100644
index 00000000..65905a0a
--- /dev/null
+++ b/Shaders/Common/toon_lighting.hlsl.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 9bb8ed4b0d9a09a4393fca509f0fc5b2
+ShaderIncludeImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Shaders/Models/pbr.hlsl b/Shaders/Models/pbr.hlsl
index 981aad75..1fece81f 100644
--- a/Shaders/Models/pbr.hlsl
+++ b/Shaders/Models/pbr.hlsl
@@ -59,6 +59,12 @@ half3 SCModelSelectMainLightDirection(SCVertexData vertex, half3 lightDirection)
return PureBasePbrSafeNormalize(UnityWorldSpaceLightDir(vertex.position));
}
+/// Preserves the existing normalized direct-light aggregate for PBR and Hybrid BRDF evaluation.
+half3 PureBasePbrSelectAggregateLightDirection(half3 directAggregateDirection, half4 shAr, half4 shAg, half4 shAb)
+{
+ return dot(directAggregateDirection, directAggregateDirection) == 0 ? half3(0, 0, 0) : normalize(directAggregateDirection);
+}
+
/// Disables Shader-Core ambient SH because Unity Standard GI owns ambient and lightmap evaluation.
half3 SCModelEvaluateAmbient(SCShadingData shadingData, half4 shAr, half4 shAg, half4 shAb, half4 shBr, half4 shBg, half4 shBb, half4 shC)
{
@@ -182,4 +188,6 @@ half4 SCModelAddSurfaceColor(SCShadingData shadingData, SCCustomData customData,
return half4(SCModelEvaluateDirectLighting(shadingData, vertex), 1);
}
+#define SCModelSelectAggregateLightDirection(directAggregateDirection, shAr, shAg, shAb) PureBasePbrSelectAggregateLightDirection(directAggregateDirection, shAr, shAg, shAb)
+
#endif
\ No newline at end of file
diff --git a/Shaders/Models/toon.hlsl b/Shaders/Models/toon.hlsl
index 4081c25a..007e6d9d 100644
--- a/Shaders/Models/toon.hlsl
+++ b/Shaders/Models/toon.hlsl
@@ -19,6 +19,8 @@
#ifndef PUREBASE_TOON_MODEL_INCLUDED
#define PUREBASE_TOON_MODEL_INCLUDED
+#include "Packages/jp.penguin.purebase/Shaders/Common/toon_lighting.hlsl"
+
/// Stores deterministic host-owned data for Shader-Core lighting callbacks.
struct SCCustomData
{
@@ -42,7 +44,7 @@ void SCModelInitializeTangentNormal(inout SCShadingData shadingData)
/// Returns the quantized per-light Toon response after the Shader-Core light phase.
half SCModelEvaluateDirectFactor(SCShadingData shadingData, SCLightData light)
{
- return step(0, dot(shadingData.N, light.direction));
+ return PureBaseToonEvaluateDirectFactor(shadingData.N, light.direction);
}
/// Retains Shader-Core's light color because the Toon wrapper does not isolate Unity Standard declarations.
@@ -57,15 +59,10 @@ half3 SCModelSelectMainLightDirection(SCVertexData vertex, half3 lightDirection)
return lightDirection;
}
-/// Evaluates the supplied Unity spherical-harmonics coefficients with the model world normal.
+/// Evaluates the supplied Unity spherical-harmonics coefficients as fixed bright and dark Toon bands.
half3 SCModelEvaluateAmbient(SCShadingData shadingData, half4 shAr, half4 shAg, half4 shAb, half4 shBr, half4 shBg, half4 shBb, half4 shC)
{
- half4 normal = half4(shadingData.N, 1);
- half3 ambient = half3(dot(shAr, normal), dot(shAg, normal), dot(shAb, normal));
- half4 quadratic = normal.xyzz * normal.yzzx;
- ambient += half3(dot(shBr, quadratic), dot(shBg, quadratic), dot(shBb, quadratic));
- ambient += shC.rgb * (normal.x * normal.x - normal.y * normal.y);
- return max(ambient, half3(0, 0, 0));
+ return PureBaseToonEvaluateTwoBandSh(shadingData.N, shadingData.L, shAr, shAg, shAb, shBr, shBg, shBb, shC);
}
/// Suppresses Shader-Core's continuous vertex-light aggregate for the Toon model.
@@ -92,4 +89,6 @@ half4 SCModelAddSurfaceColor(SCShadingData shadingData, SCCustomData customData,
return half4(shadingData.albedoAlpha.rgb * shadingData.lightColor, 1);
}
+#define SCModelSelectAggregateLightDirection(directAggregateDirection, shAr, shAg, shAb) PureBaseToonComputeLightDirection(directAggregateDirection, shAr, shAg, shAb)
+
#endif
\ No newline at end of file
diff --git a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs
index 82b40fb5..1b036dea 100644
--- a/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs
+++ b/Tests/Daily/Editor/PureBaseRenderingModeRenderingTests.SourceContracts.cs
@@ -32,6 +32,30 @@ public sealed partial class PureBaseRenderingModeRenderingTests
private const string BirpHostPath =
"Packages/jp.penguin.purebase/Shaders/Common/birp_host.hlsl";
+ /// Identifies the Toon model source whose direct and environment callback ownership is diagnosed.
+ private const string ToonModelPath =
+ "Packages/jp.penguin.purebase/Shaders/Models/toon.hlsl";
+
+ /// Identifies the Toon-only helper that owns dominant-direction and two-band SH evaluation.
+ private const string ToonLightingHelperPath =
+ "Packages/jp.penguin.purebase/Shaders/Common/toon_lighting.hlsl";
+
+ /// Identifies the PBR model that must not consume Toon lighting direction or environment bands.
+ private const string PbrModelPath =
+ "Packages/jp.penguin.purebase/Shaders/Models/pbr.hlsl";
+
+ /// Identifies the Hybrid model wrapper that must retain PBR lighting ownership.
+ private const string HybridModelPath =
+ "Packages/jp.penguin.purebase/Shaders/Models/hybrid.hlsl";
+
+ /// Identifies the shared PBR BRDF source that retains Hybrid's inline binary diffuse factor.
+ private const string PbrBrdfPath =
+ "Packages/jp.penguin.purebase/Shaders/Common/pbr_brdf.hlsl";
+
+ /// Identifies the Shader-Core BIRP light acquisition boundary that retains lightmap ownership.
+ private const string ShaderCoreBirpLightingPath =
+ "Packages/jp.lilxyzw.shadercore/ShaderLibrary/birp_lighting.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";
@@ -136,6 +160,169 @@ public void BirpHostPreservesModeAlphaFogPostPixelAndForwardAddSourceOrder()
StringAssert.Contains("sd.col.a = half(0.25)", File.ReadAllText(PostPixelProbePath));
}
+ /// Requires Toon-owned binary direct and two-band environment lighting with Shader-Core lightmap and ForwardAdd isolation.
+ [Test]
+ public void ToonLightingOwnershipKeepsBinaryDirectTwoBandShaderCoreLightmapsAndForwardAddIsolation()
+ {
+ string toon = File.ReadAllText(ToonModelPath);
+ string helper = File.ReadAllText(ToonLightingHelperPath);
+ string host = File.ReadAllText(BirpHostPath);
+ string shaderCoreLighting = File.ReadAllText(ShaderCoreBirpLightingPath);
+ string pbr = File.ReadAllText(PbrModelPath);
+ string pbrBrdf = File.ReadAllText(PbrBrdfPath);
+ string hybrid = File.ReadAllText(HybridModelPath);
+
+ AssertToonHelperAndModelContracts(toon, helper);
+ AssertBirpHostForwardAddAndLightmapContracts(host, shaderCoreLighting);
+ AssertPbrAndHybridLightingOwnership(pbr, pbrBrdf, hybrid);
+ AssertLightingPhaseOrder(host);
+ }
+
+ /// Asserts that Toon alone owns its binary direct response and two-band environment interpretation.
+ /// The Toon model source.
+ /// The Toon lighting helper source.
+ private static void AssertToonHelperAndModelContracts(string toon, string helper)
+ {
+ StringAssert.Contains(
+ "return step(0, dot(surfaceNormal, lightDirection));",
+ helper,
+ "The Toon helper must own the stable binary direct-light response."
+ );
+ StringAssert.Contains(
+ "return PureBaseToonEvaluateDirectFactor(shadingData.N, light.direction);",
+ toon,
+ "The Toon model must delegate direct-light evaluation to its binary helper."
+ );
+ StringAssert.Contains(
+ "float3 PureBaseToonComputeLightDirection",
+ helper,
+ "The Toon helper must own the stable direct and SH aggregate direction."
+ );
+ StringAssert.Contains(
+ "float3 PureBaseToonEvaluateTwoBandSh",
+ helper,
+ "The Toon helper must own fixed bright and dark environment band interpretation."
+ );
+ StringAssert.Contains(
+ "return lerp(dark, bright, step(0, dot(surfaceNormal, L)));",
+ helper,
+ "The Toon helper must select the environment from fixed bright and dark bands."
+ );
+ StringAssert.Contains(
+ "#include \"Packages/jp.penguin.purebase/Shaders/Common/toon_lighting.hlsl\"",
+ toon,
+ "The Toon model must include its lighting helper as the sole Toon-specific lighting source."
+ );
+ StringAssert.Contains("SCModelEvaluateAmbient", toon);
+ StringAssert.Contains("SCModelSelectEnvironmentLighting", toon);
+ }
+
+ /// Asserts the BIRP host controls ForwardAdd selection and Shader-Core retains lightmap ownership.
+ /// The common BIRP fragment host source.
+ /// The Shader-Core BIRP lighting source.
+ private static void AssertBirpHostForwardAddAndLightmapContracts(string host, string shaderCoreLighting)
+ {
+ StringAssert.Contains("SCCalculateAllLights", host, "The BIRP host must own the aggregate light flow.");
+ StringAssert.Contains("env = SCModelSelectEnvironmentLighting(env);", host);
+ StringAssert.Contains("sd.lightColor = lightSum.color + env;", host);
+ StringAssert.Contains("sd.lightColor = lightSum.color;", host);
+ Assert.That(
+ RequireIndex(host, "#if defined(UNITY_PASS_FORWARDADD)"),
+ Is.LessThan(RequireIndex(host, "env = SCModelSelectEnvironmentLighting(env);")),
+ "ForwardAdd must select only the direct aggregate before the Base environment calculation branch."
+ );
+ StringAssert.Contains("LIGHTMAP_ON", shaderCoreLighting);
+ StringAssert.Contains("unity_Lightmap", shaderCoreLighting);
+ StringAssert.Contains("__SC_PHASE_customlight__", shaderCoreLighting);
+ }
+
+ /// Asserts PBR and Hybrid retain their independent PBR lighting and binary-diffuse ownership.
+ /// The PBR model source.
+ /// The shared PBR BRDF source.
+ /// The Hybrid model source.
+ private static void AssertPbrAndHybridLightingOwnership(string pbr, string pbrBrdf, string hybrid)
+ {
+ StringAssert.DoesNotContain(
+ "toon_lighting.hlsl",
+ pbr,
+ "PBR must retain its own environment and aggregate-direction ownership."
+ );
+ StringAssert.Contains(
+ "half diffuseNdotL = binaryDiffuse ? step(0.0, signedNdotL) : NdotL;",
+ pbrBrdf,
+ "Hybrid must retain the existing inline PBR binary diffuse equation."
+ );
+ StringAssert.DoesNotContain(
+ "toon_lighting.hlsl",
+ hybrid,
+ "Hybrid must retain PBR lighting ownership without consuming Toon SH direction."
+ );
+ }
+
+ /// Asserts the required aggregate-light, environment, and fragment-phase execution order.
+ /// The common BIRP fragment host source.
+ private static void AssertLightingPhaseOrder(string host)
+ {
+ int allLights = RequireIndex(host, "SCCalculateAllLights");
+ int environmentSelection = RequireIndex(host, "env = SCModelSelectEnvironmentLighting(env);");
+ int modifyLight = RequireIndex(host, "__SC_PHASE_modifylight__");
+ int shade = RequireIndex(host, "__SC_PHASE_shade__");
+ int reflection = RequireIndex(host, "__SC_PHASE_reflection__");
+ int add = RequireIndex(host, "__SC_PHASE_add__");
+ int postPixel = RequireIndex(host, "__SC_PHASE_postpixel__");
+ Assert.That(allLights, Is.LessThan(environmentSelection));
+ Assert.That(environmentSelection, Is.LessThan(modifyLight));
+ Assert.That(modifyLight, Is.LessThan(shade));
+ Assert.That(shade, Is.LessThan(reflection));
+ Assert.That(reflection, Is.LessThan(add));
+ Assert.That(add, Is.LessThan(postPixel));
+ }
+
+ /// Requires the Toon-only dominant-direction and bright/dark SH helper without changing PBR or Hybrid ownership.
+ [Test]
+ public void ToonLightingRequiresFixedTwoBandHelperFallbackAndModelCallbackSeparation()
+ {
+ string toon = File.ReadAllText(ToonModelPath);
+ Assert.That(
+ File.Exists(ToonLightingHelperPath),
+ Is.True,
+ "The Toon-only dominant-direction and two-band SH helper must exist."
+ );
+ string helper = File.ReadAllText(ToonLightingHelperPath);
+ string host = File.ReadAllText(BirpHostPath);
+ string pbrBrdf = File.ReadAllText(PbrBrdfPath);
+
+ StringAssert.Contains(
+ "#include \"Packages/jp.penguin.purebase/Shaders/Common/toon_lighting.hlsl\"",
+ toon,
+ "The two-band SH helper must be included only by the Toon model."
+ );
+ StringAssert.Contains("SCModelEvaluateDirectFactor", toon);
+ StringAssert.Contains("SCModelEvaluateAmbient", toon);
+ StringAssert.Contains("SCModelSelectEnvironmentLighting", toon);
+ StringAssert.Contains(
+ "float3 shDirection = (shAr.xyz + shAg.xyz + shAb.xyz) / 3",
+ helper
+ );
+ StringAssert.Contains(
+ "float3(shDirection.x, abs(shDirection.y), shDirection.z)",
+ helper
+ );
+ StringAssert.Contains("<= 0.000001", helper);
+ StringAssert.Contains("float3(0.001, 0.002, 0.001)", helper);
+ StringAssert.Contains("E = L * 0.666666", helper);
+ StringAssert.Contains("base + linear", helper);
+ StringAssert.Contains("base - linear", helper);
+ StringAssert.Contains("step(0, dot(surfaceNormal, L))", helper);
+ StringAssert.DoesNotContain("toon_lighting.hlsl", host);
+ StringAssert.DoesNotContain("toon_lighting.hlsl", pbrBrdf);
+ StringAssert.Contains(
+ "half diffuseNdotL = binaryDiffuse ? step(0.0, signedNdotL) : NdotL;",
+ pbrBrdf,
+ "The Toon helper must not replace Hybrid's inline binary direct-diffuse branch."
+ );
+ }
+
/// Requires pass-bounded Stencil policy while preserving the existing pass and rendering-mode keyword ABI.
[Test]
public void ProductGeneratedSourcesExposeStencilPassContractsWithoutNewVariantsOrPasses()
diff --git a/Tests/Daily/Editor/PureBaseToonLightingContractTests.Runtime.cs b/Tests/Daily/Editor/PureBaseToonLightingContractTests.Runtime.cs
new file mode 100644
index 00000000..b4351398
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseToonLightingContractTests.Runtime.cs
@@ -0,0 +1,602 @@
+/*
+ * Copyright 2026 Penguin
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Owns the isolated Toon lighting runtime capture fixture and Unity-state restoration.
+
+using System;
+using System.Collections.Generic;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+using UnityEngine.Rendering;
+using UnityEngine.SceneManagement;
+
+namespace PureBase.Tests.Daily
+{
+ public sealed partial class PureBaseToonLightingContractTests
+ {
+ /// Owns one isolated regular-render fixture and restores every Unity global it changes.
+ private class ToonLightingCaptureRuntimeScope : IDisposable
+ {
+ /// Stores the dedicated layer used by the preview-scene renderer and lights.
+ private const int FixtureLayer = 31;
+
+ /// Lists the spherical-harmonic globals injected immediately before each render.
+ private static readonly string[] GlobalNames =
+ {
+ "unity_SHAr",
+ "unity_SHAg",
+ "unity_SHAb",
+ "unity_SHBr",
+ "unity_SHBg",
+ "unity_SHBb",
+ "unity_SHC",
+ };
+
+ /// Stores the global vectors captured before the fixture writes spherical-harmonic input.
+ private readonly Dictionary globals = new Dictionary();
+
+ /// Stores caller-owned temporary material instances.
+ private readonly List materials = new List();
+
+ /// Stores the active render target before CPU readback changes it.
+ private readonly RenderTexture activeRenderTexture;
+
+ /// Stores the active scene before the Preview Scene becomes the capture context.
+ private readonly Scene activeScene;
+
+ /// Stores the original loaded scene count for restoration diagnostics.
+ private readonly int sceneCount;
+
+ /// Stores the original pixel-light budget.
+ private readonly int pixelLightCount;
+
+ /// Stores the original fog setting for the formerly active scene.
+ private readonly bool fogEnabled;
+
+ /// Stores the disposable Preview Scene that owns all generated GameObjects.
+ private readonly Scene scene;
+
+ /// Stores the isolated Forward-rendering camera.
+ private Camera camera;
+
+ /// Stores the renderer used for every regular product-material render.
+ private MeshRenderer renderer;
+
+ /// Stores the mesh filter used for controlled-normal render meshes.
+ private MeshFilter meshFilter;
+
+ /// Tracks completed renderer meshes for deterministic scope cleanup.
+ private readonly List meshes = new List();
+
+ /// Tracks camera and renderer GameObjects for deterministic preview-scene cleanup.
+ private readonly List gameObjects = new List();
+
+ /// Stores the linear float render target.
+ private RenderTexture target;
+
+ /// Stores the float CPU readback texture.
+ private Texture2D readback;
+
+ /// Stores the controlled linear normal texture used by every product-material render.
+ private Texture2D normalMap;
+
+ /// Stores the command buffer that injects only SH globals immediately before every render.
+ private CommandBuffer commandBuffer;
+
+ /// Stores the renderer-local SH override that wins over Unity's per-object probe setup.
+ private MaterialPropertyBlock shProperties;
+
+ /// Tracks whether this scope has already released its resources.
+ private bool disposed;
+
+ /// Creates an isolated linear render fixture with no fog or reflection probes.
+ public ToonLightingCaptureRuntimeScope()
+ {
+ activeRenderTexture = RenderTexture.active;
+ activeScene = SceneManager.GetActiveScene();
+ sceneCount = SceneManager.sceneCount;
+ pixelLightCount = QualitySettings.pixelLightCount;
+ fogEnabled = RenderSettings.fog;
+ foreach (string globalName in GlobalNames)
+ {
+ globals.Add(globalName, Shader.GetGlobalVector(globalName));
+ }
+
+ try
+ {
+ scene = EditorSceneManager.NewPreviewScene();
+ RenderSettings.fog = false;
+ QualitySettings.pixelLightCount = Mathf.Max(2, pixelLightCount);
+
+ InitializeRenderResources();
+ }
+ catch
+ {
+ ReleaseRenderResources();
+ ClosePreviewScene();
+ RestoreCallerState();
+ throw;
+ }
+ }
+
+ /// Creates the hidden preview-scene objects, transient render resources, and camera configuration.
+ private void InitializeRenderResources()
+ {
+ GameObject cameraObject = CreateHiddenObject("PureBase Toon Lighting Contract Camera");
+ camera = cameraObject.AddComponent();
+ GameObject quadObject = CreateHiddenObject("PureBase Toon Lighting Contract Renderer");
+ meshFilter = quadObject.AddComponent();
+ renderer = quadObject.AddComponent();
+ renderer.enabled = false;
+ renderer.lightProbeUsage = LightProbeUsage.Off;
+ target = new RenderTexture(
+ 64,
+ 64,
+ 24,
+ RenderTextureFormat.ARGBFloat,
+ RenderTextureReadWrite.Linear
+ ) { hideFlags = HideFlags.HideAndDontSave };
+ target.Create();
+ readback = new Texture2D(64, 64, TextureFormat.RGBAFloat, false, true)
+ {
+ hideFlags = HideFlags.HideAndDontSave,
+ };
+ normalMap = CreateNeutralNormalTexture();
+ commandBuffer = new CommandBuffer { name = "PureBase Toon Lighting Contract SH" };
+ shProperties = new MaterialPropertyBlock();
+ camera.enabled = false;
+ camera.cullingMask = 1 << FixtureLayer;
+ camera.overrideSceneCullingMask = EditorSceneManager.GetSceneCullingMask(scene);
+ camera.orthographic = true;
+ camera.orthographicSize = 1.0f;
+ camera.nearClipPlane = 0.1f;
+ camera.farClipPlane = 10.0f;
+ 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.37f);
+ camera.renderingPath = RenderingPath.Forward;
+ camera.targetTexture = target;
+ camera.AddCommandBuffer(CameraEvent.BeforeForwardOpaque, commandBuffer);
+ }
+
+ /// Renders one product material through BIRP after installing the exact SH globals for the current test case.
+ /// The required product shader name.
+ /// The required product pass name.
+ /// The uniform mesh world normal.
+ /// The real main or additional light color.
+ /// The real directional or point light vector.
+ /// The seven SH globals injected immediately before the render.
+ /// Whether to isolate a Point ForwardAdd contribution with one- versus two-light rendering.
+ /// The material metallic value.
+ /// The center linear float readback color.
+ public Color Render(
+ string shaderName,
+ string passName,
+ Vector3 normal,
+ Vector4 lightColor,
+ Vector4 lightPosition,
+ ShCoefficients coefficients,
+ bool pointLight = false,
+ float metallic = 0.0f
+ )
+ {
+ Material material = CreateProductMaterial(shaderName, passName, metallic);
+ if (pointLight)
+ {
+ Color oneLight = RenderWithLights(
+ material,
+ normal,
+ lightColor,
+ lightPosition,
+ coefficients,
+ true,
+ 1
+ );
+ Color twoLights = RenderWithLights(
+ material,
+ normal,
+ lightColor,
+ lightPosition,
+ coefficients,
+ true,
+ 2
+ );
+ return new Color(
+ twoLights.r - oneLight.r,
+ twoLights.g - oneLight.g,
+ twoLights.b - oneLight.b,
+ twoLights.a
+ );
+ }
+
+ return RenderWithLights(
+ material,
+ normal,
+ lightColor,
+ lightPosition,
+ coefficients,
+ false,
+ lightColor == Vector4.zero ? 0 : 1
+ );
+ }
+
+ /// Releases generated objects and restores global, render-target, quality, fog, and active-scene state.
+ public void Dispose()
+ {
+ if (disposed)
+ {
+ return;
+ }
+
+ disposed = true;
+ try
+ {
+ ReleaseRenderResources();
+ ClosePreviewScene();
+ }
+ finally
+ {
+ RestoreCallerState();
+ AssertRestoredSceneCount();
+ }
+ }
+
+ /// Creates and configures one transient product material for the required named pass.
+ /// The required product shader name.
+ /// The required explicit pass name.
+ /// The material metallic value.
+ /// The registered transient material.
+ private Material CreateProductMaterial(
+ string shaderName,
+ string passName,
+ float metallic
+ )
+ {
+ Shader shader = Shader.Find(shaderName);
+ Assert.That(shader, Is.Not.Null, "Product shader '" + shaderName + "' is unavailable.");
+ Assert.That(
+ ShaderUtil.ShaderHasError(shader),
+ Is.False,
+ "Product shader '" + shaderName + "' has compiler errors."
+ );
+ var material = new Material(shader) { hideFlags = HideFlags.HideAndDontSave };
+ materials.Add(material);
+ ConfigureMaterial(material, metallic);
+ int pass = material.FindPass(passName);
+ Assert.That(pass, Is.GreaterThanOrEqualTo(0), shaderName + " requires " + passName + ".");
+ return material;
+ }
+
+ /// Renders a controlled mesh with the requested real Unity light setup.
+ /// The configured transient material.
+ /// The uniform mesh world normal.
+ /// The real main or additional light color.
+ /// The real directional or point light vector.
+ /// The seven SH globals for the render.
+ /// Whether the setup uses Point lights.
+ /// The number of ForcePixel lights to create.
+ /// The center linear float readback color.
+ private Color RenderWithLights(
+ Material material,
+ Vector3 normal,
+ Vector4 lightColor,
+ Vector4 lightPosition,
+ ShCoefficients coefficients,
+ bool pointLight,
+ int lightCount
+ )
+ {
+ var lightObjects = new List();
+ try
+ {
+ InjectShGlobals(coefficients);
+ ApplyShProperties(coefficients);
+ meshFilter.sharedMesh = CreateNormalControlledQuad(normal);
+ renderer.sharedMaterial = material;
+ renderer.enabled = true;
+ CreateLights(lightObjects, lightColor, lightPosition, pointLight, lightCount);
+ camera.Render();
+ Assert.That(
+ camera.actualRenderingPath,
+ Is.EqualTo(RenderingPath.Forward),
+ "The Toon lighting capture scope must actually use the BIRP Forward camera path."
+ );
+ return ReadCenterPixel();
+ }
+ finally
+ {
+ renderer.enabled = false;
+ renderer.SetPropertyBlock(null);
+ DestroyGameObjects(lightObjects);
+ }
+ }
+
+ /// Injects only the test-owned spherical-harmonic globals immediately before the render.
+ /// The seven SH globals for the render.
+ private void InjectShGlobals(ShCoefficients coefficients)
+ {
+ commandBuffer.Clear();
+ commandBuffer.SetGlobalVector("unity_SHAr", coefficients.ar);
+ commandBuffer.SetGlobalVector("unity_SHAg", coefficients.ag);
+ commandBuffer.SetGlobalVector("unity_SHAb", coefficients.ab);
+ commandBuffer.SetGlobalVector("unity_SHBr", coefficients.br);
+ commandBuffer.SetGlobalVector("unity_SHBg", coefficients.bg);
+ commandBuffer.SetGlobalVector("unity_SHBb", coefficients.bb);
+ commandBuffer.SetGlobalVector("unity_SHC", coefficients.c);
+ }
+
+ /// Applies the test-owned SH vectors after Unity prepares renderer-local probe data.
+ /// The seven SH vectors for the render.
+ private void ApplyShProperties(ShCoefficients coefficients)
+ {
+ shProperties.Clear();
+ shProperties.SetVector("unity_SHAr", coefficients.ar);
+ shProperties.SetVector("unity_SHAg", coefficients.ag);
+ shProperties.SetVector("unity_SHAb", coefficients.ab);
+ shProperties.SetVector("unity_SHBr", coefficients.br);
+ shProperties.SetVector("unity_SHBg", coefficients.bg);
+ shProperties.SetVector("unity_SHBb", coefficients.bb);
+ shProperties.SetVector("unity_SHC", coefficients.c);
+ renderer.SetPropertyBlock(shProperties);
+ }
+
+ /// Creates real ForcePixel lights on the isolated preview-scene layer.
+ /// Receives the caller-owned light GameObjects immediately after allocation.
+ /// The real main or additional light color.
+ /// The directional or point light vector.
+ /// Whether the setup uses Point lights.
+ /// The number of ForcePixel lights to create.
+ private void CreateLights(
+ List lightObjects,
+ Vector4 lightColor,
+ Vector4 lightPosition,
+ bool pointLight,
+ int lightCount
+ )
+ {
+ for (int index = 0; index < lightCount; index++)
+ {
+ GameObject lightObject = CreateHiddenObject(
+ "PureBase Toon Lighting Contract Light " + index,
+ lightObjects
+ );
+ Light light = lightObject.AddComponent();
+ light.renderMode = LightRenderMode.ForcePixel;
+ light.color = new Color(lightColor.x, lightColor.y, lightColor.z, 1.0f).gamma;
+ light.intensity = 1.0f;
+ light.cullingMask = 1 << FixtureLayer;
+ if (pointLight)
+ {
+ light.type = LightType.Point;
+ light.range = 4.0f;
+ lightObject.transform.position = new Vector3(
+ lightPosition.x,
+ lightPosition.y,
+ lightPosition.z
+ );
+ }
+ else
+ {
+ light.type = LightType.Directional;
+ Vector3 direction = new Vector3(
+ lightPosition.x,
+ lightPosition.y,
+ lightPosition.z
+ ).normalized;
+ Assert.That(
+ direction,
+ Is.Not.EqualTo(Vector3.zero),
+ "Directional lighting requires a nonzero direction."
+ );
+ lightObject.transform.rotation = Quaternion.LookRotation(-direction, Vector3.up);
+ }
+ }
+ }
+
+ /// Creates one hidden preview-scene GameObject and registers it immediately for cleanup.
+ /// The diagnostic object name.
+ /// The caller-owned hidden preview-scene GameObject.
+ private GameObject CreateHiddenObject(string name)
+ {
+ return CreateHiddenObject(name, gameObjects);
+ }
+
+ /// Creates one hidden preview-scene GameObject in the supplied cleanup collection.
+ /// The diagnostic object name.
+ /// The cleanup collection that receives the object immediately after allocation.
+ /// The caller-owned hidden preview-scene GameObject.
+ private GameObject CreateHiddenObject(string name, List objects)
+ {
+ var gameObject = new GameObject(name);
+ objects.Add(gameObject);
+ gameObject.hideFlags = HideFlags.HideAndDontSave;
+ gameObject.layer = FixtureLayer;
+ SceneManager.MoveGameObjectToScene(gameObject, scene);
+ return gameObject;
+ }
+
+ /// Releases command-buffer, temporary objects, material, texture, target, and render-mesh resources.
+ private void ReleaseRenderResources()
+ {
+ if (camera != null && commandBuffer != null)
+ {
+ camera.RemoveCommandBuffer(CameraEvent.BeforeForwardOpaque, commandBuffer);
+ }
+
+ if (commandBuffer != null)
+ {
+ commandBuffer.Release();
+ }
+
+ DestroyGameObjects(gameObjects);
+
+ foreach (Material material in materials)
+ {
+ UnityEngine.Object.DestroyImmediate(material);
+ }
+
+ if (readback != null)
+ {
+ UnityEngine.Object.DestroyImmediate(readback);
+ }
+
+ if (normalMap != null)
+ {
+ UnityEngine.Object.DestroyImmediate(normalMap);
+ }
+
+ if (target != null)
+ {
+ target.Release();
+ UnityEngine.Object.DestroyImmediate(target);
+ }
+
+ foreach (Mesh mesh in meshes)
+ {
+ UnityEngine.Object.DestroyImmediate(mesh);
+ }
+ }
+
+ /// Destroys the supplied temporary GameObjects in reverse creation order.
+ /// The caller-owned GameObjects to destroy.
+ private static void DestroyGameObjects(List objects)
+ {
+ for (int index = objects.Count - 1; index >= 0; index--)
+ {
+ UnityEngine.Object.DestroyImmediate(objects[index]);
+ }
+
+ objects.Clear();
+ }
+
+ /// Closes the generated Preview Scene after all generated resources have been released.
+ private void ClosePreviewScene()
+ {
+ if (scene.IsValid() && scene.isLoaded)
+ {
+ EditorSceneManager.ClosePreviewScene(scene);
+ }
+ }
+
+ /// Restores every caller-owned global, render target, quality, and scene setting.
+ private void RestoreCallerState()
+ {
+ foreach (KeyValuePair global in globals)
+ {
+ Shader.SetGlobalVector(global.Key, global.Value);
+ }
+
+ RenderTexture.active = activeRenderTexture;
+ QualitySettings.pixelLightCount = pixelLightCount;
+ if (activeScene.IsValid() && activeScene.isLoaded)
+ {
+ SceneManager.SetActiveScene(activeScene);
+ RenderSettings.fog = fogEnabled;
+ }
+ }
+
+ /// Asserts that the generated Preview Scene did not leak after cleanup.
+ private void AssertRestoredSceneCount()
+ {
+ Assert.That(
+ SceneManager.sceneCount,
+ Is.EqualTo(sceneCount),
+ "The Toon lighting capture scope must restore the original loaded scene count."
+ );
+ }
+
+ /// Configures a white opaque product material with no texture-specific lighting variation.
+ /// The transient product material.
+ /// The metallic value for PBR and Hybrid observations.
+ private void ConfigureMaterial(Material material, float metallic)
+ {
+ material.SetTexture("_BaseTexture", Texture2D.whiteTexture);
+ material.SetColor("_BaseColor", Color.white);
+ material.SetTexture("_NormalMap", normalMap);
+ material.SetFloat("_NormalScale", 1.0f);
+ if (material.HasProperty("_Metallic"))
+ {
+ material.SetFloat("_Metallic", metallic);
+ material.SetFloat("_Roughness", 0.25f);
+ }
+ }
+
+ /// Creates a linear normal-map texel that unpacks to the tangent-space forward vector in both Shader-Core branches.
+ /// The fixture-owned nonpersistent normal texture.
+ private static Texture2D CreateNeutralNormalTexture()
+ {
+ var texture = new Texture2D(1, 1, TextureFormat.RGBAFloat, false, true)
+ {
+ hideFlags = HideFlags.HideAndDontSave,
+ filterMode = FilterMode.Point,
+ wrapMode = TextureWrapMode.Clamp,
+ };
+ texture.SetPixel(0, 0, new Color(0.5f, 0.5f, 1.0f, 1.0f));
+ texture.Apply(false, true);
+ return texture;
+ }
+
+ /// Creates the full-frame mesh used by regular renderer draws.
+ /// The required uniform world-space normal.
+ /// The caller-owned transient mesh.
+ private Mesh CreateNormalControlledQuad(Vector3 normal)
+ {
+ var result = new Mesh { hideFlags = HideFlags.HideAndDontSave };
+ meshes.Add(result);
+ Vector3 normalized = normal.normalized;
+ result.vertices = new[]
+ {
+ new Vector3(-1.0f, -1.0f, 0.0f),
+ new Vector3(-1.0f, 1.0f, 0.0f),
+ new Vector3(1.0f, 1.0f, 0.0f),
+ new Vector3(1.0f, -1.0f, 0.0f),
+ };
+ result.uv = new[] { Vector2.zero, Vector2.up, Vector2.one, Vector2.right };
+ result.triangles = new[] { 0, 1, 2, 0, 2, 3 };
+ result.RecalculateBounds();
+ result.normals = new[] { normalized, normalized, normalized, normalized };
+ result.tangents = new[]
+ {
+ new Vector4(1.0f, 0.0f, 0.0f, 1.0f),
+ new Vector4(1.0f, 0.0f, 0.0f, 1.0f),
+ new Vector4(1.0f, 0.0f, 0.0f, 1.0f),
+ new Vector4(1.0f, 0.0f, 0.0f, 1.0f),
+ };
+ return result;
+ }
+
+ /// Reads the center pixel while restoring the caller's active render target.
+ /// The center linear color.
+ private Color ReadCenterPixel()
+ {
+ RenderTexture previous = RenderTexture.active;
+ try
+ {
+ RenderTexture.active = target;
+ readback.ReadPixels(new Rect(31.0f, 31.0f, 1.0f, 1.0f), 0, 0);
+ readback.Apply(false, false);
+ return readback.GetPixel(0, 0);
+ }
+ finally
+ {
+ RenderTexture.active = previous;
+ }
+ }
+ }
+ }
+}
diff --git a/Tests/Daily/Editor/PureBaseToonLightingContractTests.Runtime.cs.meta b/Tests/Daily/Editor/PureBaseToonLightingContractTests.Runtime.cs.meta
new file mode 100644
index 00000000..235689db
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseToonLightingContractTests.Runtime.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9601244364f328349a1714f27eb3ae03
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Tests/Daily/Editor/PureBaseToonLightingContractTests.cs b/Tests/Daily/Editor/PureBaseToonLightingContractTests.cs
new file mode 100644
index 00000000..dded6cdc
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseToonLightingContractTests.cs
@@ -0,0 +1,591 @@
+/*
+ * 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 focused numerical and runtime contracts for Toon scene-light direction and two-band SH lighting.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEditor.SceneManagement;
+using UnityEngine;
+using UnityEngine.Rendering;
+using UnityEngine.SceneManagement;
+
+namespace PureBase.Tests.Daily
+{
+ /// Defines fixed Toon lighting contracts independently of product HLSL implementation details.
+ public sealed partial class PureBaseToonLightingContractTests
+ {
+ /// Defines the tolerance for fixed half/float-compatible lighting reference values.
+ private const float OracleTolerance = 0.0002f;
+
+ /// Identifies the global keyword that selects the ForwardAdd point-light variant.
+ private const string PointKeyword = "POINT";
+
+ /// Requires the fixed dominant-direction two-band SH equation and rejects the current continuous normal evaluation.
+ [Test]
+ public void FixedTwoBandShOracleMatchesReferenceAndRejectsContinuousNormalEvaluation()
+ {
+ Vector4 shAr = new Vector4(0.3f, 0.0f, 0.0f, 0.2f);
+ Vector4 shAg = new Vector4(0.0f, 0.15f, 0.0f, 0.1f);
+ Vector4 shAb = new Vector4(0.0f, 0.0f, 0.45f, 0.3f);
+ Vector3 direction = EvaluateDominantDirection(
+ Vector3.zero,
+ shAr,
+ shAg,
+ shAb
+ );
+ Color bright = EvaluateTwoBandSh(direction, direction, shAr, shAg, shAb);
+ Color dark = EvaluateTwoBandSh(-direction, direction, shAr, shAg, shAb);
+
+ AssertVector(
+ new Vector3(0.53452248f, 0.26726124f, 0.80178373f),
+ direction,
+ "Fixed Toon SH dominant direction"
+ );
+ AssertColor(
+ new Color(0.30690450f, 0.12672612f, 0.54053512f, 1.0f),
+ bright,
+ "Fixed Toon SH bright band"
+ );
+ AssertColor(
+ new Color(0.09309550f, 0.07327388f, 0.05946488f, 1.0f),
+ dark,
+ "Fixed Toon SH dark band"
+ );
+
+ Color oldContinuousBright = EvaluateContinuousNormalSh(direction, shAr, shAg, shAb);
+ Color oldContinuousDark = EvaluateContinuousNormalSh(-direction, shAr, shAg, shAb);
+ Assert.That(
+ MaximumRgbDifference(oldContinuousBright, bright),
+ Is.GreaterThan(0.02f),
+ "The old continuous normal-evaluated SH must not satisfy the fixed bright-band oracle."
+ );
+ Assert.That(
+ MaximumRgbDifference(oldContinuousDark, dark),
+ Is.GreaterThan(0.02f),
+ "The old continuous normal-evaluated SH must not satisfy the fixed dark-band oracle."
+ );
+ }
+
+ /// Requires the fixed nonzero fallback before dominant-direction normalization.
+ [Test]
+ public void DegenerateDominantDirectionUsesFixedFiniteFallback()
+ {
+ Vector3 direction = EvaluateDominantDirection(
+ Vector3.zero,
+ Vector4.zero,
+ Vector4.zero,
+ Vector4.zero
+ );
+
+ AssertVector(
+ new Vector3(0.40824829f, 0.81649658f, 0.40824829f),
+ direction,
+ "Fixed Toon SH degenerate-direction fallback"
+ );
+ Assert.That(IsFinite(direction), Is.True, "Fallback direction must remain finite.");
+ }
+
+ /// Requires Toon ForwardBase readbacks to select fixed bright and dark SH bands instead of continuous normal SH.
+ [Test]
+ public void ToonForwardBaseShOnlyReadbackRequiresFixedBrightAndDarkBands()
+ {
+ ShCoefficients coefficients = ShCoefficients.FixedOracle;
+ Vector3 direction = EvaluateDominantDirection(
+ Vector3.zero,
+ coefficients.ar,
+ coefficients.ag,
+ coefficients.ab
+ );
+ Color expectedBright = EvaluateTwoBandSh(
+ direction,
+ direction,
+ coefficients.ar,
+ coefficients.ag,
+ coefficients.ab
+ );
+ Color expectedDark = EvaluateTwoBandSh(
+ -direction,
+ direction,
+ coefficients.ar,
+ coefficients.ag,
+ coefficients.ab
+ );
+
+ using (var capture = new ToonLightingCaptureScope())
+ {
+ Color bright = capture.Render(
+ "PureBase/Toon",
+ "ForwardBase",
+ direction,
+ Vector4.zero,
+ Vector4.zero,
+ coefficients
+ );
+ Color dark = capture.Render(
+ "PureBase/Toon",
+ "ForwardBase",
+ -direction,
+ Vector4.zero,
+ Vector4.zero,
+ coefficients
+ );
+
+ AssertFinite(bright, "Toon SH-only bright readback");
+ AssertFinite(dark, "Toon SH-only dark readback");
+ AssertColor(expectedBright, bright, "Toon SH-only bright readback");
+ AssertColor(expectedDark, dark, "Toon SH-only dark readback");
+ }
+ }
+
+ /// Requires direct aggregate direction to participate in Toon SH-band selection while direct binary lighting remains additive.
+ [Test]
+ public void ToonForwardBaseDirectAggregateParticipatesInShBandDirection()
+ {
+ ShCoefficients coefficients = ShCoefficients.FixedOracle;
+ Vector4 directLight = new Vector4(0.2f, 0.2f, 0.2f, 1.0f);
+ Vector4 directionalPosition = new Vector4(0.0f, 0.0f, 1.0f, 0.0f);
+ Vector3 direction = EvaluateDominantDirection(
+ new Vector3(0.0f, 0.0f, 0.2f),
+ coefficients.ar,
+ coefficients.ag,
+ coefficients.ab
+ );
+ Color expectedBright = EvaluateTwoBandSh(
+ direction,
+ direction,
+ coefficients.ar,
+ coefficients.ag,
+ coefficients.ab
+ ) + new Color(0.2f, 0.2f, 0.2f, 0.0f);
+
+ using (var capture = new ToonLightingCaptureScope())
+ {
+ Color actual = capture.Render(
+ "PureBase/Toon",
+ "ForwardBase",
+ direction,
+ directLight,
+ directionalPosition,
+ coefficients
+ );
+
+ AssertFinite(actual, "Toon direct-plus-SH readback");
+ AssertColor(expectedBright, actual, "Toon direct-plus-SH readback");
+ }
+ }
+
+ /// Requires injected SH to leave a point-light ForwardAdd readback unchanged while its RGB and alpha remain valid.
+ [Test]
+ public void ToonForwardAddSecondPointLightIgnoresInjectedShAndPreservesDestinationAlpha()
+ {
+ Vector4 pointPosition = new Vector4(0.0f, 0.0f, -2.0f, 1.0f);
+ Vector4 pointLight = new Vector4(0.3f, 0.2f, 0.1f, 1.0f);
+ using (var capture = new ToonLightingCaptureScope())
+ {
+ Color withoutSh = capture.Render(
+ "PureBase/Toon",
+ "ForwardAdd",
+ Vector3.back,
+ pointLight,
+ pointPosition,
+ ShCoefficients.Zero,
+ true
+ );
+ Color withSh = capture.Render(
+ "PureBase/Toon",
+ "ForwardAdd",
+ Vector3.back,
+ pointLight,
+ pointPosition,
+ ShCoefficients.FixedOracle,
+ true
+ );
+
+ AssertFinite(withoutSh, "Toon ForwardAdd point readback without SH");
+ AssertFinite(withSh, "Toon ForwardAdd point readback with SH");
+ Assert.That(
+ RgbMagnitude(withoutSh),
+ Is.GreaterThan(0.001f),
+ "The isolated second point-light ForwardAdd contribution must retain finite nonzero RGB."
+ );
+ Assert.That(
+ MaximumRgbDifference(withoutSh, withSh),
+ Is.LessThanOrEqualTo(0.002f),
+ "Injected SH must not alter the isolated additional-light ForwardAdd contribution."
+ );
+ Assert.That(
+ withSh.a,
+ Is.EqualTo(withoutSh.a).Within(0.002f),
+ "ForwardAdd must preserve the existing destination alpha contract."
+ );
+ }
+ }
+
+ /// Requires ForwardAdd point-light rendering to preserve the caller's global POINT keyword state.
+ /// Whether POINT is globally enabled before the transient capture begins.
+ [TestCase(false)]
+ [TestCase(true)]
+ public void ToonForwardAddPointLightRenderRestoresPointKeywordState(bool initiallyEnabled)
+ {
+ bool originalPointKeywordState = Shader.IsKeywordEnabled(PointKeyword);
+ try
+ {
+ RestorePointKeywordState(initiallyEnabled);
+ using (var capture = new ToonLightingCaptureScope())
+ {
+ Color readback = capture.Render(
+ "PureBase/Toon",
+ "ForwardAdd",
+ Vector3.back,
+ new Vector4(0.3f, 0.2f, 0.1f, 1.0f),
+ new Vector4(0.0f, 0.0f, -2.0f, 1.0f),
+ ShCoefficients.Zero,
+ true
+ );
+
+ AssertFinite(readback, "Toon ForwardAdd point keyword-state readback");
+ Assert.That(
+ Shader.IsKeywordEnabled(PointKeyword),
+ Is.EqualTo(initiallyEnabled),
+ "ForwardAdd point-light rendering must restore POINT after the draw."
+ );
+ }
+
+ Assert.That(
+ Shader.IsKeywordEnabled(PointKeyword),
+ Is.EqualTo(initiallyEnabled),
+ "Disposing the capture scope must retain the caller's POINT state."
+ );
+ }
+ finally
+ {
+ RestorePointKeywordState(originalPointKeywordState);
+ }
+ }
+
+ /// Restores the global POINT keyword to the supplied caller-owned state.
+ /// Whether POINT must be enabled after restoration.
+ private static void RestorePointKeywordState(bool enabled)
+ {
+ if (enabled)
+ {
+ Shader.EnableKeyword(PointKeyword);
+ }
+ else
+ {
+ Shader.DisableKeyword(PointKeyword);
+ }
+ }
+
+ /// Requires metallic-one PBR and Hybrid direct GGX to match and remain insensitive to injected SH.
+ [Test]
+ public void HybridMetallicOneDirectSpecularMatchesPbrAndIgnoresInjectedSh()
+ {
+ Vector4 directLight = new Vector4(0.7f, 0.6f, 0.5f, 1.0f);
+ Vector4 directionalPosition = new Vector4(0.0f, 0.0f, 1.0f, 0.0f);
+ using (var capture = new ToonLightingCaptureScope())
+ {
+ Color pbr = RenderDirectObservation(capture, "PureBase/PBR", Vector3.forward, directLight, directionalPosition, ShCoefficients.Zero, 1.0f);
+ Color hybridWithoutSh = RenderDirectObservation(capture, "PureBase/Hybrid", Vector3.forward, directLight, directionalPosition, ShCoefficients.Zero, 1.0f);
+ Color hybridWithSh = RenderDirectObservation(capture, "PureBase/Hybrid", Vector3.forward, directLight, directionalPosition, ShCoefficients.FixedOracle, 1.0f);
+ Color hybridBackFace = RenderDirectObservation(capture, "PureBase/Hybrid", Vector3.back, directLight, directionalPosition, ShCoefficients.Zero, 0.0f);
+ Color hybridFrontFace = RenderDirectObservation(capture, "PureBase/Hybrid", Vector3.forward, directLight, directionalPosition, ShCoefficients.Zero, 0.0f);
+
+ AssertFinite(pbr, "PBR metallic-one direct-specular readback");
+ AssertFinite(hybridWithoutSh, "Hybrid metallic-one direct-specular readback");
+ AssertFinite(hybridWithSh, "Hybrid metallic-one SH-injected readback");
+ AssertMetallicOneDirectEquivalence(pbr, hybridWithoutSh);
+ AssertInjectedShIsolation(hybridWithoutSh, hybridWithSh);
+ AssertNonmetallicBinaryDirectResponse(hybridBackFace, hybridFrontFace);
+ }
+ }
+
+ /// Renders one controlled ForwardBase direct-light observation.
+ private static Color RenderDirectObservation(ToonLightingCaptureScope capture, string shaderName, Vector3 normal, Vector4 lightColor, Vector4 lightPosition, ShCoefficients coefficients, float metallic)
+ {
+ return capture.Render(shaderName, "ForwardBase", normal, lightColor, lightPosition, coefficients, false, metallic);
+ }
+
+ /// Asserts metallic-one Hybrid direct GGX remains equivalent to PBR.
+ private static void AssertMetallicOneDirectEquivalence(Color pbr, Color hybrid)
+ {
+ Assert.That(MaximumRgbDifference(pbr, hybrid), Is.LessThanOrEqualTo(0.01f), "PBR and Hybrid metallic-one direct GGX must remain equivalent.");
+ }
+
+ /// Asserts injected Toon SH does not contribute to Hybrid direct GGX.
+ private static void AssertInjectedShIsolation(Color withoutSh, Color withSh)
+ {
+ Assert.That(MaximumRgbDifference(withoutSh, withSh), Is.LessThanOrEqualTo(0.01f), "Injected Toon SH must not enter Hybrid direct GGX.");
+ }
+
+ /// Asserts nonmetallic Hybrid retains its binary direct-diffuse response.
+ private static void AssertNonmetallicBinaryDirectResponse(Color backFace, Color frontFace)
+ {
+ Assert.That(RgbMagnitude(frontFace), Is.GreaterThan(RgbMagnitude(backFace) + 0.01f), "Nonmetallic Hybrid must retain the binary direct-diffuse response.");
+ }
+
+ /// Stores the seven Unity SH global vectors injected immediately before each explicit draw.
+ [SuppressMessage(
+ "Major Code Smell",
+ "S3898:Implement this method because it is defined in 'ValueType'.",
+ Justification = "This private immutable Unity-global input carrier is never compared or used as a hash key."
+ )]
+ private readonly struct ShCoefficients
+ {
+ /// Initializes the complete Unity SH global vector set.
+ /// The red linear SH vector.
+ /// The green linear SH vector.
+ /// The blue linear SH vector.
+ /// The red quadratic SH vector.
+ /// The green quadratic SH vector.
+ /// The blue quadratic SH vector.
+ /// The shared quadratic SH vector.
+ public ShCoefficients(
+ Vector4 ar,
+ Vector4 ag,
+ Vector4 ab,
+ Vector4 br,
+ Vector4 bg,
+ Vector4 bb,
+ Vector4 c
+ )
+ {
+ this.ar = ar;
+ this.ag = ag;
+ this.ab = ab;
+ this.br = br;
+ this.bg = bg;
+ this.bb = bb;
+ this.c = c;
+ }
+
+ /// Gets the red linear SH vector.
+ public Vector4 ar { get; }
+
+ /// Gets the green linear SH vector.
+ public Vector4 ag { get; }
+
+ /// Gets the blue linear SH vector.
+ public Vector4 ab { get; }
+
+ /// Gets the red quadratic SH vector.
+ public Vector4 br { get; }
+
+ /// Gets the green quadratic SH vector.
+ public Vector4 bg { get; }
+
+ /// Gets the blue quadratic SH vector.
+ public Vector4 bb { get; }
+
+ /// Gets the shared quadratic SH vector.
+ public Vector4 c { get; }
+
+ /// Gets the fixed coefficient set used by the pure C# oracle and runtime contracts.
+ public static ShCoefficients FixedOracle => new ShCoefficients(
+ new Vector4(0.3f, 0.0f, 0.0f, 0.2f),
+ new Vector4(0.0f, 0.15f, 0.0f, 0.1f),
+ new Vector4(0.0f, 0.0f, 0.45f, 0.3f),
+ Vector4.zero,
+ Vector4.zero,
+ Vector4.zero,
+ Vector4.zero
+ );
+
+ /// Gets the all-zero SH set used to isolate direct-light contributions.
+ public static ShCoefficients Zero => new ShCoefficients(
+ Vector4.zero,
+ Vector4.zero,
+ Vector4.zero,
+ Vector4.zero,
+ Vector4.zero,
+ Vector4.zero,
+ Vector4.zero
+ );
+ }
+
+ /// Owns one isolated explicit-draw fixture, restores every Unity global it changes, and preserves the established capture type while the runtime implementation resides in a partial source file.
+ private sealed class ToonLightingCaptureScope : ToonLightingCaptureRuntimeScope
+ {
+ }
+
+ /// Evaluates the fixed direct-plus-SH dominant direction with the required degenerate fallback.
+ /// The grayscale-weighted direct-light direction aggregate.
+ /// The Unity red first-order SH coefficient vector.
+ /// The Unity green first-order SH coefficient vector.
+ /// The Unity blue first-order SH coefficient vector.
+ /// The finite normalized Toon scene-light direction.
+ private static Vector3 EvaluateDominantDirection(
+ Vector3 directAggregateDirection,
+ Vector4 shAr,
+ Vector4 shAg,
+ Vector4 shAb
+ )
+ {
+ Vector3 shDirection = (new Vector3(shAr.x, shAr.y, shAr.z)
+ + new Vector3(shAg.x, shAg.y, shAg.z)
+ + new Vector3(shAb.x, shAb.y, shAb.z))
+ / 3.0f;
+ Vector3 directionVector = directAggregateDirection
+ + new Vector3(shDirection.x, Mathf.Abs(shDirection.y), shDirection.z);
+ if (Vector3.Dot(directionVector, directionVector) <= 0.000001f)
+ {
+ directionVector = new Vector3(0.001f, 0.002f, 0.001f);
+ }
+
+ return directionVector.normalized;
+ }
+
+ /// Evaluates the fixed Toon bright or dark SH band selected by the supplied surface normal.
+ /// The normalized world-space surface normal.
+ /// The fixed dominant scene-light direction.
+ /// The Unity red first-order SH coefficient vector.
+ /// The Unity green first-order SH coefficient vector.
+ /// The Unity blue first-order SH coefficient vector.
+ /// The selected nonnegative SH band.
+ private static Color EvaluateTwoBandSh(
+ Vector3 surfaceNormal,
+ Vector3 lightDirection,
+ Vector4 shAr,
+ Vector4 shAg,
+ Vector4 shAb
+ )
+ {
+ Vector3 evaluationDirection = lightDirection * 0.666666f;
+ Vector3 baseTerm = new Vector3(shAr.w, shAg.w, shAb.w);
+ Vector3 linear = new Vector3(
+ Vector3.Dot(new Vector3(shAr.x, shAr.y, shAr.z), evaluationDirection),
+ Vector3.Dot(new Vector3(shAg.x, shAg.y, shAg.z), evaluationDirection),
+ Vector3.Dot(new Vector3(shAb.x, shAb.y, shAb.z), evaluationDirection)
+ );
+ Vector3 bright = Vector3.Max(baseTerm + linear, Vector3.zero);
+ Vector3 dark = Vector3.Max(baseTerm - linear, Vector3.zero);
+ Vector3 selected = Vector3.Dot(surfaceNormal, lightDirection) >= 0.0f ? bright : dark;
+ return new Color(selected.x, selected.y, selected.z, 1.0f);
+ }
+
+ /// Evaluates the pre-change Toon continuous normal SH implementation with zero quadratic coefficients.
+ /// The normalized world-space surface normal.
+ /// The Unity red first-order SH coefficient vector.
+ /// The Unity green first-order SH coefficient vector.
+ /// The Unity blue first-order SH coefficient vector.
+ /// The pre-change continuous nonnegative SH result.
+ private static Color EvaluateContinuousNormalSh(
+ Vector3 surfaceNormal,
+ Vector4 shAr,
+ Vector4 shAg,
+ Vector4 shAb
+ )
+ {
+ Vector4 normal = new Vector4(surfaceNormal.x, surfaceNormal.y, surfaceNormal.z, 1.0f);
+ Vector3 ambient = Vector3.Max(
+ new Vector3(
+ Vector4.Dot(shAr, normal),
+ Vector4.Dot(shAg, normal),
+ Vector4.Dot(shAb, normal)
+ ),
+ Vector3.zero
+ );
+ return new Color(ambient.x, ambient.y, ambient.z, 1.0f);
+ }
+
+ /// Asserts a fixed Vector3 reference within the half/float-compatible tolerance.
+ /// The fixed reference vector.
+ /// The observed vector.
+ /// The diagnostic label.
+ private static void AssertVector(Vector3 expected, Vector3 actual, string label)
+ {
+ Assert.That(actual.x, Is.EqualTo(expected.x).Within(OracleTolerance), label + " red/x");
+ Assert.That(actual.y, Is.EqualTo(expected.y).Within(OracleTolerance), label + " green/y");
+ Assert.That(actual.z, Is.EqualTo(expected.z).Within(OracleTolerance), label + " blue/z");
+ }
+
+ /// Asserts a fixed RGB reference within the half/float-compatible tolerance.
+ /// The fixed reference color.
+ /// The observed color.
+ /// The diagnostic label.
+ private static void AssertColor(Color expected, Color actual, string label)
+ {
+ Assert.That(actual.r, Is.EqualTo(expected.r).Within(OracleTolerance), label + " red");
+ Assert.That(actual.g, Is.EqualTo(expected.g).Within(OracleTolerance), label + " green");
+ Assert.That(actual.b, Is.EqualTo(expected.b).Within(OracleTolerance), label + " blue");
+ }
+
+ /// Asserts that every component of one readback color is finite.
+ /// The color to inspect.
+ /// The diagnostic label.
+ private static void AssertFinite(Color value, string label)
+ {
+ Assert.That(
+ float.IsNaN(value.r) || float.IsInfinity(value.r),
+ Is.False,
+ label + " red is non-finite."
+ );
+ Assert.That(
+ float.IsNaN(value.g) || float.IsInfinity(value.g),
+ Is.False,
+ label + " green is non-finite."
+ );
+ Assert.That(
+ float.IsNaN(value.b) || float.IsInfinity(value.b),
+ Is.False,
+ label + " blue is non-finite."
+ );
+ Assert.That(
+ float.IsNaN(value.a) || float.IsInfinity(value.a),
+ Is.False,
+ label + " alpha is non-finite."
+ );
+ }
+
+ /// Returns the Euclidean magnitude of a color's RGB components.
+ /// The color to measure.
+ /// The nonnegative RGB magnitude.
+ private static float RgbMagnitude(Color value)
+ {
+ return Mathf.Sqrt(value.r * value.r + value.g * value.g + value.b * value.b);
+ }
+
+ /// Returns the greatest absolute RGB channel difference between two colors.
+ /// The first color.
+ /// The second color.
+ /// The maximum absolute RGB difference.
+ private static float MaximumRgbDifference(Color left, Color right)
+ {
+ return Mathf.Max(
+ Mathf.Abs(left.r - right.r),
+ Mathf.Abs(left.g - right.g),
+ Mathf.Abs(left.b - right.b)
+ );
+ }
+
+ /// Returns whether every vector component is finite.
+ /// The vector to inspect.
+ /// True when all components are finite.
+ private static bool IsFinite(Vector3 value)
+ {
+ return !float.IsNaN(value.x)
+ && !float.IsInfinity(value.x)
+ && !float.IsNaN(value.y)
+ && !float.IsInfinity(value.y)
+ && !float.IsNaN(value.z)
+ && !float.IsInfinity(value.z);
+ }
+ }
+}
diff --git a/Tests/Daily/Editor/PureBaseToonLightingContractTests.cs.meta b/Tests/Daily/Editor/PureBaseToonLightingContractTests.cs.meta
new file mode 100644
index 00000000..598b209c
--- /dev/null
+++ b/Tests/Daily/Editor/PureBaseToonLightingContractTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 11bb9e350742a394a8c97eba853c7630
+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 1865d1d8..04aa3af7 100644
--- a/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.cs
+++ b/Tests/Daily/Editor/PureBaseValidationSceneRegressionTests.cs
@@ -1462,6 +1462,55 @@ string observationLabel
}
}
+ /// Requires the committed Toon static lightmap to produce a finite observable baked delta without rebaking the fixture.
+ [Test]
+ public void CanonicalToonStaticLightmapProducesFiniteNonzeroTransientCloneDelta()
+ {
+ EditorStateSnapshot state = EditorStateSnapshot.Capture();
+ var fixtureScope = new ControlledFixtureSceneScope(ScenePath);
+ Scene validationScene = default;
+ Scene originalValidationScene = SceneManager.GetSceneByPath(ScenePath);
+ bool sceneWasLoaded = originalValidationScene.isLoaded;
+ bool sceneWasDirty = originalValidationScene.isDirty;
+ try
+ {
+ validationScene = fixtureScope.GetLoadedFixture(ScenePath);
+ fixtureScope.SetActiveFixture(validationScene);
+ ValidateFixture(validationScene);
+ MeshRenderer toonRenderer = FindAssignedToonStaticRenderer(validationScene);
+ StaticLightmapReadback assigned = CaptureTransientToonStaticLightmapReadback(
+ validationScene,
+ toonRenderer,
+ false
+ );
+ StaticLightmapReadback disabled = CaptureTransientToonStaticLightmapReadback(
+ validationScene,
+ toonRenderer,
+ true
+ );
+
+ Assert.That(assigned.finitePixelCount, Is.EqualTo(RenderSize * RenderSize));
+ Assert.That(disabled.finitePixelCount, Is.EqualTo(RenderSize * RenderSize));
+ Assert.That(
+ MaximumAbsoluteRgbDifference(assigned.pixels, disabled.pixels),
+ Is.GreaterThan(0.001f),
+ "The committed Toon static-lightmap assignment must contribute a finite nonzero baked readback delta. Dynamic lightmaps remain outside this deterministic numeric contract."
+ );
+ Assert.That(toonRenderer.lightmapIndex, Is.GreaterThanOrEqualTo(0));
+ }
+ finally
+ {
+ try
+ {
+ state.Restore(validationScene, sceneWasLoaded, sceneWasDirty);
+ }
+ finally
+ {
+ fixtureScope.Dispose();
+ }
+ }
+ }
+
/// Validates the persistent scene, materials, and lighting settings without modifying them.
/// The canonical validation scene.
private static void ValidateFixture(Scene scene)
@@ -1569,6 +1618,190 @@ private static string DescribeLoadedScenePaths()
return string.Join(", ", paths);
}
+ /// Finds the committed static Toon renderer with a valid baked lightmap assignment.
+ /// The canonical validation scene.
+ /// The Toon renderer whose assigned lightmap is observed through a transient clone.
+ private static MeshRenderer FindAssignedToonStaticRenderer(Scene scene)
+ {
+ foreach (MeshRenderer renderer in GetStaticRenderers(scene))
+ {
+ if (
+ renderer.sharedMaterial != null
+ && renderer.sharedMaterial.shader != null
+ && renderer.sharedMaterial.shader.name == "PureBase/Toon"
+ && renderer.lightmapIndex >= 0
+ )
+ {
+ return renderer;
+ }
+ }
+
+ throw new AssertionException(
+ "The canonical validation scene has no static PureBase/Toon renderer assigned to a baked lightmap."
+ );
+ }
+
+ /// Stores one complete linear static-lightmap clone readback without retaining native resources.
+ private sealed class StaticLightmapReadback
+ {
+ /// Initializes the managed pixels and finite-sample count for one clone capture.
+ /// The complete linear pixel array.
+ /// The number of finite RGB samples.
+ public StaticLightmapReadback(Color[] pixels, int finitePixelCount)
+ {
+ this.pixels = pixels;
+ this.finitePixelCount = finitePixelCount;
+ }
+
+ /// Gets the complete linear pixel array.
+ public Color[] pixels { get; }
+
+ /// Gets the number of finite RGB samples.
+ public int finitePixelCount { get; }
+ }
+
+ /// Renders a transient canonical Toon clone with its assigned static lightmap enabled or disabled.
+ /// The canonical scene that owns the source renderer.
+ /// The persistent Toon renderer whose assignment is copied without mutation.
+ /// Whether the transient clone clears only its static lightmap assignment.
+ /// The complete linear float capture.
+ private static StaticLightmapReadback CaptureTransientToonStaticLightmapReadback(
+ Scene sourceScene,
+ MeshRenderer sourceRenderer,
+ bool disableStaticLightmap
+ )
+ {
+ Camera sourceCamera = FindSceneCamera(sourceScene);
+ int captureLayer = FindUnusedCaptureLayer();
+ GameObject clone = EditorUtility.CreateGameObjectWithHideFlags(
+ "PureBase Toon Static Lightmap Clone",
+ HideFlags.HideAndDontSave,
+ typeof(MeshFilter),
+ typeof(MeshRenderer)
+ );
+ GameObject cameraObject = EditorUtility.CreateGameObjectWithHideFlags(
+ "PureBase Toon Static Lightmap Readback Camera",
+ HideFlags.HideAndDontSave,
+ typeof(Camera)
+ );
+ RenderTexture target = new RenderTexture(
+ RenderSize,
+ RenderSize,
+ 24,
+ RenderTextureFormat.ARGBFloat,
+ RenderTextureReadWrite.Linear
+ );
+ Texture2D readback = new Texture2D(
+ RenderSize,
+ RenderSize,
+ TextureFormat.RGBAFloat,
+ false,
+ true
+ );
+ try
+ {
+ SceneManager.MoveGameObjectToScene(clone, sourceScene);
+ SceneManager.MoveGameObjectToScene(cameraObject, sourceScene);
+ MeshFilter sourceFilter = sourceRenderer.GetComponent();
+ Assert.That(sourceFilter, Is.Not.Null, "The canonical Toon renderer requires a MeshFilter.");
+ clone.GetComponent().sharedMesh = sourceFilter.sharedMesh;
+ MeshRenderer cloneRenderer = clone.GetComponent();
+ cloneRenderer.sharedMaterial = sourceRenderer.sharedMaterial;
+ clone.transform.SetPositionAndRotation(
+ sourceRenderer.transform.position,
+ sourceRenderer.transform.rotation
+ );
+ clone.transform.localScale = sourceRenderer.transform.lossyScale;
+ clone.layer = captureLayer;
+ cloneRenderer.lightmapScaleOffset = sourceRenderer.lightmapScaleOffset;
+ cloneRenderer.lightmapIndex = disableStaticLightmap
+ ? -1
+ : sourceRenderer.lightmapIndex;
+
+ Camera camera = cameraObject.GetComponent();
+ camera.CopyFrom(sourceCamera);
+ camera.enabled = false;
+ camera.cullingMask = 1 << captureLayer;
+ camera.clearFlags = CameraClearFlags.SolidColor;
+ camera.backgroundColor = Color.clear;
+ target.Create();
+ camera.targetTexture = target;
+ camera.Render();
+ Color[] pixels = ReadPixels(target, readback);
+ return new StaticLightmapReadback(pixels, CountFinitePixels(pixels));
+ }
+ finally
+ {
+ UnityEngine.Object.DestroyImmediate(readback);
+ target.Release();
+ UnityEngine.Object.DestroyImmediate(target);
+ UnityEngine.Object.DestroyImmediate(cameraObject);
+ UnityEngine.Object.DestroyImmediate(clone);
+ }
+ }
+
+ /// Finds an unused user layer so transient canonical clone capture cannot include persistent renderers.
+ /// An unused user-layer index.
+ private static int FindUnusedCaptureLayer()
+ {
+ for (int layer = 31; layer >= 8; layer--)
+ {
+ bool used = false;
+ for (int sceneIndex = 0; sceneIndex < SceneManager.sceneCount && !used; sceneIndex++)
+ {
+ Scene scene = SceneManager.GetSceneAt(sceneIndex);
+ if (!scene.isLoaded)
+ {
+ continue;
+ }
+ foreach (GameObject root in scene.GetRootGameObjects())
+ {
+ foreach (Transform transform in root.GetComponentsInChildren(true))
+ {
+ if (transform.gameObject.layer == layer)
+ {
+ used = true;
+ break;
+ }
+ }
+ if (used)
+ {
+ break;
+ }
+ }
+ }
+ if (!used)
+ {
+ return layer;
+ }
+ }
+
+ throw new AssertionException(
+ "The static-lightmap capture requires an unused user layer to isolate transient clone rendering."
+ );
+ }
+
+ /// Returns the largest absolute RGB difference between two same-sized captures.
+ /// The first complete linear capture.
+ /// The second complete linear capture.
+ /// The largest RGB channel difference.
+ private static float MaximumAbsoluteRgbDifference(Color[] left, Color[] right)
+ {
+ Assert.That(left, Has.Length.EqualTo(right.Length));
+ float maximum = 0.0f;
+ for (int index = 0; index < left.Length; index++)
+ {
+ maximum = Mathf.Max(
+ maximum,
+ Mathf.Abs(left[index].r - right[index].r),
+ Mathf.Abs(left[index].g - right[index].g),
+ Mathf.Abs(left[index].b - right[index].b)
+ );
+ }
+
+ return maximum;
+ }
+
/// Renders the scene through a temporary camera without changing the persisted camera target.
/// The canonical validation scene.
/// The observation to populate.
diff --git a/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1 b/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1
index dc53f013..8d617d6c 100644
--- a/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1
+++ b/Tests/Release/Run-PureBaseReleaseValidation.Tests.ps1
@@ -1254,3 +1254,29 @@ exit /b %PUREBASE_HARNESS_BOOTSTRAP_EXIT%
}
}
+Describe 'Release validation workflow evidence export contracts' {
+ It 'uploads runner evidence while preserving the package and repository-state export' {
+ $packageRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
+ $workflowPath = Join-Path $packageRoot '.github/workflows/release-validation.yml'
+ $workflowSource = Get-Content -LiteralPath $workflowPath -Raw
+ $uploadStepMatch = [regex]::Match($workflowSource, '(?ms)^\s*- name: Upload release validation evidence\s*\r?\n.*?(?=^\s*- name:|\z)')
+ $artifactRoot = '${{ runner.temp }}\PureBase-Release-Validation-${{ github.run_id }}-${{ github.run_attempt }}'
+ $expectedArtifactPaths = @(
+ ($artifactRoot + '\validated-package'),
+ ($artifactRoot + '\repository-state.json'),
+ ($artifactRoot + '\ReleaseConsumer-*\**\runtime-readbacks.json'),
+ ($artifactRoot + '\ReleaseConsumer-*\**\library-reset.json'),
+ ($artifactRoot + '\ReleaseConsumer-*\**\*summary.json'),
+ ($artifactRoot + '\ReleaseConsumer-*\**\*.log')
+ )
+
+ $uploadStepMatch.Success | Should -BeTrue -Because 'the Release workflow must have a dedicated evidence upload step'
+ $uploadStep = $uploadStepMatch.Value
+ $uploadStep | Should -Match '(?m)^\s*if: always\(\)\s*$' -Because 'failed Release validation must upload evidence'
+ $uploadStep | Should -Match 'actions/upload-artifact@' -Because 'the existing evidence artifact must remain an uploaded artifact'
+ foreach ($expectedArtifactPath in $expectedArtifactPaths) {
+ $uploadStep | Should -Match ([regex]::Escape($expectedArtifactPath)) -Because "the evidence artifact must include '$expectedArtifactPath'"
+ }
+ }
+}
+
diff --git a/package.json b/package.json
index e81746f1..4faf7b24 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "jp.penguin.purebase",
"displayName": "PureBase",
- "version": "0.2.0-beta.1",
+ "version": "0.2.0-beta.2",
"author": {
"name": "Penguin"
},
@@ -14,7 +14,7 @@
"keywords": [
"Shader"
],
- "url": "https://github.com/Penguin-Repository/Pure-Base/releases/download/0.2.0-beta.1/jp.penguin.purebase-0.2.0-beta.1.zip",
+ "url": "https://github.com/Penguin-Repository/Pure-Base/releases/download/0.2.0-beta.2/jp.penguin.purebase-0.2.0-beta.2.zip",
"legacyFolders": {
"Assets\\PureBase": ""
}