Shader sanity

- Switch #use to #include
- Stop ignoring errors from within shader compilers
- Track load errors in CompilerStats
- Move LoadError to separate sealed interface, LoadResult.Failure wraps
  a LoadError
- Move SourceFile member parsing methods to respective classes
- Add tests for SourceFile loading
- Start work on tests for error messages
This commit is contained in:
Jozufozu 2023-05-13 17:02:18 -07:00
parent d1e54d65da
commit d27929c307
42 changed files with 731 additions and 412 deletions

View file

@ -12,6 +12,9 @@ import com.jozufozu.flywheel.backend.compile.core.ProgramLinker;
import com.jozufozu.flywheel.backend.compile.core.ShaderCompiler;
import com.jozufozu.flywheel.gl.shader.GlProgram;
import com.jozufozu.flywheel.glsl.ShaderSources;
import com.jozufozu.flywheel.glsl.SourceFile;
import net.minecraft.resources.ResourceLocation;
public abstract class AbstractCompiler<K> {
protected final ShaderSources sources;
@ -31,6 +34,13 @@ public abstract class AbstractCompiler<K> {
@Nullable
protected abstract GlProgram compile(K key);
@Nullable
protected SourceFile findOrReport(ResourceLocation rl) {
var out = sources.find(rl);
stats.loadResult(out);
return out.unwrap();
}
@Nullable
public Map<K, GlProgram> compileAndReportErrors() {
stats.start();

View file

@ -1,7 +1,5 @@
package com.jozufozu.flywheel.backend.compile;
import java.util.List;
import org.jetbrains.annotations.Nullable;
import com.google.common.collect.ImmutableList;
@ -13,7 +11,6 @@ import com.jozufozu.flywheel.gl.shader.GlProgram;
import com.jozufozu.flywheel.gl.shader.ShaderType;
import com.jozufozu.flywheel.glsl.GLSLVersion;
import com.jozufozu.flywheel.glsl.ShaderSources;
import com.jozufozu.flywheel.glsl.SourceComponent;
import com.jozufozu.flywheel.glsl.SourceFile;
import net.minecraft.resources.ResourceLocation;
@ -33,7 +30,14 @@ public class CullingCompiler extends AbstractCompiler<InstanceType<?>> {
@Nullable
@Override
protected GlProgram compile(InstanceType<?> key) {
var computeComponents = getComputeComponents(key);
var instanceAssembly = new IndirectComponent(sources, key);
var instance = findOrReport(key.instanceShader());
if (instance == null) {
return null;
}
var computeComponents = ImmutableList.of(uniformComponent, instanceAssembly, instance, pipelineCompute);
var compute = shaderCompiler.compile(GLSLVersion.V460, ShaderType.COMPUTE, computeComponents);
if (compute == null) {
@ -43,15 +47,6 @@ public class CullingCompiler extends AbstractCompiler<InstanceType<?>> {
return programLinker.link(compute);
}
private List<SourceComponent> getComputeComponents(InstanceType<?> instanceType) {
var instanceAssembly = new IndirectComponent(sources, instanceType);
ResourceLocation key = instanceType.instanceShader();
var instance = sources.find(key)
.unwrap();
return ImmutableList.of(uniformComponent, instanceAssembly, instance, pipelineCompute);
}
private static final class Files {
public static final ResourceLocation INDIRECT_CULL = Flywheel.rl("internal/indirect_cull.glsl");
}

View file

@ -8,6 +8,7 @@ import com.google.common.collect.ImmutableList;
import com.jozufozu.flywheel.backend.compile.component.MaterialAdapterComponent;
import com.jozufozu.flywheel.backend.compile.component.UniformComponent;
import com.jozufozu.flywheel.gl.shader.GlProgram;
import com.jozufozu.flywheel.gl.shader.GlShader;
import com.jozufozu.flywheel.gl.shader.ShaderType;
import com.jozufozu.flywheel.glsl.ShaderSources;
import com.jozufozu.flywheel.glsl.SourceComponent;
@ -37,10 +38,8 @@ public class PipelineCompiler extends AbstractCompiler<PipelineProgramKey> {
@Nullable
@Override
protected GlProgram compile(PipelineProgramKey key) {
var glslVersion = pipeline.glslVersion();
var vertex = shaderCompiler.compile(glslVersion, ShaderType.VERTEX, getVertexComponents(key));
var fragment = shaderCompiler.compile(glslVersion, ShaderType.FRAGMENT, getFragmentComponents(key));
GlShader vertex = compileVertex(key);
GlShader fragment = compileFragment(key);
if (vertex == null || fragment == null) {
return null;
@ -52,27 +51,54 @@ public class PipelineCompiler extends AbstractCompiler<PipelineProgramKey> {
return glProgram;
}
@Nullable
private GlShader compileVertex(PipelineProgramKey key) {
var vertexComponents = getVertexComponents(key);
if (vertexComponents == null) {
return null;
}
return shaderCompiler.compile(pipeline.glslVersion(), ShaderType.VERTEX, vertexComponents);
}
@Nullable
private GlShader compileFragment(PipelineProgramKey key) {
var fragmentComponents = getFragmentComponents(key);
if (fragmentComponents == null) {
return null;
}
return shaderCompiler.compile(pipeline.glslVersion(), ShaderType.FRAGMENT, fragmentComponents);
}
@Nullable
private List<SourceComponent> getVertexComponents(PipelineProgramKey key) {
var instanceAssembly = pipeline.assembler()
.assemble(new Pipeline.InstanceAssemblerContext(sources, key.vertexType(), key.instanceType()));
var layout = sources.find(key.vertexType()
.layoutShader())
.unwrap();
var instance = sources.find(key.instanceType()
.instanceShader())
.unwrap();
var context = sources.find(key.contextShader()
.vertexShader())
.unwrap();
var layout = findOrReport(key.vertexType()
.layoutShader());
var instance = findOrReport(key.instanceType()
.instanceShader());
var context = findOrReport(key.contextShader()
.vertexShader());
if (instanceAssembly == null || layout == null || instance == null || context == null) {
return null;
}
return ImmutableList.of(uniformComponent, vertexMaterialComponent, instanceAssembly, layout, instance, context, pipelineVertex);
}
@Nullable
private List<SourceComponent> getFragmentComponents(PipelineProgramKey key) {
var context = sources.find(key.contextShader()
.fragmentShader())
.unwrap();
var context = findOrReport(key.contextShader()
.fragmentShader());
if (context == null) {
return null;
}
return ImmutableList.of(uniformComponent, fragmentMaterialComponent, context, pipelineFragment);
}
}

View file

@ -1,10 +1,17 @@
package com.jozufozu.flywheel.backend.compile.core;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.jetbrains.annotations.NotNull;
import com.jozufozu.flywheel.Flywheel;
import com.jozufozu.flywheel.glsl.LoadError;
import com.jozufozu.flywheel.glsl.LoadResult;
import com.jozufozu.flywheel.glsl.error.ErrorBuilder;
import com.jozufozu.flywheel.util.StringUtil;
public class CompilerStats {
@ -12,6 +19,8 @@ public class CompilerStats {
private final List<FailedCompilation> shaderErrors = new ArrayList<>();
private final List<String> programErrors = new ArrayList<>();
private final Set<LoadError> loadErrors = new HashSet<>();
private boolean errored = false;
private int shaderCount = 0;
private int programCount = 0;
@ -32,8 +41,28 @@ public class CompilerStats {
}
public String generateErrorLog() {
return String.join("\n", programErrors) + '\n' + shaderErrors.stream()
.map(FailedCompilation::getMessage)
return """
%s
%s
%s
""".formatted(loadErrors(), compileErrors(), linkErrors());
}
private String compileErrors() {
return shaderErrors.stream()
.map(FailedCompilation::generateMessage)
.collect(Collectors.joining("\n"));
}
@NotNull
private String linkErrors() {
return String.join("\n", programErrors);
}
private String loadErrors() {
return loadErrors.stream()
.map(LoadError::generateMessage)
.map(ErrorBuilder::build)
.collect(Collectors.joining("\n"));
}
@ -52,4 +81,11 @@ public class CompilerStats {
}
programCount++;
}
public void loadResult(LoadResult loadResult) {
if (loadResult instanceof LoadResult.Failure f) {
loadErrors.add(f.error());
errored = true;
}
}
}

View file

@ -33,7 +33,7 @@ public class FailedCompilation {
this.errorLog = errorLog;
}
public String getMessage() {
public String generateMessage() {
return ConsoleColors.RED_BOLD_BRIGHT + "Failed to compile " + shaderName + ":\n" + errorString();
}

View file

@ -8,7 +8,6 @@ import java.util.Objects;
import java.util.Set;
import java.util.function.Consumer;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import com.jozufozu.flywheel.gl.shader.GlShader;
@ -24,10 +23,6 @@ public class ShaderCompiler {
this.stats = stats;
}
public int shaderCount() {
return shaderCache.size();
}
@Nullable
public GlShader compile(GLSLVersion glslVersion, ShaderType shaderType, List<SourceComponent> sourceComponents) {
var key = new ShaderKey(glslVersion, shaderType, sourceComponents);
@ -36,7 +31,13 @@ public class ShaderCompiler {
return cached.unwrap();
}
ShaderResult out = compileUncached(new Compilation(glslVersion, shaderType), sourceComponents);
Compilation ctx = new Compilation(glslVersion, shaderType);
ctx.enableExtension("GL_ARB_explicit_attrib_location");
ctx.enableExtension("GL_ARB_conservative_depth");
expand(sourceComponents, ctx::appendComponent);
ShaderResult out = ctx.compile();
shaderCache.put(key, out);
stats.shaderResult(out);
return out.unwrap();
@ -50,16 +51,6 @@ public class ShaderCompiler {
.forEach(GlShader::delete);
}
@NotNull
private ShaderResult compileUncached(Compilation ctx, List<SourceComponent> sourceComponents) {
ctx.enableExtension("GL_ARB_explicit_attrib_location");
ctx.enableExtension("GL_ARB_conservative_depth");
expand(sourceComponents, ctx::appendComponent);
return ctx.compile();
}
private static void expand(List<SourceComponent> rootSources, Consumer<SourceComponent> out) {
var included = new LinkedHashSet<SourceComponent>(); // use hash set to deduplicate. linked to preserve order
for (var component : rootSources) {

View file

@ -0,0 +1,58 @@
package com.jozufozu.flywheel.glsl;
import java.io.IOException;
import java.util.List;
import java.util.stream.Collectors;
import com.jozufozu.flywheel.glsl.error.ErrorBuilder;
import com.jozufozu.flywheel.glsl.span.Span;
import com.jozufozu.flywheel.util.Pair;
import net.minecraft.resources.ResourceLocation;
sealed public interface LoadError {
ErrorBuilder generateMessage();
record CircularDependency(ResourceLocation offender, List<ResourceLocation> stack) implements LoadError {
public String format() {
return stack.stream()
.dropWhile(l -> !l.equals(offender))
.map(ResourceLocation::toString)
.collect(Collectors.joining(" -> "));
}
@Override
public ErrorBuilder generateMessage() {
return ErrorBuilder.create()
.error("files are circularly dependent")
.note(format());
}
}
record IncludeError(ResourceLocation location, List<Pair<Span, LoadError>> innerErrors) implements LoadError {
@Override
public ErrorBuilder generateMessage() {
var out = ErrorBuilder.create()
.error("could not load shader due to errors in included files")
.pointAtFile(location);
for (var innerError : innerErrors) {
var err = innerError.second()
.generateMessage();
out.pointAt(innerError.first())
.nested(err);
}
return out;
}
}
record IOError(ResourceLocation location, IOException exception) implements LoadError {
@Override
public ErrorBuilder generateMessage() {
return ErrorBuilder.create()
.error("could not load \"" + location + "\" due to an IO error")
.note(exception.getMessage());
}
}
}

View file

@ -1,20 +1,14 @@
package com.jozufozu.flywheel.glsl;
import java.io.IOException;
import java.util.List;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import net.minecraft.resources.ResourceLocation;
public sealed interface LoadResult {
static LoadResult success(SourceFile sourceFile) {
return new Success(sourceFile);
@Nullable
default SourceFile unwrap() {
return null;
}
@Nullable SourceFile unwrap();
record Success(SourceFile source) implements LoadResult {
@Override
@NotNull
@ -23,17 +17,6 @@ public sealed interface LoadResult {
}
}
record IOError(ResourceLocation location, IOException exception) implements LoadResult {
@Override
public SourceFile unwrap() {
return null;
}
}
record IncludeError(ResourceLocation location, List<LoadResult> innerFailures) implements LoadResult {
@Override
public SourceFile unwrap() {
return null;
}
record Failure(LoadError error) implements LoadResult {
}
}

View file

@ -1,12 +0,0 @@
package com.jozufozu.flywheel.glsl;
public class ShaderLoadingException extends RuntimeException {
public ShaderLoadingException(String message) {
super(message);
}
public ShaderLoadingException(String message, Throwable cause) {
super(message, cause);
}
}

View file

@ -4,11 +4,14 @@ import java.io.IOException;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.VisibleForTesting;
import com.jozufozu.flywheel.util.ResourceUtil;
import com.jozufozu.flywheel.util.StringUtil;
@ -23,7 +26,8 @@ public class ShaderSources {
private final ResourceManager manager;
private final Map<ResourceLocation, LoadResult> cache = new HashMap<>();
@VisibleForTesting
protected final Map<ResourceLocation, LoadResult> cache = new HashMap<>();
/**
* Tracks where we are in the mutual recursion to detect circular imports.
@ -36,19 +40,34 @@ public class ShaderSources {
@Nonnull
public LoadResult find(ResourceLocation location) {
pushFindStack(location);
if (findStack.contains(location)) {
// Make a copy of the find stack with the offending location added on top to show the full path.
findStack.addLast(location);
var copy = List.copyOf(findStack);
findStack.removeLast();
return new LoadResult.Failure(new LoadError.CircularDependency(location, copy));
}
findStack.addLast(location);
LoadResult out = _find(location);
findStack.removeLast();
return out;
}
@NotNull
private LoadResult _find(ResourceLocation location) {
// Can't use computeIfAbsent because mutual recursion causes ConcurrentModificationExceptions
var out = cache.get(location);
if (out == null) {
out = load(location);
cache.put(location, out);
}
popFindStack();
return out;
}
@Nonnull
private LoadResult load(ResourceLocation loc) {
protected LoadResult load(ResourceLocation loc) {
try {
var resource = manager.getResource(ResourceUtil.prefixed(SHADER_DIR, loc));
@ -56,28 +75,7 @@ public class ShaderSources {
return SourceFile.parse(this, loc, sourceString);
} catch (IOException e) {
return new LoadResult.IOError(loc, e);
return new LoadResult.Failure(new LoadError.IOError(loc, e));
}
}
private void generateRecursiveImportException(ResourceLocation location) {
findStack.add(location);
String path = findStack.stream()
.dropWhile(l -> !l.equals(location))
.map(ResourceLocation::toString)
.collect(Collectors.joining(" -> "));
findStack.clear();
throw new ShaderLoadingException("recursive import: " + path);
}
private void pushFindStack(ResourceLocation location) {
if (findStack.contains(location)) {
generateRecursiveImportException(location);
}
findStack.addLast(location);
}
private void popFindStack() {
findStack.removeLast();
}
}

View file

@ -2,13 +2,10 @@ package com.jozufozu.flywheel.glsl;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import org.jetbrains.annotations.NotNull;
@ -18,9 +15,9 @@ import com.jozufozu.flywheel.glsl.parse.Import;
import com.jozufozu.flywheel.glsl.parse.ShaderField;
import com.jozufozu.flywheel.glsl.parse.ShaderFunction;
import com.jozufozu.flywheel.glsl.parse.ShaderStruct;
import com.jozufozu.flywheel.glsl.span.ErrorSpan;
import com.jozufozu.flywheel.glsl.span.Span;
import com.jozufozu.flywheel.glsl.span.StringSpan;
import com.jozufozu.flywheel.util.Pair;
import net.minecraft.resources.ResourceLocation;
@ -70,10 +67,10 @@ public class SourceFile implements SourceComponent {
public static LoadResult parse(ShaderSources sourceFinder, ResourceLocation name, String stringSource) {
var source = new SourceLines(name, stringSource);
var imports = parseImports(source);
var imports = Import.parseImports(source);
List<SourceFile> included = new ArrayList<>();
List<LoadResult> failures = new ArrayList<>();
List<Pair<Span, LoadError>> failures = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (Import i : imports) {
@ -85,20 +82,20 @@ public class SourceFile implements SourceComponent {
var result = sourceFinder.find(new ResourceLocation(string));
if (result instanceof LoadResult.Success s) {
included.add(s.unwrap());
} else {
failures.add(result);
} else if (result instanceof LoadResult.Failure e) {
failures.add(Pair.of(fileSpan, e.error()));
}
}
if (!failures.isEmpty()) {
return new LoadResult.IncludeError(name, failures);
return new LoadResult.Failure(new LoadError.IncludeError(name, failures));
}
var functions = parseFunctions(source);
var structs = parseStructs(source);
var fields = parseFields(source);
var functions = ShaderFunction.parseFunctions(source);
var structs = ShaderStruct.parseStructs(source);
var fields = ShaderField.parseFields(source);
var finalSource = generateFinalSource(imports, source);
return LoadResult.success(new SourceFile(name, source, functions, structs, imports, fields, included, finalSource));
return new LoadResult.Success(new SourceFile(name, source, functions, structs, imports, fields, included, finalSource));
}
@Override
@ -111,25 +108,6 @@ public class SourceFile implements SourceComponent {
return finalSource;
}
@NotNull
private static String generateFinalSource(ImmutableList<Import> imports, SourceLines source) {
var out = new StringBuilder();
int lastEnd = 0;
for (var include : imports) {
var loc = include.self();
out.append(source, lastEnd, loc.startIndex());
lastEnd = loc.endIndex();
}
out.append(source, lastEnd, source.length());
return out.toString();
}
@Override
public ResourceLocation name() {
return name;
@ -216,119 +194,23 @@ public class SourceFile implements SourceComponent {
return System.identityHashCode(this);
}
/**
* Scan the source for {@code #use "..."} directives.
* Records the contents of the directive into an {@link Import} object, and marks the directive for elision.
*/
private static ImmutableList<Import> parseImports(SourceLines source) {
Matcher uses = Import.PATTERN.matcher(source);
@NotNull
private static String generateFinalSource(ImmutableList<Import> imports, SourceLines source) {
var out = new StringBuilder();
var imports = ImmutableList.<Import>builder();
int lastEnd = 0;
while (uses.find()) {
Span use = Span.fromMatcher(source, uses);
Span file = Span.fromMatcher(source, uses, 1);
for (var include : imports) {
var loc = include.self();
imports.add(new Import(use, file));
out.append(source, lastEnd, loc.startIndex());
lastEnd = loc.endIndex();
}
return imports.build();
out.append(source, lastEnd, source.length());
return out.toString();
}
/**
* Scan the source for function definitions and "parse" them into objects that contain properties of the function.
*/
private static ImmutableMap<String, ShaderFunction> parseFunctions(SourceLines source) {
Matcher matcher = ShaderFunction.PATTERN.matcher(source);
Map<String, ShaderFunction> functions = new HashMap<>();
while (matcher.find()) {
Span type = Span.fromMatcher(source, matcher, 1);
Span name = Span.fromMatcher(source, matcher, 2);
Span args = Span.fromMatcher(source, matcher, 3);
int blockStart = matcher.end();
int blockEnd = findEndOfBlock(source, blockStart);
Span self;
Span body;
if (blockEnd > blockStart) {
self = new StringSpan(source, matcher.start(), blockEnd + 1);
body = new StringSpan(source, blockStart, blockEnd);
} else {
self = new ErrorSpan(source, matcher.start(), matcher.end());
body = new ErrorSpan(source, blockStart);
}
ShaderFunction function = new ShaderFunction(self, type, name, args, body);
functions.put(name.get(), function);
}
return ImmutableMap.copyOf(functions);
}
/**
* Scan the source for function definitions and "parse" them into objects that contain properties of the function.
*/
private static ImmutableMap<String, ShaderStruct> parseStructs(SourceLines source) {
Matcher matcher = ShaderStruct.PATTERN.matcher(source);
ImmutableMap.Builder<String, ShaderStruct> structs = ImmutableMap.builder();
while (matcher.find()) {
Span self = Span.fromMatcher(source, matcher);
Span name = Span.fromMatcher(source, matcher, 1);
Span body = Span.fromMatcher(source, matcher, 2);
Span variableName = Span.fromMatcher(source, matcher, 3);
ShaderStruct shaderStruct = new ShaderStruct(self, name, body, variableName);
structs.put(name.get(), shaderStruct);
}
return structs.build();
}
/**
* Scan the source for function definitions and "parse" them into objects that contain properties of the function.
*/
private static ImmutableMap<String, ShaderField> parseFields(SourceLines source) {
Matcher matcher = ShaderField.PATTERN.matcher(source);
ImmutableMap.Builder<String, ShaderField> fields = ImmutableMap.builder();
while (matcher.find()) {
Span self = Span.fromMatcher(source, matcher);
Span location = Span.fromMatcher(source, matcher, 1);
Span decoration = Span.fromMatcher(source, matcher, 2);
Span type = Span.fromMatcher(source, matcher, 3);
Span name = Span.fromMatcher(source, matcher, 4);
fields.put(location.get(), new ShaderField(self, location, decoration, type, name));
}
return fields.build();
}
/**
* Given the position of an opening brace, scans through the source for a paired closing brace.
*/
private static int findEndOfBlock(CharSequence source, int start) {
int blockDepth = 0;
for (int i = start + 1; i < source.length(); i++) {
char ch = source.charAt(i);
if (ch == '{') {
blockDepth++;
} else if (ch == '}') {
blockDepth--;
}
if (blockDepth < 0) {
return i;
}
}
return -1;
}
}

View file

@ -2,14 +2,19 @@ package com.jozufozu.flywheel.glsl.error;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.VisibleForTesting;
import com.jozufozu.flywheel.glsl.SourceFile;
import com.jozufozu.flywheel.glsl.SourceLines;
import com.jozufozu.flywheel.glsl.error.lines.ErrorLine;
import com.jozufozu.flywheel.glsl.error.lines.FileLine;
import com.jozufozu.flywheel.glsl.error.lines.HeaderLine;
import com.jozufozu.flywheel.glsl.error.lines.NestedLine;
import com.jozufozu.flywheel.glsl.error.lines.SourceLine;
import com.jozufozu.flywheel.glsl.error.lines.SpanHighlightLine;
import com.jozufozu.flywheel.glsl.error.lines.TextLine;
@ -17,7 +22,12 @@ import com.jozufozu.flywheel.glsl.span.Span;
import com.jozufozu.flywheel.util.ConsoleColors;
import com.jozufozu.flywheel.util.StringUtil;
import net.minecraft.resources.ResourceLocation;
public class ErrorBuilder {
// set to false for testing
@VisibleForTesting
public static boolean CONSOLE_COLORS = true;
private final List<ErrorLine> lines = new ArrayList<>();
@ -56,11 +66,15 @@ public class ErrorBuilder {
}
public ErrorBuilder pointAtFile(SourceFile file) {
return pointAtFile(file.name.toString());
return pointAtFile(file.name);
}
public ErrorBuilder pointAtFile(SourceLines source) {
return pointAtFile(source.name.toString());
return pointAtFile(source.name);
}
public ErrorBuilder pointAtFile(ResourceLocation file) {
return pointAtFile(file.toString());
}
public ErrorBuilder pointAtFile(String file) {
@ -126,6 +140,34 @@ public class ErrorBuilder {
}
public String build() {
Stream<String> lineStream = getLineStream();
if (CONSOLE_COLORS) {
lineStream = lineStream.map(line -> line + ConsoleColors.RESET);
}
return lineStream.collect(Collectors.joining("\n"));
}
@NotNull
private Stream<String> getLineStream() {
int maxMargin = calculateMargin();
return lines.stream()
.map(line -> addPaddingToLine(maxMargin, line));
}
private static String addPaddingToLine(int maxMargin, ErrorLine errorLine) {
int neededMargin = errorLine.neededMargin();
if (neededMargin >= 0) {
return StringUtil.repeatChar(' ', maxMargin - neededMargin) + errorLine.build();
} else {
return errorLine.build();
}
}
private int calculateMargin() {
int maxMargin = -1;
for (ErrorLine line : lines) {
int neededMargin = line.neededMargin();
@ -134,20 +176,12 @@ public class ErrorBuilder {
maxMargin = neededMargin;
}
}
return maxMargin;
}
StringBuilder builder = new StringBuilder();
for (ErrorLine line : lines) {
int neededMargin = line.neededMargin();
if (neededMargin >= 0) {
builder.append(StringUtil.repeatChar(' ', maxMargin - neededMargin));
}
builder.append(line.build())
.append(ConsoleColors.RESET)
.append('\n');
}
return builder.toString();
public void nested(ErrorBuilder err) {
err.getLineStream()
.map(NestedLine::new)
.forEach(lines::add);
}
}

View file

@ -3,20 +3,26 @@ package com.jozufozu.flywheel.glsl.error;
import com.jozufozu.flywheel.util.ConsoleColors;
public enum ErrorLevel {
WARN(ConsoleColors.YELLOW + "warn"),
ERROR(ConsoleColors.RED + "error"),
HINT(ConsoleColors.WHITE_BRIGHT + "hint"),
NOTE(ConsoleColors.WHITE_BRIGHT + "note"),
WARN(ConsoleColors.YELLOW, "warn"),
ERROR(ConsoleColors.RED, "error"),
HINT(ConsoleColors.WHITE_BRIGHT, "hint"),
NOTE(ConsoleColors.WHITE_BRIGHT, "note"),
;
private final String color;
private final String error;
ErrorLevel(String error) {
ErrorLevel(String color, String error) {
this.color = color;
this.error = error;
}
@Override
public String toString() {
return error;
if (ErrorBuilder.CONSOLE_COLORS) {
return color + error;
} else {
return error;
}
}
}

View file

@ -1,115 +0,0 @@
package com.jozufozu.flywheel.glsl.error;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import com.jozufozu.flywheel.glsl.ShaderLoadingException;
import com.jozufozu.flywheel.glsl.SourceFile;
import com.jozufozu.flywheel.glsl.span.Span;
import com.jozufozu.flywheel.lib.math.MoreMath;
import com.jozufozu.flywheel.util.StringUtil;
import com.mojang.logging.LogUtils;
public class ErrorReporter {
private static final Logger LOGGER = LogUtils.getLogger();
private final List<ErrorBuilder> reportedErrors = new ArrayList<>();
public void generateMissingStruct(SourceFile file, Span vertexName, CharSequence msg) {
generateMissingStruct(file, vertexName, msg, "");
}
public void generateMissingStruct(SourceFile file, Span vertexName, CharSequence msg, CharSequence hint) {
// Optional<Span> span = file.parent.index.getStructDefinitionsMatching(vertexName)
// .stream()
// .findFirst()
// .map(ShaderStruct::getName);
//
// this.error(msg)
// .pointAtFile(file)
// .pointAt(vertexName, 1)
// .hintIncludeFor(span.orElse(null), hint);
}
public void generateMissingFunction(SourceFile file, CharSequence functionName, CharSequence msg) {
generateMissingFunction(file, functionName, msg, "");
}
public void generateMissingFunction(SourceFile file, CharSequence functionName, CharSequence msg, CharSequence hint) {
// Optional<Span> span = file.parent.index.getFunctionDefinitionsMatching(functionName)
// .stream()
// .findFirst()
// .map(ShaderFunction::getName);
//
// this.error(msg)
// .pointAtFile(file)
// .hintIncludeFor(span.orElse(null), hint);
}
public ErrorBuilder generateFunctionArgumentCountError(String name, int requiredArguments, Span span) {
var msg = '"' + name + "\" function must ";
if (requiredArguments == 0) {
msg += "not have any arguments";
} else {
msg += "have exactly " + requiredArguments + " argument" + (requiredArguments == 1 ? "" : "s");
}
return generateSpanError(span, msg);
}
public ErrorBuilder generateSpanError(Span span, String message) {
var file = span.source();
return error(message).pointAtFile(file)
.pointAt(span, 2);
}
public ErrorBuilder generateFileError(SourceFile file, String message) {
return error(message).pointAtFile(file);
}
public ErrorBuilder error(String msg) {
var out = ErrorBuilder.create()
.error(msg);
reportedErrors.add(out);
return out;
}
public boolean hasErrored() {
return !reportedErrors.isEmpty();
}
public ShaderLoadingException dump() {
var allErrors = reportedErrors.stream()
.map(ErrorBuilder::build)
.collect(Collectors.joining());
return new ShaderLoadingException(allErrors);
}
public static void printLines(String string) {
List<String> lines = string.lines()
.toList();
int size = lines.size();
int maxWidth = MoreMath.numDigits(size) + 1;
StringBuilder builder = new StringBuilder().append('\n');
for (int i = 0; i < size; i++) {
builder.append(i)
.append(StringUtil.repeatChar(' ', maxWidth - MoreMath.numDigits(i)))
.append("| ")
.append(lines.get(i))
.append('\n');
}
LOGGER.error(builder.toString());
}
}

View file

@ -0,0 +1,8 @@
package com.jozufozu.flywheel.glsl.error.lines;
public record NestedLine(String right) implements ErrorLine {
@Override
public String right() {
return right;
}
}

View file

@ -0,0 +1,6 @@
@MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault
package com.jozufozu.flywheel.glsl;
import javax.annotation.ParametersAreNonnullByDefault;
import net.minecraft.MethodsReturnNonnullByDefault;

View file

@ -1,9 +1,31 @@
package com.jozufozu.flywheel.glsl.parse;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.google.common.collect.ImmutableList;
import com.jozufozu.flywheel.glsl.SourceLines;
import com.jozufozu.flywheel.glsl.span.Span;
public record Import(Span self, Span file) {
public static final Pattern PATTERN = Pattern.compile("^\\s*#\\s*use\\s+\"(.*)\"", Pattern.MULTILINE);
public static final Pattern PATTERN = Pattern.compile("^\\s*#\\s*include\\s+\"(.*)\"", Pattern.MULTILINE);
/**
* Scan the source for {@code #use "..."} directives.
* Records the contents of the directive into an {@link Import} object, and marks the directive for elision.
*/
public static ImmutableList<Import> parseImports(SourceLines source) {
Matcher uses = PATTERN.matcher(source);
var imports = ImmutableList.<Import>builder();
while (uses.find()) {
Span use = Span.fromMatcher(source, uses);
Span file = Span.fromMatcher(source, uses, 1);
imports.add(new Import(use, file));
}
return imports.build();
}
}

View file

@ -1,9 +1,12 @@
package com.jozufozu.flywheel.glsl.parse;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jetbrains.annotations.Nullable;
import com.google.common.collect.ImmutableMap;
import com.jozufozu.flywheel.glsl.SourceLines;
import com.jozufozu.flywheel.glsl.span.Span;
public class ShaderField {
@ -24,6 +27,26 @@ public class ShaderField {
this.name = name;
}
/**
* Scan the source for function definitions and "parse" them into objects that contain properties of the function.
*/
public static ImmutableMap<String, ShaderField> parseFields(SourceLines source) {
Matcher matcher = PATTERN.matcher(source);
ImmutableMap.Builder<String, ShaderField> fields = ImmutableMap.builder();
while (matcher.find()) {
Span self = Span.fromMatcher(source, matcher);
Span location = Span.fromMatcher(source, matcher, 1);
Span decoration = Span.fromMatcher(source, matcher, 2);
Span type = Span.fromMatcher(source, matcher, 3);
Span name = Span.fromMatcher(source, matcher, 4);
fields.put(location.get(), new ShaderField(self, location, decoration, type, name));
}
return fields.build();
}
public enum Decoration {
IN,
OUT,

View file

@ -1,11 +1,17 @@
package com.jozufozu.flywheel.glsl.parse;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.jozufozu.flywheel.glsl.SourceLines;
import com.jozufozu.flywheel.glsl.span.ErrorSpan;
import com.jozufozu.flywheel.glsl.span.Span;
import com.jozufozu.flywheel.glsl.span.StringSpan;
public class ShaderFunction {
// https://regexr.com/60n3d
@ -32,6 +38,62 @@ public class ShaderFunction {
this.parameters = parseArguments();
}
/**
* Scan the source for function definitions and "parse" them into objects that contain properties of the function.
*/
public static ImmutableMap<String, ShaderFunction> parseFunctions(SourceLines source) {
Matcher matcher = PATTERN.matcher(source);
Map<String, ShaderFunction> functions = new HashMap<>();
while (matcher.find()) {
Span type = Span.fromMatcher(source, matcher, 1);
Span name = Span.fromMatcher(source, matcher, 2);
Span args = Span.fromMatcher(source, matcher, 3);
int blockStart = matcher.end();
int blockEnd = findEndOfBlock(source, blockStart);
Span self;
Span body;
if (blockEnd > blockStart) {
self = new StringSpan(source, matcher.start(), blockEnd + 1);
body = new StringSpan(source, blockStart, blockEnd);
} else {
self = new ErrorSpan(source, matcher.start(), matcher.end());
body = new ErrorSpan(source, blockStart);
}
ShaderFunction function = new ShaderFunction(self, type, name, args, body);
functions.put(name.get(), function);
}
return ImmutableMap.copyOf(functions);
}
/**
* Given the position of an opening brace, scans through the source for a paired closing brace.
*/
private static int findEndOfBlock(CharSequence source, int start) {
int blockDepth = 0;
for (int i = start + 1; i < source.length(); i++) {
char ch = source.charAt(i);
if (ch == '{') {
blockDepth++;
} else if (ch == '}') {
blockDepth--;
}
if (blockDepth < 0) {
return i;
}
}
return -1;
}
public Span getType() {
return type;
}

View file

@ -5,6 +5,7 @@ import java.util.regex.Pattern;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.jozufozu.flywheel.glsl.SourceLines;
import com.jozufozu.flywheel.glsl.span.Span;
public class ShaderStruct {
@ -29,6 +30,27 @@ public class ShaderStruct {
this.fields2Types = createTypeLookup();
}
/**
* Scan the source for function definitions and "parse" them into objects that contain properties of the function.
*/
public static ImmutableMap<String, ShaderStruct> parseStructs(SourceLines source) {
Matcher matcher = PATTERN.matcher(source);
ImmutableMap.Builder<String, ShaderStruct> structs = ImmutableMap.builder();
while (matcher.find()) {
Span self = Span.fromMatcher(source, matcher);
Span name = Span.fromMatcher(source, matcher, 1);
Span body = Span.fromMatcher(source, matcher, 2);
Span variableName = Span.fromMatcher(source, matcher, 3);
ShaderStruct shaderStruct = new ShaderStruct(self, name, body, variableName);
structs.put(name.get(), shaderStruct);
}
return structs.build();
}
public Span getName() {
return name;
}

View file

@ -1,5 +1,5 @@
#use "flywheel:api/vertex.glsl"
#use "flywheel:util/fog.glsl"
#include "flywheel:api/vertex.glsl"
#include "flywheel:util/fog.glsl"
void flw_contextVertex() {
flw_distance = fog_distance(flw_vertexPos.xyz, flywheel.cameraPos.xyz, flywheel.fogShape);

View file

@ -1,4 +1,4 @@
#use "flywheel:api/fragment.glsl"
#include "flywheel:api/fragment.glsl"
uniform sampler2D flw_diffuseTex;

View file

@ -1 +1 @@
#use "flywheel:context/common.vert"
#include "flywheel:context/common.vert"

View file

@ -1,4 +1,4 @@
#use "flywheel:api/fragment.glsl"
#include "flywheel:api/fragment.glsl"
// optimize discard usage
#ifdef ALPHA_DISCARD

View file

@ -1 +1 @@
#use "flywheel:context/common.vert"
#include "flywheel:context/common.vert"

View file

@ -1,5 +1,5 @@
#use "flywheel:api/vertex.glsl"
#use "flywheel:util/quaternion.glsl"
#include "flywheel:api/vertex.glsl"
#include "flywheel:util/quaternion.glsl"
void flw_transformBoundingSphere(in FlwInstance i, inout vec3 center, inout float radius) {
vec4 rotation = i.rotation;
@ -9,7 +9,7 @@ void flw_transformBoundingSphere(in FlwInstance i, inout vec3 center, inout floa
center = rotateVertexByQuat(center - pivot, rotation) + pivot + pos;
}
#ifdef VERTEX_SHADER
#ifdef VERTEX_SHADER
void flw_instanceVertex(in FlwInstance i) {
flw_vertexPos = vec4(rotateVertexByQuat(flw_vertexPos.xyz - i.pivot, i.rotation) + i.pivot + i.position, 1.0);
flw_vertexNormal = rotateVertexByQuat(flw_vertexNormal, i.rotation);

View file

@ -1,4 +1,4 @@
#use "flywheel:api/vertex.glsl"
#include "flywheel:api/vertex.glsl"
void flw_transformBoundingSphere(in FlwInstance i, inout vec3 center, inout float radius) {
mat4 pose = i.pose;

View file

@ -1,4 +1,4 @@
#use "flywheel:api/fragment.glsl"
#include "flywheel:api/fragment.glsl"
void main() {
flw_initFragment();

View file

@ -1,8 +1,8 @@
#define FLW_SUBGROUP_SIZE 32
layout(local_size_x = FLW_SUBGROUP_SIZE) in;
#use "flywheel:util/types.glsl"
#use "flywheel:internal/indirect_draw_command.glsl"
#include "flywheel:util/types.glsl"
#include "flywheel:internal/indirect_draw_command.glsl"
// populated by instancers
layout(std430, binding = 0) restrict readonly buffer ObjectBuffer {

View file

@ -1,5 +1,5 @@
#use "flywheel:api/vertex.glsl"
#use "flywheel:internal/indirect_draw_command.glsl"
#include "flywheel:api/vertex.glsl"
#include "flywheel:internal/indirect_draw_command.glsl"
layout(std430, binding = 0) restrict readonly buffer ObjectBuffer {
FlwPackedInstance objects[];

View file

@ -1,4 +1,4 @@
#use "flywheel:util/types.glsl"
#include "flywheel:util/types.glsl"
struct MeshDrawCommand {
uint indexCount;

View file

@ -1,4 +1,4 @@
#use "flywheel:api/vertex.glsl"
#include "flywheel:api/vertex.glsl"
uniform uvec2 _flw_materialID_instancing;

View file

@ -1,4 +1,4 @@
#use "flywheel:api/vertex.glsl"
#include "flywheel:api/vertex.glsl"
layout(location = 0) in vec3 _flw_v_pos;
layout(location = 1) in vec4 _flw_v_color;

View file

@ -1,4 +1,4 @@
#use "flywheel:api/vertex.glsl"
#include "flywheel:api/vertex.glsl"
layout(location = 0) in vec3 _flw_v_pos;
layout(location = 1) in vec2 _flw_v_texCoord;

View file

@ -1,5 +1,5 @@
#use "flywheel:api/fragment.glsl"
#use "flywheel:util/fog.glsl"
#include "flywheel:api/fragment.glsl"
#include "flywheel:util/fog.glsl"
void flw_materialFragment() {
}

View file

@ -1,5 +1,5 @@
#use "flywheel:api/fragment.glsl"
#use "flywheel:util/fog.glsl"
#include "flywheel:api/fragment.glsl"
#include "flywheel:util/fog.glsl"
void flw_materialFragment() {
}

View file

@ -1,4 +1,4 @@
#use "flywheel:api/vertex.glsl"
#include "flywheel:api/vertex.glsl"
void flw_materialVertex() {
}

View file

@ -1,5 +1,5 @@
#use "flywheel:api/vertex.glsl"
#use "flywheel:util/diffuse.glsl"
#include "flywheel:api/vertex.glsl"
#include "flywheel:util/diffuse.glsl"
void flw_materialVertex() {
flw_vertexNormal = normalize(flw_vertexNormal);

View file

@ -0,0 +1,38 @@
package com.jozufozu.flywheel.glsl;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.Assertions;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.packs.resources.ResourceManager;
public class MockShaderSources extends ShaderSources {
private final Map<ResourceLocation, String> sources = new HashMap<>();
public MockShaderSources() {
super(ResourceManager.Empty.INSTANCE);
}
public void add(ResourceLocation loc, String source) {
sources.put(loc, source);
}
@NotNull
@Override
protected LoadResult load(ResourceLocation loc) {
var maybeFound = sources.get(loc);
if (maybeFound == null) {
return new LoadResult.Failure(new LoadError.IOError(loc, new IOException("Mock source not found")));
}
return SourceFile.parse(this, loc, maybeFound);
}
public LoadResult assertLoaded(ResourceLocation loc) {
Assertions.assertTrue(cache.containsKey(loc), "Expected " + loc + " to be cached");
return cache.get(loc);
}
}

View file

@ -0,0 +1,67 @@
package com.jozufozu.flywheel.glsl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.util.List;
import org.jetbrains.annotations.NotNull;
import com.jozufozu.flywheel.Flywheel;
import com.jozufozu.flywheel.glsl.error.ErrorBuilder;
import net.minecraft.resources.ResourceLocation;
public class TestBase {
public static final ResourceLocation FLW_A = Flywheel.rl("a.glsl");
public static final ResourceLocation FLW_B = Flywheel.rl("b.glsl");
public static final ResourceLocation FLW_C = Flywheel.rl("c.glsl");
public static <T> T assertSingletonList(List<T> list) {
assertEquals(1, list.size());
return list.get(0);
}
@NotNull
public static <E extends LoadError> E findAndAssertError(Class<E> clazz, MockShaderSources sources, ResourceLocation loc) {
var result = sources.find(loc);
var failure = assertInstanceOf(LoadResult.Failure.class, result);
return assertInstanceOf(clazz, failure.error());
}
@NotNull
public static ErrorBuilder assertErrorAndGetMessage(MockShaderSources sources, ResourceLocation loc) {
var result = sources.find(loc);
var failure = assertInstanceOf(LoadResult.Failure.class, result);
return failure.error()
.generateMessage();
}
static <E extends LoadError> E assertSimpleNestedErrorsToDepth(Class<E> finalErrType, LoadError err, int depth) {
var includeError = assertInstanceOf(LoadError.IncludeError.class, err);
var pair = assertSingletonList(includeError.innerErrors());
for (int i = 1; i < depth; i++) {
includeError = assertInstanceOf(LoadError.IncludeError.class, pair.second());
pair = assertSingletonList(includeError.innerErrors());
}
return assertInstanceOf(finalErrType, pair.second());
}
@NotNull
public static SourceFile findAndAssertSuccess(MockShaderSources sources, ResourceLocation loc) {
var result = sources.find(loc);
return assertSuccessAndUnwrap(loc, result);
}
@NotNull
public static SourceFile assertSuccessAndUnwrap(ResourceLocation expectedName, LoadResult result) {
assertInstanceOf(LoadResult.Success.class, result);
var file = result.unwrap();
assertNotNull(file);
assertEquals(expectedName, file.name);
return file;
}
}

View file

@ -0,0 +1,33 @@
package com.jozufozu.flywheel.glsl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import com.jozufozu.flywheel.glsl.error.ErrorBuilder;
public class TestErrorMessages extends TestBase {
@BeforeAll
static void disableConsoleColors() {
ErrorBuilder.CONSOLE_COLORS = false;
}
@Test
void testMissingIncludeMsg() {
var sources = new MockShaderSources();
sources.add(FLW_A, """
#include "flywheel:b.glsl"
""");
var aErr = assertErrorAndGetMessage(sources, FLW_A);
assertEquals("""
error: could not load shader due to errors in included files
--> flywheel:a.glsl
1 | #include "flywheel:b.glsl"
| ^^^^^^^^^^^^^^^
| error: could not load "flywheel:b.glsl" due to an IO error
| note: Mock source not found""", aErr.build());
}
}

View file

@ -0,0 +1,146 @@
package com.jozufozu.flywheel.glsl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import org.junit.jupiter.api.Test;
import com.google.common.collect.ImmutableList;
import com.jozufozu.flywheel.glsl.parse.Import;
public class TestShaderSourceLoading extends TestBase {
@Test
void testSimpleFind() {
var sources = new MockShaderSources();
sources.add(FLW_A, "");
SourceFile file = findAndAssertSuccess(sources, FLW_A);
assertEquals("", file.finalSource);
}
@Test
void testMissingFileAtRoot() {
var sources = new MockShaderSources();
findAndAssertError(LoadError.IOError.class, sources, FLW_A);
}
@Test
void testMissingInclude() {
var sources = new MockShaderSources();
sources.add(FLW_A, """
#include "flywheel:b.glsl"
""");
var aErr = findAndAssertError(LoadError.IncludeError.class, sources, FLW_A);
var ioErr = assertSimpleNestedErrorsToDepth(LoadError.IOError.class, aErr, 1);
assertEquals(FLW_B, ioErr.location());
}
@Test
void testBasicInclude() {
var sources = new MockShaderSources();
sources.add(FLW_A, """
#include "flywheel:b.glsl"
""");
sources.add(FLW_B, "");
SourceFile a = findAndAssertSuccess(sources, FLW_A);
sources.assertLoaded(FLW_B);
var includeB = assertSingletonList(a.imports);
assertEquals(FLW_B.toString(), includeB.file()
.toString());
assertEquals("""
""", a.finalSource, "Include statements should be elided.");
}
@Test
void testRedundantInclude() {
var sources = new MockShaderSources();
sources.add(FLW_A, """
#include "flywheel:b.glsl"
#include "flywheel:b.glsl"
""");
sources.add(FLW_B, "");
SourceFile a = findAndAssertSuccess(sources, FLW_A);
sources.assertLoaded(FLW_B);
assertEquals(2, a.imports.size());
for (Import include : a.imports) {
assertEquals(FLW_B.toString(), include.file()
.toString());
}
assertEquals("""
""", a.finalSource, "Both include statements should be elided.");
LoadResult bResult = sources.assertLoaded(FLW_B);
SourceFile b = assertSuccessAndUnwrap(FLW_B, bResult);
assertEquals(ImmutableList.of(b), a.included);
}
@Test
void testSelfInclude() {
var sources = new MockShaderSources();
sources.add(FLW_A, """
#include "flywheel:a.glsl"
""");
var aErr = findAndAssertError(LoadError.IncludeError.class, sources, FLW_A);
var shouldBeRecursiveIncludePair = assertSingletonList(aErr.innerErrors());
var circularDependency = assertInstanceOf(LoadError.CircularDependency.class, shouldBeRecursiveIncludePair.second());
assertEquals(ImmutableList.of(FLW_A, FLW_A), circularDependency.stack());
assertEquals(FLW_A, circularDependency.offender());
assertEquals(FLW_A.toString(), shouldBeRecursiveIncludePair.first()
.toString());
}
@Test
void test2LayerCircularDependency() {
var sources = new MockShaderSources();
sources.add(FLW_A, """
#include "flywheel:b.glsl"
""");
sources.add(FLW_B, """
#include "flywheel:a.glsl"
""");
var aErr = findAndAssertError(LoadError.IncludeError.class, sources, FLW_A);
sources.assertLoaded(FLW_B);
var recursiveInclude = assertSimpleNestedErrorsToDepth(LoadError.CircularDependency.class, aErr, 2);
assertEquals(ImmutableList.of(FLW_A, FLW_B, FLW_A), recursiveInclude.stack());
}
@Test
void test3LayerCircularDependency() {
var sources = new MockShaderSources();
sources.add(FLW_A, """
#include "flywheel:b.glsl"
""");
sources.add(FLW_B, """
#include "flywheel:c.glsl"
""");
sources.add(FLW_C, """
#include "flywheel:a.glsl"
""");
var aErr = findAndAssertError(LoadError.IncludeError.class, sources, FLW_A);
sources.assertLoaded(FLW_B);
sources.assertLoaded(FLW_C);
var recursiveInclude = assertSimpleNestedErrorsToDepth(LoadError.CircularDependency.class, aErr, 3);
assertEquals(ImmutableList.of(FLW_A, FLW_B, FLW_C, FLW_A), recursiveInclude.stack());
}
}