Files
2026-11-01 23:39:55 +02:00

161 lines
9.3 KiB
C#

using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using NUnit.Framework;
using UnityEditor.Rendering.Universal.ShaderGraph;
using UnityEditor.ShaderGraph;
namespace UnityEditor.Rendering.Universal.Tests
{
[TestFixture]
class UniversalTerrainLitCodeGenTests
{
internal enum VertexContext
{
WithPositionBlock,
Empty,
}
// TerrainLit emits the primary shader plus the hidden _AddPass and _BaseMapGen shaders, so
// assertions have to cover all of them, not just Generator.generatedShader.
static List<(string name, string code)> GenerateShaders(VertexContext vertexContext, GenerationMode mode)
{
var target = new UniversalTarget();
Assert.IsTrue(target.TrySetActiveSubTarget(typeof(UniversalTerrainLitSubTarget)),
"TerrainLit subtarget is not registered on UniversalTarget");
var blocks = vertexContext == VertexContext.WithPositionBlock
? new[] { BlockFields.VertexDescription.Position, BlockFields.SurfaceDescription.BaseColor }
: new[] { BlockFields.SurfaceDescription.BaseColor };
var graph = new GraphData();
graph.AddContexts();
graph.InitializeOutputs(new Target[] { target }, blocks);
// outputNode stays null for a non-subgraph. A non-null one would make Generator swap in
// PreviewTarget and drop the terrain subtarget entirely.
var shaders = new Generator(graph, graph.outputNode, mode, "TerrainLitCodeGen").allGeneratedShaders
.Select(shader => (shader.shaderName, shader.codeString))
.ToList();
Assert.IsNotEmpty(shaders, "TerrainLit generated no shaders");
// The template preprocessor treats '$' as a command marker even inside a comment, and on a
// malformed directive it splices a marker into the shader instead of failing. Without this
// check a broken template would leave the assertions below passing on text that is wrong.
// Every marker is a comment line carrying a caret under the offending column. Matching the
// caret rather than the word ERROR also catches Expect(), which reports "^ Expected ')'".
foreach (var (name, code) in shaders)
StringAssert.DoesNotMatch(@"(?m)^\s*//\s*\^ ", code, $"template preprocessor error in {name}");
return shaders;
}
// Asserting mere presence would be satisfied by a single pass out of fifteen, so a new terrain
// pass whose template forgot the include would slip through. Every terrain pass emits exactly one
// UNIVERSAL_TERRAIN_ENABLED define and should carry exactly one copy of the function.
static void AssertDefinedInEveryTerrainPass(string function, VertexContext vertexContext, GenerationMode mode)
{
foreach (var (name, code) in GenerateTerrainShaders(vertexContext, mode))
{
var passes = Regex.Matches(code, @"#define UNIVERSAL_TERRAIN_ENABLED 1").Count;
var definitions = Regex.Matches(code, Regex.Escape(function) + @"\(").Count;
Assert.AreEqual(passes, definitions, $"{function} in {name}");
}
}
// Preview generation emits the _AddPass shader with no passes at all, and a shader with no
// terrain pass has nothing to define. Key off the define the terrain passes themselves add.
static List<(string name, string code)> GenerateTerrainShaders(VertexContext vertexContext, GenerationMode mode)
{
var shaders = GenerateShaders(vertexContext, mode)
.Where(shader => shader.code.Contains("UNIVERSAL_TERRAIN_ENABLED"))
.ToList();
Assert.IsNotEmpty(shaders, "no generated shader turns on the terrain path");
return shaders;
}
// Varyings.hlsl calls ApplyTerrainInstancing outside of any FEATURES_GRAPH_VERTEX guard, so the
// terrain passes have to emit it even when the graph's Vertex context is empty. That state is one
// Delete away: TerrainLit's only active vertex block is Position, it is unconnected by default, and
// removing it does not trigger the auto re-add. Gating the definition on graphVertex breaks every pass.
[TestCase(VertexContext.WithPositionBlock, GenerationMode.ForReals)]
[TestCase(VertexContext.Empty, GenerationMode.ForReals)]
[TestCase(VertexContext.WithPositionBlock, GenerationMode.Preview)]
[TestCase(VertexContext.Empty, GenerationMode.Preview)]
public void TerrainShadersDefineApplyTerrainInstancing(VertexContext vertexContext, GenerationMode mode)
{
AssertDefinedInEveryTerrainPass("ApplyTerrainInstancing", vertexContext, mode);
}
// Same for ConstructTerrainTangent, which the forward and gbuffer passes call under
// ENABLE_TERRAIN_PERPIXEL_NORMAL.
[TestCase(VertexContext.WithPositionBlock, GenerationMode.ForReals)]
[TestCase(VertexContext.Empty, GenerationMode.ForReals)]
[TestCase(VertexContext.WithPositionBlock, GenerationMode.Preview)]
[TestCase(VertexContext.Empty, GenerationMode.Preview)]
public void TerrainShadersDefineConstructTerrainTangent(VertexContext vertexContext, GenerationMode mode)
{
AssertDefinedInEveryTerrainPass("ConstructTerrainTangent", vertexContext, mode);
}
// The splat UV plumbing this subtarget used to emit was guarded on UNIVERSAL_TERRAIN_SPLAT02, a
// keyword no descriptor ever declared, so the guarded block compiled out in silence and shipped
// that way for five release streams. A misspelled #if is invisible to the shader compiler and to
// image comparison alike, which is why it survived that long. Assert the invariant instead: a
// terrain pass may only test UNIVERSAL_TERRAIN_ keywords that the same pass defines or declares.
//
// The scope is one HLSLPROGRAM, not the whole shader, because preprocessor state does not cross
// that boundary. The pre-fix tree had exactly that shape: every pixel pass tested
// UNIVERSAL_TERRAIN_SPLAT01, but ShadowCaster, DepthOnly and the two picking passes never
// defined it, and a shader-wide set difference is satisfied by the forward pass. A #pragma
// multi_compile or shader_feature counts as a declaration, since those keywords never appear as
// a #define and are legitimately tested.
//
// UNIVERSAL_TERRAIN_ENABLED is the only keyword left under this prefix, so this guards the next
// one added rather than a live bug. The prefix stays narrow on purpose: terrain templates also
// test keywords that core URP declares (_NORMALMAP), and a wider net would need a whitelist of
// foreign declarations that would rot.
[TestCase(VertexContext.WithPositionBlock, GenerationMode.ForReals)]
[TestCase(VertexContext.Empty, GenerationMode.ForReals)]
[TestCase(VertexContext.WithPositionBlock, GenerationMode.Preview)]
[TestCase(VertexContext.Empty, GenerationMode.Preview)]
public void TerrainPassesDeclareEveryUniversalTerrainKeywordTheyTest(VertexContext vertexContext, GenerationMode mode)
{
// Collect across shaders and passes before asserting, so one failure names every offender.
var undeclared = new List<string>();
foreach (var (name, code) in GenerateTerrainShaders(vertexContext, mode))
{
var programs = Regex.Matches(code, @"HLSLPROGRAM.*?ENDHLSL", RegexOptions.Singleline);
Assert.IsNotEmpty(programs, $"no HLSLPROGRAM block in {name}");
foreach (Match program in programs)
{
var declared = new HashSet<string>(Regex.Matches(program.Value, @"#define\s+(UNIVERSAL_TERRAIN_\w+)")
.Cast<Match>().Select(match => match.Groups[1].Value));
foreach (Match pragma in Regex.Matches(program.Value, @"#pragma\s+(?:multi_compile|shader_feature|dynamic_branch)[^\n]*"))
declared.UnionWith(Regex.Matches(pragma.Value, @"UNIVERSAL_TERRAIN_\w+").Cast<Match>().Select(match => match.Value));
var referenced = new HashSet<string>(Regex.Matches(program.Value, @"UNIVERSAL_TERRAIN_\w+")
.Cast<Match>().Select(match => match.Value));
referenced.ExceptWith(declared);
if (referenced.Count > 0)
undeclared.Add($"{name} {PassName(code, program.Index)}: {string.Join(" ", referenced.OrderBy(keyword => keyword))}");
}
}
CollectionAssert.IsEmpty(undeclared, "terrain keyword tested but never declared in the same pass");
}
// Regular passes carry Name "Universal Forward" from the PassName splice; the BaseMapGen passes
// carry "Name" = "_MainTex" as a tag instead. Either way it is the last one before the program.
static string PassName(string code, int programIndex)
{
var names = Regex.Matches(code.Substring(0, programIndex), @"""?Name""?\s*=?\s*""([^""]+)""");
return names.Count > 0 ? names[names.Count - 1].Groups[1].Value : $"program at offset {programIndex}";
}
}
}