Skip to content

Pipelines and Materials

David Dubois edited this page Sep 7, 2018 · 5 revisions

Pipelines and Materials

Nova has Pipelines and Materials. A Pipeline describes the rasterization state to use for a set of materials, while a Material describes the uniforms and framebuffer attachments needed to render a group of objects.

Pipeline

A Pipeline described rasterization state. Pipelines are based off of the Bedrock engine's States, but Nova adds a number of attributes for laying out shader inputs and assigning a pipeline to a pass.

Pipelines live in the folder pipelines in the root of your shaderpack. They have the .pipeline extension, which is different from Bedrock which uses .material for a similar file

One important thing about pipelines is that you can have multiple pipelines per file, and pipelines in one file can extend another pipeline in that file. We'll look at some examples, but this feature lets you re-use a pipeline to build a more complex pipeline. It's a good thing, I promise.

Let's look at some examples. These examples are part of the forward with depth pre-pass render graph we built in the render graph page. Reading that page before this one is recommended.

First, the depth pre-pass (filename pipelines/DepthPrePass.pipeline):

[
    {
        "name": "DepthPrePass",
        "states": [
            "DisableColorWrite",
            "DisableAlphaWrite"
        ],

        "vertexShader": "shaders/OnlyTransformPositions",
        "fragmentShader": "shaders/DepthPrePass",

        "vertexFields": [
            {"field": "Position"}
        ]
    }
]

As you can see, a .pipeline file contains an array of JSON sobject. Each element of that JSON array is a different pipeline. This file only has one pipeline, DepthPrePass, but we'll see a file with multiple pipelines pretty soon.

States

In Bedrock (and also in Nova), you can declare a bunch of "states" (this is a bad name IMO but Bedrock uses it). Each state turns on or off a feature. Here's all the states that Nova allows:

State name Description
Blending Enables blending. Blending parameters are controlled by various attributes in a pipeline, which we will see shortly
InvertCulling Culls the front faces instead of the back faces
DisableCulling Turns off face culling entirely. Can be useful for blocks that aren't fully opaque, like leaves or glass
DisableDepthWrite This pipeline won't write to the depth buffer
DisableDepthTest This pipeline won't perform a depth test
EnableStencilTest Perform the stencil test. Stencil test parameters are controlled by a few of the attributes in a pipeline. This tutorial won't cover them but the advanced documentation does
StencilWrite This pipeline writes to the stencil buffer. Stencil buffer writing parameters are controlled by a few of the attributes in a pipeline. This tutorial won't cover them but the advanced documentation does
DisableColorWrite Disables writing colors
EnableAlphaToCoverage Uses the alpha value of a fragment as its coverage value for a MSAA resolve. This can greatly improve the quality of foliage, especially when the foliage has complex geometry
DisableAlphaWrite Disables writing alphas

The states that are available all change the pipeline from its default state... meaning that if you don't specify any states, Nova will assume you want your pipeline to do a few things. Specifically, a pipeline that defines no states will:

  • Write colors, alphas, and depth value
  • Cull backfaces
  • Not perform any blending
  • Not write to the stencil buffer, nor perform any stencil tests
  • Not use alpha-to-coverage

Vertex and fragment (and maybe more!) shaders

A pipeline has two to five shaders. Each pipeline is required to have a vertex and fragment shader, although these are allowed to come from a parent pipeline. It may optionally have a geometry shader, and/or a tessellation evaluation and tessellation control shader

Shaders are specified by their file path, which is relative to the root of your shaderpack. Attempting to load a shader from outside your shaderpack will result in an error.

You shouldn't include the file extension when writing the filepath to your shaders. The reason comes from Bedrock: in Bedrock you write shaders in HLSL for Windows 10 and XBox, GLSL ES for Android, or the Nintendo Switch's shading language for Switch. Bedrock lets you write one pipeline for all of those platforms, meaning that the pipeline can't have anything platform-specific. You can write your shaders in each language, put them all in a single shaderpack, and the Bedrock engine will select the correct files at runtime. Nova works a little differently: it defines a hierarchy of shading languages that is tries to load. First it tries SPIR-V, then GLSL, then HLSL, then GLSL ES. Not writing down the file extension allows Nova to add in its own file extensions as it tries to load each of the supported shading languages.

Vertex Fields

The vertex fields array defines how your vertex data is laid out. This layout MUST match your shader's vertex input, or things won't work right. A number of vertex fields are available to you:

Vertex field name GLSL data type HLSL data type Description
Position vec3 float3 The modelspace position of the current vertex
Color vec4 float4 The color of the vertex. Used for Minecraft's AO and biome colors
UV0 vec2 float2 The main texture coordinate, for use with the color, normal, and data textures
UV1 vec2 float2 The coordinate into the lightmap. The first component is the block light, the second is the sky light
Normal vec3 float3 The modelspace normal of the current vertex
Tangent vec3 float3 The modelspace tangent of the current vertex
MidTexCoord vec2 float2 The texture coordinate at the center of the face being processed. Useful for POM and other effects that rely on knowing the entire texture
VirtualTextureId int int The ID of the current triangle's texture. This is used in Nova's virtual texture gather pass and during shading to index into an array of texture locations in the virtual texture atlas. Nova provides a number of helper macros to hide many of the detains of its virtual texture system, which will be explained in a later tutorial
McEntityId vec4 float4 Data about the block or entity being rendered. If no block or entity is being rendered, this is all 0 in the shader
Empty n/a n/a Tells Nova to not bind anything to a vertex attribute

That's pretty much all we need for a depth pre-pass. Since we only write depth, we can disable color and alpha writes. We use the depthPrePass shaders, which will do little more than transform vertex positions into clip space and output depth. We only need the position of each vertex, so we request the position for vertex attribute 0.

Next, let's look at the forward shading pipelines. We're going to put them into a new file, pipelines/Forward.pipeline, to keep them separate.

[
    {
        "name": "ForwardUntexturedUnlit",

        "pass": "DepthPrePass",

        "states": [
            "DisableDepthWrite"
        ],

        "vertexShader": "shaders/OnlyTransformPositions",
        "fragmentShader": "shaders/ForwardUntexturedUnlit",

        "vertexFields": [
            {"field": "Position"}
        ]
    }
]

This pipeline is super similar to the depth pre-pass pipeline, with the only difference being in the states. While the depth pre-pass didn't write to color or alpha but did write to depth, all the forward pipelines will write to color and alpha, but not depth.

Note that the ForwardUntexturedUnlit pipeline uses the same vertex shader as the DepthPrePass pipeline. Shaders can (and should!) be shared between pipelines if different pipelines need the same functionality

What about the next pipeline file?

[
    {
        "name": "ForwardUntexturedUnlit",

        "pass": "Forward",

        "states": [
            "DisableDepthWrite"
        ],

        "vertexShader": "shaders/UvPassthrough",
        "fragmentShader": "shaders/ForwardUntexturedUnlit",

        "vertexFields": [
            {"field": "Position"}
        ]
    },
    {
        "name": "ForwardTexturedUnlit",
        "interitsFrom": "ForwardUntexturedUnlit",

        "fragmentShader": "shaders/ForwardTexturedUnlint",

        "+vertexFields": [
            {"field": "UV0"}
        ]
    }
]

So many new things!

Pipeline inheritance

A pipeline can extend, or inherit from, another pipeline. This is accomplished with the inheritsFrom field, which contains the name of the pipeline that you're inheriting from.

Fields in a pipeline that is inherited from are present in the pipeline that does the inheriting, unless the inheriting pipeline overwrites them. The ForwardTexturedUnlit pipeline has the same states ss the ForwardUntexturedUnlit pipeline, but it uses different vertex and fragment shaders.

Array appending

The ForwardTexturedUnlit pipeline adds on to the vertex fields of the ForwardUntexturedUnlit pipeline. Note the + at the front of the name +vertexfields - this tells Nova to append whatever's in the vertexFields array in this pipeline to the vertexFields array in the parent pipeline. In this way you don't have to lay out your entire vertex data for each pipeline (although you could if you wanted to).

The ForwardTexturedUnlit pipeline wil receive the Position vertex attribute in attribute slot 0, and the UV0 vertex attribute in attribute slot 1.

Pipeline layout

Each pipeline has a number of shaders, these shaders have a number of descriptor layouts (uniforms in OpenGL terms). All these descriptors together form the pipeline layout, which is all the descriptor layouts that a pipeline can take as input. The pipeline layout serves as the interface to the shader. Materials can attach (bind) data (descriptors) to this interface (layout).

Vulkan has a maximum number of bound descriptors that you can have at a given time. This number is often quite low - 8 on Nvidia GPUs! To get around this, descriptors are grouped together into a descriptor set and bound as a single unit. How you group your descriptors can have a significant impact on performance, so be careful with how you group them.

In general, you want to group things based on the frequency of binding. For example, you'd put all the data the only changes once per frame in one set, then maybe have another set for data that changes with each drawn object, and possibly another set that has data that's updated once and then left forever. As always, we'll look at an example.

Consider the following block of GLSL:

layout(set = 1, binding = 0) uniform sampler2D colortex;
layout(set = 1, binding = 1) uniform sampler2D normaltex;
layout(set = 1, binding = 2) uniform sampler2D datatex;
layout(set = 1, binding = 3) uniform sampler2D lightmap;

layout(set = 0, binding = 0) uniform per_frame_uniforms {
    mat4 gbufferModelView;
    mat4 gbufferProjection;
};

A shader with this code declared two descriptor sets. The first, set 0, contains a uniform buffer with the modelview and projection matrices. The second contains four textures.

Note that each descriptor has a unique combination of set and binding. Vulkan identifies descriptors be the conbination of their set and binding, and declaring multiple descriptors with the same set and binding will result in an error.

Note also that we don't skip any sets - we use set 0 and 1 here. The descriptor sets in all of the shaders in a pipeline must be sequential and you must use set 0. You can have sets 0 and 2 in the fragment shader, and 0 and 1 in the vertex shader, and that's fine. However, if you skip a set, Nova will yell at you and won't compile your pipeline.

One important rule with descriptor sets as Nova uses them is that a given set NUST be the same across all shader files attached to a given pipeline (but you don't have to use every set in every shader). If the block of GLSL above was in a fragment shader, and the vertex shader wanted to use set the color texture, you MUST have the exact same set in both the vertex and the fragment shader. If you don't then Nova won't know what to do and things won't work as you expect.

That's kinda a lot of rules about how you need to arrange your descriptor sets. If you use either the Bedrock or the Optifine default render graphs, you won't have to worry about descriptor sets - but you're limited to what those render graphs give you. If you want control over everything your shaderpack does, you have to work for it.

Materials

Given that information about a pipeline layout, you're probably wondering how to bind data to a pipeline layout. Materials, that's how!

A material tells Nova what to bind to each variable in a pipeline layout. Materials use the string name of decsriptors - comtinuing from the above example, your material would contain an entry for colortex, normaltex, datatex, lightmap, and per_frame_uniforms.

Materials hae a number of passes. A material pass is different from a render pass - even though they both have the word "pass".

Here's an example material:

{
    "passes": [
        {
            "pipeline": "DepthPrePass",
            "bindings": {
                "per_frame_uniforms": "NovaPerFrameUBO"
            }
        },
        {
            "pipeline": "ForwardTexturedUnlit",
            "bindings": {
                "per_frame_uniforms": "NovaPerFrameUBO",
                "colortex": "NovaColorAtlas"
            }
        }
    ],
    "filter": "geometry_type::particle"
}

Pretty simple - this material renders its objects twice, once for the depth pre pass and once for the forward pass. The order of the passes in the material is a hint to Nova - if the render graph says to draw the passes in a separate order than you've specified then it will, but if you have multiple passes for the same pipeline then they'll be drawn in the order they're specified in the file.

The name of a material is the filename of the materials. If Particles.mat is the filename, then it defines a material called Particles.

The last part of a material is the filter. This tells Nova which objects should be drawn by this material. You can find a full write on Nova's filters here, but for the purposes of this material know that we're rendering all the particles.

There's one rule to how you can bind things: NovaPerModelUBO MUST be bound to a descriptor set that only has one binding.

You can’t perform that action at this time.