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.