Voxel Array Texture implementation in Bevy 0.9

This is the current implementation for rendering chunks in Spacefarer v0.3.

It is a naive implementation (with inefficient data binding) but it works well and allows us to overlay voxels.

This makes it very easy to embed ore in the world – we don’t need to create a material for every combination of ore and underlying voxel, it’s done completely in the shader.

A limitation of the current implementation is no support for transparent / translucent materials. This is definitely something that we want to add, but it isn’t easy to implement and we don’t need it just yet.

An array texture is built for all different voxel materials – this allows us to sidestep texture atlas limitations (namely texture bleeding) and make each chunk only require one mesh.

Underlying materials and overlaid materials may have different PBR values like roughness, metallic, and reflectance.

Material

pub const ATTRIBUTE_BASE_VOXEL_INDICES: MeshVertexAttribute =
    MeshVertexAttribute::new("BaseVoxelIndices", 988540917, VertexFormat::Uint32);
pub const ATTRIBUTE_OVERLAY_VOXEL_INDICES: MeshVertexAttribute =
    MeshVertexAttribute::new("OverlayVoxelIndices", 593015852, VertexFormat::Uint32);

#[derive(AsBindGroup, Debug, Clone, TypeUuid)]
#[uuid = "f690fdae-d598-45ab-8225-97e2a3f056e0"]
pub struct ChunkMaterial {
    #[texture(0, dimension = "2d_array")]
    #[sampler(1)]
    pub texture: Handle<Image>,

    #[texture(2, dimension = "2d_array")]
    #[sampler(3)]
    pub pbr_texture: Handle<Image>,
}

impl Material for ChunkMaterial {
    fn vertex_shader() -> ShaderRef {
        "shaders/chunk.wgsl".into()
    }

    fn fragment_shader() -> ShaderRef {
        "shaders/chunk.wgsl".into()
    }

    fn alpha_mode(&self) -> AlphaMode {
        AlphaMode::Blend
    }

    fn specialize(
        _pipeline: &MaterialPipeline<Self>,
        descriptor: &mut RenderPipelineDescriptor,
        layout: &MeshVertexBufferLayout,
        _key: MaterialPipelineKey<Self>,
    ) -> Result<(), SpecializedMeshPipelineError> {
        let vertex_layout = layout.get_layout(&[
            Mesh::ATTRIBUTE_POSITION.at_shader_location(0),
            Mesh::ATTRIBUTE_NORMAL.at_shader_location(1),
            Mesh::ATTRIBUTE_UV_0.at_shader_location(2),
            //Mesh::ATTRIBUTE_TANGENT.at_shader_location(3),
            //Mesh::ATTRIBUTE_COLOR.at_shader_location(4),
            //Mesh::ATTRIBUTE_JOINT_INDEX.at_shader_location(5),
            //Mesh::ATTRIBUTE_JOINT_WEIGHT.at_shader_location(6),
            ATTRIBUTE_BASE_VOXEL_INDICES.at_shader_location(7),
            ATTRIBUTE_OVERLAY_VOXEL_INDICES.at_shader_location(8),
        ])?;
        descriptor.vertex.buffers = vec![vertex_layout];
        Ok(())
    }
}

Breakdown

This is mostly boilerplate, but there are a few sections that are worth looking at.

Custom Vertex Attributes

Code
pub const ATTRIBUTE_BASE_VOXEL_INDICES: MeshVertexAttribute =
    MeshVertexAttribute::new("BaseVoxelIndices", 988540917, VertexFormat::Uint32);
pub const ATTRIBUTE_OVERLAY_VOXEL_INDICES: MeshVertexAttribute =
    MeshVertexAttribute::new("OverlayVoxelIndices", 593015852, VertexFormat::Uint32);

A global registry of voxel material information (such as name, hardness, roughness, metallic, reflectance, etc) exists and is used to coordinate voxel rendering.

Before being sent to the shader, voxel materials are combined together to create a single, global array texture that contains all of the textures for the voxels in the world.

There is also a global PBR values array texture that keeps track of PBR information for each voxel material (roughness, metallic, reflectance).

This registry acts as an indexmap, keeping track of indices for materials.

When a chunk is sent for meshing, the indexmap is used to convert voxel IDs into indices, which is then added to the mesh via vertex attributes.

Even though we use greedy meshing to limit the amount of faces and vertices in the final mesh, each vertex in the mesh represents a different voxel in the chunk.

The shader can use these indices alongside the array texture to properly render the surface of a voxel.

BindGroup layout

Code
#[derive(AsBindGroup, Debug, Clone, TypeUuid)]
#[uuid = "f690fdae-d598-45ab-8225-97e2a3f056e0"]
pub struct ChunkMaterial {
    #[texture(0, dimension = "2d_array")]
    #[sampler(1)]
    pub texture: Handle<Image>,
    #[texture(2, dimension = "2d_array")]
    #[sampler(3)]
    pub pbr_texture: Handle<Image>,
}

We bind the global array texture at position 0, and its sampler at position 1.

A global array texture for PBR information (roughness, metallic, reflectance) is also bound, at position 2 with its sampler at position 3.

Registering attributes in the vertex layout

Code
            ATTRIBUTE_BASE_VOXEL_INDICES.at_shader_location(7),
            ATTRIBUTE_OVERLAY_VOXEL_INDICES.at_shader_location(8),

This is where our custom vertex attributes are registered in the material.

Shader

#import bevy_pbr::mesh_view_bindings
#import bevy_pbr::pbr_types
#import bevy_pbr::mesh_bindings

#import bevy_pbr::utils
#import bevy_pbr::clustered_forward
#import bevy_pbr::lighting
#import bevy_pbr::shadows
#import bevy_pbr::pbr_functions

@group(1) @binding(0)
var chunk_texture: texture_2d_array<f32>;

@group(1) @binding(1)
var chunk_sampler: sampler;

@group(1) @binding(2)
var pbr_texture: texture_2d_array<f32>;

@group(1) @binding(3)
var pbr_sampler: sampler;

// NOTE: Bindings must come before functions that use them!
#import bevy_pbr::mesh_functions

struct Vertex {
    @location(0) position: vec3<f32>,
    @location(1) normal: vec3<f32>,
#ifdef VERTEX_UVS
    @location(2) uv: vec2<f32>,
#endif
#ifdef VERTEX_TANGENTS
    @location(3) tangent: vec4<f32>,
#endif
#ifdef VERTEX_COLORS
    @location(4) color: vec4<f32>,
#endif
#ifdef SKINNED
    @location(5) joint_indices: vec4<u32>,
    @location(6) joint_weights: vec4<f32>,
#endif
    @location(7) base_indice: u32,
    @location(8) overlay_indice: u32,
};

struct VertexOutput {
    @builtin(position) clip_position: vec4<f32>,
    #import bevy_pbr::mesh_vertex_output
    
    @location(5) base_indice: u32,
    @location(6) overlay_indice: u32,
};

@vertex
fn vertex(vertex: Vertex) -> VertexOutput {
    var out: VertexOutput;
#ifdef SKINNED
    var model = skin_model(vertex.joint_indices, vertex.joint_weights);
    out.world_normal = skin_normals(model, vertex.normal);
#else
    var model = mesh.model;
    out.world_normal = mesh_normal_local_to_world(vertex.normal);
#endif
    out.world_position = mesh_position_local_to_world(model, vec4<f32>(vertex.position, 1.0));
#ifdef VERTEX_UVS
    out.uv = vertex.uv;
#endif
#ifdef VERTEX_TANGENTS
    out.world_tangent = mesh_tangent_local_to_world(model, vertex.tangent);
#endif
#ifdef VERTEX_COLORS
    out.color = vertex.color;
#endif
    out.clip_position = mesh_position_world_to_clip(out.world_position);

    out.base_indice = vertex.base_indice;
    out.overlay_indice = vertex.overlay_indice;

    return out;
}

struct FragmentInput {
    @builtin(front_facing) is_front: bool,
    @builtin(position) frag_coord: vec4<f32>,
    #import bevy_pbr::mesh_vertex_output
    
    @location(5) base_indice: u32,
    @location(6) overlay_indice: u32,
};

@fragment
fn fragment(in: FragmentInput) -> @location(0) vec4<f32> {
    var pbr_input: PbrInput = pbr_input_new();

    // base color with overlay

    let b = textureSample(chunk_texture, chunk_sampler, in.uv, i32(in.base_indice));
    let o = textureSample(chunk_texture, chunk_sampler, in.uv, i32(in.overlay_indice));

    let bg_r = b.r * b.a;
    let bg_g = b.g * b.a;
    let bg_b = b.b * b.a;

    let fg_r = o.r * o.a;
    let fg_g = o.g * o.a;
    let fg_b = o.b * o.a;

    let color_r = fg_r + bg_r * (1.0 - o.a);
    let color_g = fg_g + bg_g * (1.0 - o.a);
    let color_b = fg_b + bg_b * (1.0 - o.a);

    let color = vec4<f32>(color_r, color_g, color_b, 1.0);

    pbr_input.material.base_color = color;

    // pbr values with overlay

    let pbr_b = textureSample(pbr_texture, pbr_sampler, in.uv, i32(in.base_indice));
    let pbr_o = textureSample(pbr_texture, pbr_sampler, in.uv, i32(in.overlay_indice));

    let pbr_bg_r = pbr_b.r * b.a;
    let pbr_bg_g = pbr_b.g * b.a;
    let pbr_bg_b = pbr_b.b * b.a;

    let pbr_fg_r = pbr_o.r * o.a;
    let pbr_fg_g = pbr_o.g * o.a;
    let pbr_fg_b = pbr_o.b * o.a;

    let pbr_r = pbr_fg_r + pbr_bg_r * (1.0 - o.a);
    let pbr_g = pbr_fg_g + pbr_bg_g * (1.0 - o.a);
    let pbr_b = pbr_fg_b + pbr_bg_b * (1.0 - o.a);

    pbr_input.material.perceptual_roughness = pbr_r;
    pbr_input.material.metallic = pbr_g;
    pbr_input.material.reflectance = pbr_b;

#ifdef VERTEX_COLORS
    pbr_input.material.base_color = pbr_input.material.base_color * in.color;
#endif

    pbr_input.frag_coord = in.frag_coord;
    pbr_input.world_position = in.world_position;
    pbr_input.world_normal = prepare_world_normal(
        in.world_normal,
        false,
        in.is_front,
    );

    pbr_input.is_orthographic = view.projection[3].w == 1.0;

    pbr_input.N = apply_normal_mapping(
        pbr_input.material.flags,
        pbr_input.world_normal,
#ifdef VERTEX_TANGENTS
#ifdef STANDARDMATERIAL_NORMAL_MAP
        in.world_tangent,
#endif
#endif
#ifdef VERTEX_UVS
        in.uv,
#endif
    );
    pbr_input.V = calculate_view(in.world_position, pbr_input.is_orthographic);

    return pbr(pbr_input);
}

Breakdown

Vertex Shader

Code
    out.base_indice = vertex.base_indice;
    out.overlay_indice = vertex.overlay_indice;

The vertex shader is mostly boilerplate except for where we handle our custom array textures.

Fragment Shader

Array Texture Sampling
Code
@fragment
fn fragment(in: FragmentInput) -> @location(0) vec4<f32> {
    var pbr_input: PbrInput = pbr_input_new();
    // base color with overlay
    let b = textureSample(chunk_texture, chunk_sampler, in.uv, i32(in.base_indice));
    let o = textureSample(chunk_texture, chunk_sampler, in.uv, i32(in.overlay_indice));
    let bg_r = b.r * b.a;
    let bg_g = b.g * b.a;
    let bg_b = b.b * b.a;
    let fg_r = o.r * o.a;
    let fg_g = o.g * o.a;
    let fg_b = o.b * o.a;
    let color_r = fg_r + bg_r * (1.0 - o.a);
    let color_g = fg_g + bg_g * (1.0 - o.a);
    let color_b = fg_b + bg_b * (1.0 - o.a);
    let color = vec4<f32>(color_r, color_g, color_b, 1.0);
    pbr_input.material.base_color = color;
    // pbr values with overlay
    let pbr_b = textureSample(pbr_texture, pbr_sampler, in.uv, i32(in.base_indice));
    let pbr_o = textureSample(pbr_texture, pbr_sampler, in.uv, i32(in.overlay_indice));
    let pbr_bg_r = pbr_b.r * b.a;
    let pbr_bg_g = pbr_b.g * b.a;
    let pbr_bg_b = pbr_b.b * b.a;
    let pbr_fg_r = pbr_o.r * o.a;
    let pbr_fg_g = pbr_o.g * o.a;
    let pbr_fg_b = pbr_o.b * o.a;
    let pbr_r = pbr_fg_r + pbr_bg_r * (1.0 - o.a);
    let pbr_g = pbr_fg_g + pbr_bg_g * (1.0 - o.a);
    let pbr_b = pbr_fg_b + pbr_bg_b * (1.0 - o.a);

In this section of the fragment shader, we calculate the resulting color by sampling the array texture for both the underlying (base) voxel and overlay voxel, and combining them.

The color of the overlay texture will override the base color at full opacity, but alpha blending is performed in transparent areas.

We are also sampling the PBR array texture to get roughness, metallic, and reflectance information for the base and overlay voxel materials.

Note that the overlay material and base material may have different PBR values and this is properly taken into account when rendering the final output.

PBR Boilerplate
Code
    pbr_input.material.perceptual_roughness = pbr_r;
    pbr_input.material.metallic = pbr_g;
    pbr_input.material.reflectance = pbr_b;
#ifdef VERTEX_COLORS
    pbr_input.material.base_color = pbr_input.material.base_color * in.color;
#endif
    pbr_input.frag_coord = in.frag_coord;
    pbr_input.world_position = in.world_position;
    pbr_input.world_normal = prepare_world_normal(
        in.world_normal,
        false,
        in.is_front,
    );
    pbr_input.is_orthographic = view.projection[3].w == 1.0;
    pbr_input.N = apply_normal_mapping(
        pbr_input.material.flags,
        pbr_input.world_normal,
#ifdef VERTEX_TANGENTS
#ifdef STANDARDMATERIAL_NORMAL_MAP
        in.world_tangent,
#endif
#endif
#ifdef VERTEX_UVS
        in.uv,
#endif
    );
    pbr_input.V = calculate_view(in.world_position, pbr_input.is_orthographic);
    return pbr(pbr_input);
}

In the current version of Bevy, you must re-implement the PBR boilerplate in your own shader if you want to have the PBR effect.

This code takes the unshaded base color and the PBR values as calculated earlier and creates a realistic output based on scene lighting, shadows, and material properties.

Leave a Reply

Your email address will not be published. Required fields are marked *