Skip to main content

VertexNova GLSL Coding Guidelines

Version: 1.0
Last Updated: January 2026
GLSL Version: 4.50 (Vulkan)
Target Backends: Vulkan, Metal (MSL), OpenGL, OpenGL ES, WebGPU (future)

Introduction

These guidelines establish standards for writing GLSL shaders in the VertexNova engine. All shaders are authored in GLSL 4.50 (Vulkan-style) and cross-compiled to other backends using SPIRV-Cross.

Goals

  • Consistency: Uniform naming and structure across all shaders
  • Portability: Code that translates cleanly to all target backends
  • Readability: Easy to compare generated output across backends
  • Maintainability: Clear organization and documentation

Backend Targets

BackendGenerated FormatTool
VulkanSPIR-V (native)glslangValidator / shaderc
MetalMSLSPIRV-Cross
OpenGLGLSL 4.10+SPIRV-Cross
OpenGL ESESSL 3.00+SPIRV-Cross
WebGPUWGSLSPIRV-Cross (future)

File Organization

File Naming

Use snake_case for shader files (matching C++ convention):

basic_triangle.vert.glsl
basic_triangle.frag.glsl
pbr_lighting.vert.glsl
pbr_lighting.frag.glsl
compute_particles.comp.glsl
fullscreen_quad.vert.glsl
post_process_bloom.frag.glsl

File Extensions

StageExtension
Vertex.vert.glsl
Fragment.frag.glsl
Compute.comp.glsl
Geometry.geom.glsl
Tessellation Control.tesc.glsl
Tessellation Evaluation.tese.glsl

Shader Header

Every shader should begin with a standard header:

#version 450
/*
* Shader: basic_triangle.vert
* Description: Simple vertex shader for triangle rendering
* Author: VertexNova Team
*
* Inputs: position (vec3), color (vec3)
* Outputs: v_color (vec3)
* Uniforms: u_transform (set=0, binding=0)
*/

Naming Conventions

Summary Table

ConstructStylePrefixExample
Vertex inputssnake_casea_a_position, a_normal
Stage outputssnake_casev_v_color, v_tex_coord
Fragment outputssnake_caseo_o_color, o_bloom
Uniform blocksPascalCasenoneTransformData, LightData
Uniform block instancescamelCaseu_u_transform, u_light
Push constantsPascalCasenonePushConstants
Samplerssnake_cases_s_albedo, s_normal_map
Images (storage)snake_casei_i_output, i_input
Buffers (SSBO)PascalCasenoneVertexBuffer, ParticleData
Buffer instancescamelCaseb_b_vertices, b_particles
Global variablessnake_caseg_g_accumulated_light, g_total_shadow
Local variablessnake_casenonelight_dir, view_pos
ConstantsUPPER_CASEnonePI, MAX_LIGHTS
FunctionscamelCasenonecalculateLighting()
MacrosUPPER_CASEVNE_VNE_ENABLE_SHADOWS

Vertex Inputs (Attributes)

Use a_ prefix (attribute) with snake_case:

// Vertex inputs
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_tex_coord;
layout(location = 3) in vec4 a_color;
layout(location = 4) in vec4 a_tangent;
layout(location = 5) in ivec4 a_bone_ids;
layout(location = 6) in vec4 a_bone_weights;

Stage Outputs (Varyings)

Use v_ prefix (varying) with snake_case:

// Vertex shader outputs / Fragment shader inputs
layout(location = 0) out vec3 v_world_position;
layout(location = 1) out vec3 v_normal;
layout(location = 2) out vec2 v_tex_coord;
layout(location = 3) out vec4 v_color;
layout(location = 4) out vec3 v_tangent;
layout(location = 5) out vec3 v_bitangent;

Fragment Outputs

Use o_ prefix (output) with snake_case:

// Fragment outputs
layout(location = 0) out vec4 o_color;
layout(location = 1) out vec4 o_bloom;
layout(location = 2) out vec4 o_normal;
layout(location = 3) out vec4 o_velocity;

Uniform Blocks (UBOs)

Use PascalCase for block names, u_ prefix with camelCase for instances:

// Camera/View data - set 0 (per-frame)
layout(set = 0, binding = 0) uniform CameraData {
mat4 view;
mat4 projection;
mat4 view_projection;
vec3 camera_position;
float near_plane;
float far_plane;
} u_camera;

// Transform data - set 1 (per-object)
layout(set = 1, binding = 0) uniform TransformData {
mat4 model;
mat4 normal_matrix;
} u_transform;

// Material data - set 2 (per-material)
layout(set = 2, binding = 0) uniform MaterialData {
vec4 base_color;
float metallic;
float roughness;
float ao;
float emissive_strength;
} u_material;

Push Constants

Use PascalCase for the block, access members directly:

layout(push_constant) uniform PushConstants {
mat4 model;
uint material_index;
uint object_id;
} pc;

void main() {
mat4 mvp = u_camera.view_projection * pc.model;
// ...
}

Samplers

Use s_ prefix with snake_case:

// Texture samplers - set 2 (per-material)
layout(set = 2, binding = 1) uniform sampler2D s_albedo;
layout(set = 2, binding = 2) uniform sampler2D s_normal_map;
layout(set = 2, binding = 3) uniform sampler2D s_metallic_roughness;
layout(set = 2, binding = 4) uniform sampler2D s_ao;
layout(set = 2, binding = 5) uniform sampler2D s_emissive;
layout(set = 2, binding = 6) uniform samplerCube s_environment;
layout(set = 2, binding = 7) uniform sampler2D s_brdf_lut;

Storage Buffers (SSBOs)

Use PascalCase for block names, b_ prefix with camelCase for instances:

// Vertex buffer (read-only)
layout(std430, set = 0, binding = 1) readonly buffer VertexBuffer {
vec4 positions[];
} b_vertices;

// Particle buffer (read-write)
layout(std430, set = 0, binding = 2) buffer ParticleBuffer {
vec4 positions[];
vec4 velocities[];
float lifetimes[];
} b_particles;

// Indirect draw commands
layout(std430, set = 0, binding = 3) buffer DrawCommands {
uint vertex_count;
uint instance_count;
uint first_vertex;
uint first_instance;
} b_draw_cmd;

Storage Images

Use i_ prefix with snake_case:

// Storage images for compute shaders
layout(set = 0, binding = 0, rgba8) uniform readonly image2D i_input;
layout(set = 0, binding = 1, rgba8) uniform writeonly image2D i_output;
layout(set = 0, binding = 2, r32f) uniform image2D i_depth;

Local Variables

Use snake_case (matching C++ local variable convention):

void main() {
vec3 world_position = (u_transform.model * vec4(a_position, 1.0)).xyz;
vec3 view_direction = normalize(u_camera.camera_position - world_position);
vec3 light_direction = normalize(u_light.position - world_position);

float n_dot_l = max(dot(normal, light_direction), 0.0);
float attenuation = 1.0 / (distance * distance);

vec3 diffuse_color = base_color.rgb * n_dot_l;
vec3 specular_color = calculateSpecular(normal, view_direction, light_direction);

vec3 final_color = diffuse_color + specular_color;
o_color = vec4(final_color, 1.0);
}

Constants

Use UPPER_CASE for compile-time constants:

// Mathematical constants
const float PI = 3.14159265359;
const float TWO_PI = 6.28318530718;
const float HALF_PI = 1.57079632679;
const float INV_PI = 0.31830988618;

// Engine constants
const int MAX_LIGHTS = 16;
const int MAX_BONES = 256;
const int MAX_CASCADES = 4;
const float EPSILON = 0.0001;

// Material defaults
const float DEFAULT_ROUGHNESS = 0.5;
const float DEFAULT_METALLIC = 0.0;

Global Variables

Use g_ prefix with snake_case for file-scope variables shared across functions:

#version 450

// Constants
const float PI = 3.14159265359;
const int MAX_LIGHTS = 16;

// Global variables (shared across functions)
vec3 g_accumulated_light;
float g_total_shadow;
int g_visible_light_count;
vec3 g_world_position;
vec3 g_view_direction;

void accumulateLight(vec3 light_color, float intensity) {
g_accumulated_light += light_color * intensity;
g_visible_light_count++;
}

void calculateShadow(int light_index) {
float shadow = sampleShadowMap(light_index, g_world_position);
g_total_shadow *= shadow;
}

void main() {
// Initialize globals
g_accumulated_light = vec3(0.0);
g_total_shadow = 1.0;
g_visible_light_count = 0;
g_world_position = v_world_position;
g_view_direction = normalize(u_camera.position - g_world_position);

// Process lights
for (int i = 0; i < u_light_count; i++) {
accumulateLight(u_lights[i].color, u_lights[i].intensity);
calculateShadow(i);
}

vec3 final_color = g_accumulated_light * g_total_shadow;
o_color = vec4(final_color, 1.0);
}

Functions

Use camelCase for function names:

// Lighting functions
vec3 calculateDiffuse(vec3 albedo, vec3 light_color, float n_dot_l) {
return albedo * light_color * n_dot_l;
}

vec3 calculateSpecular(vec3 normal, vec3 view_dir, vec3 light_dir, float roughness) {
vec3 half_vector = normalize(view_dir + light_dir);
float n_dot_h = max(dot(normal, half_vector), 0.0);
float spec_power = (1.0 - roughness) * 128.0;
return vec3(pow(n_dot_h, spec_power));
}

float calculateAttenuation(float distance, float range) {
float attenuation = 1.0 / (distance * distance);
float falloff = saturate(1.0 - pow(distance / range, 4.0));
return attenuation * falloff * falloff;
}

// Utility functions
float saturate(float x) {
return clamp(x, 0.0, 1.0);
}

vec3 saturate(vec3 x) {
return clamp(x, vec3(0.0), vec3(1.0));
}

Layout Qualifiers

Descriptor Set Organization

Use a consistent set layout across all shaders:

SetPurposeUpdate Frequency
0Global/Frame dataPer frame
1Per-object dataPer draw call
2Per-material dataPer material
3Bindless/DynamicAs needed
// Set 0: Per-frame (camera, lights, time)
layout(set = 0, binding = 0) uniform CameraData { ... } u_camera;
layout(set = 0, binding = 1) uniform LightData { ... } u_lights;
layout(set = 0, binding = 2) uniform TimeData { ... } u_time;

// Set 1: Per-object (transform)
layout(set = 1, binding = 0) uniform TransformData { ... } u_transform;

// Set 2: Per-material (properties, textures)
layout(set = 2, binding = 0) uniform MaterialData { ... } u_material;
layout(set = 2, binding = 1) uniform sampler2D s_albedo;
layout(set = 2, binding = 2) uniform sampler2D s_normal_map;

Location Assignments

Use explicit locations for all inputs/outputs:

// Vertex inputs - consistent across all vertex shaders
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_tex_coord;
layout(location = 3) in vec4 a_tangent;
layout(location = 4) in vec4 a_color;

// Vertex outputs - match fragment inputs
layout(location = 0) out vec3 v_world_position;
layout(location = 1) out vec3 v_normal;
layout(location = 2) out vec2 v_tex_coord;

Standard Vertex Attribute Locations

Maintain consistent attribute locations:

LocationAttributeType
0positionvec3
1normalvec3
2tex_coordvec2
3tangentvec4
4colorvec4
5bone_idsivec4
6bone_weightsvec4

SPIRV-Cross Portability

Avoid Platform-Specific Features

Features that may not translate well:

// Avoid: Geometry shaders (limited Metal support)
// Avoid: Tessellation (complex translation)
// Avoid: gl_ClipDistance arrays (varies by backend)

// Prefer: Compute shaders for GPU work
// Prefer: Vertex pulling for complex vertex formats

Use Standard Types

Stick to types with good cross-platform support:

// Preferred types
float, vec2, vec3, vec4
int, ivec2, ivec3, ivec4
uint, uvec2, uvec3, uvec4
mat2, mat3, mat4

// Use with caution (check backend support)
double, dvec2, dvec3, dvec4 // Not all backends
int8_t, uint8_t // Extension required
float16_t // Extension required

Buffer Layout

Use std140 for uniform blocks (widest compatibility):

// std140 layout - predictable padding, works everywhere
layout(std140, set = 0, binding = 0) uniform CameraData {
mat4 view; // offset 0
mat4 projection; // offset 64
vec4 position; // offset 128 (vec3 padded to vec4)
float near_plane; // offset 144
float far_plane; // offset 148
vec2 _padding; // offset 152 (explicit padding)
} u_camera;

Use std430 for storage buffers:

// std430 layout - tighter packing, SSBOs only
layout(std430, set = 0, binding = 1) buffer VertexData {
vec4 positions[]; // No padding between elements
} b_vertices;

Explicit Padding

Add explicit padding for cross-platform consistency:

layout(std140, set = 0, binding = 0) uniform LightData {
vec3 position;
float _pad0; // Explicit padding
vec3 color;
float intensity;
vec3 direction;
float _pad1; // Explicit padding
int type;
float range;
float inner_cone;
float outer_cone;
} u_light;

Sampler Handling

Some backends combine textures and samplers differently:

// Preferred: Combined image sampler (works everywhere)
layout(set = 2, binding = 0) uniform sampler2D s_albedo;

// Alternative: Separate (more flexible, but check backend)
layout(set = 2, binding = 0) uniform texture2D t_albedo;
layout(set = 2, binding = 1) uniform sampler sampler_linear;
// Usage: texture(sampler2D(t_albedo, sampler_linear), uv)

Code Structure

Standard Vertex Shader Template

#version 450

// Inputs
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_tex_coord;

// Outputs
layout(location = 0) out vec3 v_world_position;
layout(location = 1) out vec3 v_normal;
layout(location = 2) out vec2 v_tex_coord;

// Uniforms
layout(set = 0, binding = 0) uniform CameraData {
mat4 view;
mat4 projection;
mat4 view_projection;
vec3 camera_position;
} u_camera;

layout(set = 1, binding = 0) uniform TransformData {
mat4 model;
mat4 normal_matrix;
} u_transform;

void main() {
vec4 world_pos = u_transform.model * vec4(a_position, 1.0);

v_world_position = world_pos.xyz;
v_normal = mat3(u_transform.normal_matrix) * a_normal;
v_tex_coord = a_tex_coord;

gl_Position = u_camera.view_projection * world_pos;
}

Standard Fragment Shader Template

#version 450

// Inputs
layout(location = 0) in vec3 v_world_position;
layout(location = 1) in vec3 v_normal;
layout(location = 2) in vec2 v_tex_coord;

// Outputs
layout(location = 0) out vec4 o_color;

// Uniforms
layout(set = 0, binding = 0) uniform CameraData {
mat4 view;
mat4 projection;
mat4 view_projection;
vec3 camera_position;
} u_camera;

layout(set = 2, binding = 0) uniform MaterialData {
vec4 base_color;
float metallic;
float roughness;
} u_material;

layout(set = 2, binding = 1) uniform sampler2D s_albedo;

void main() {
vec3 normal = normalize(v_normal);
vec4 albedo = texture(s_albedo, v_tex_coord) * u_material.base_color;

// Simple lighting
vec3 light_dir = normalize(vec3(1.0, 1.0, 1.0));
float n_dot_l = max(dot(normal, light_dir), 0.0);

vec3 ambient = albedo.rgb * 0.1;
vec3 diffuse = albedo.rgb * n_dot_l;

o_color = vec4(ambient + diffuse, albedo.a);
}

Standard Compute Shader Template

#version 450

layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in;

// Storage images
layout(set = 0, binding = 0, rgba8) uniform readonly image2D i_input;
layout(set = 0, binding = 1, rgba8) uniform writeonly image2D i_output;

// Uniforms
layout(set = 0, binding = 2) uniform ComputeParams {
ivec2 image_size;
float time;
} u_params;

void main() {
ivec2 pixel_coord = ivec2(gl_GlobalInvocationID.xy);

// Bounds check
if (pixel_coord.x >= u_params.image_size.x ||
pixel_coord.y >= u_params.image_size.y) {
return;
}

vec4 color = imageLoad(i_input, pixel_coord);

// Process color
color.rgb = pow(color.rgb, vec3(1.0 / 2.2)); // Gamma correction

imageStore(i_output, pixel_coord, color);
}

Common Patterns

Include System

Use preprocessor for shared code:

// common.glsl - shared definitions
#ifndef COMMON_GLSL
#define COMMON_GLSL

const float PI = 3.14159265359;
const float EPSILON = 0.0001;

float saturate(float x) {
return clamp(x, 0.0, 1.0);
}

vec3 saturate(vec3 x) {
return clamp(x, vec3(0.0), vec3(1.0));
}

#endif // COMMON_GLSL
// pbr_lighting.frag.glsl
#version 450
#include "common.glsl"
#include "pbr_functions.glsl"

void main() {
// ...
}

Feature Toggles

Use defines for optional features:

#version 450

// Feature defines (set by engine)
// #define VNE_ENABLE_NORMAL_MAPPING
// #define VNE_ENABLE_SHADOWS
// #define VNE_ENABLE_IBL

void main() {
vec3 normal = normalize(v_normal);

#ifdef VNE_ENABLE_NORMAL_MAPPING
vec3 tangent_normal = texture(s_normal_map, v_tex_coord).xyz * 2.0 - 1.0;
normal = normalize(tbn * tangent_normal);
#endif

vec3 lighting = calculateDirectLighting(normal);

#ifdef VNE_ENABLE_SHADOWS
float shadow = calculateShadow(v_world_position);
lighting *= shadow;
#endif

#ifdef VNE_ENABLE_IBL
vec3 ambient = calculateIBL(normal, view_dir, roughness);
lighting += ambient;
#endif

o_color = vec4(lighting, 1.0);
}

Multi-Output (MRT)

For deferred rendering or multiple render targets:

// G-Buffer output
layout(location = 0) out vec4 o_albedo; // RGB: albedo, A: alpha
layout(location = 1) out vec4 o_normal; // RGB: normal (encoded), A: unused
layout(location = 2) out vec4 o_material; // R: metallic, G: roughness, B: ao, A: unused
layout(location = 3) out vec4 o_emissive; // RGB: emissive, A: unused

void main() {
o_albedo = vec4(albedo, alpha);
o_normal = vec4(encodeNormal(normal), 0.0);
o_material = vec4(metallic, roughness, ao, 0.0);
o_emissive = vec4(emissive, 0.0);
}

Best Practices

Performance

  1. Minimize texture samples: Cache results, use mipmaps
  2. Avoid branching: Use step/mix instead when possible
  3. Use half precision: Where accuracy allows (mobile)
  4. Vectorize operations: Operate on vec4 when possible
  5. Early-out: Use discard sparingly (breaks early-z)
// Prefer: Branchless
float result = mix(value_a, value_b, step(threshold, x));

// Avoid: Branching (when possible)
float result = x > threshold ? value_b : value_a;

Precision

Specify precision hints for mobile/WebGL compatibility:

#version 450

// Precision hints (GLES/WebGL)
precision highp float;
precision highp int;
precision highp sampler2D;

// Or per-variable
mediump vec3 color;
lowp float alpha;

Documentation

Document complex shaders:

/**
* PBR Lighting Calculation
*
* Implements Cook-Torrance BRDF with GGX distribution
*
* @param normal Surface normal (world space, normalized)
* @param view_dir View direction (world space, normalized)
* @param light_dir Light direction (world space, normalized)
* @param albedo Base color
* @param metallic Metallic factor [0, 1]
* @param roughness Roughness factor [0, 1]
* @return Final lit color
*/
vec3 calculatePBR(
vec3 normal,
vec3 view_dir,
vec3 light_dir,
vec3 albedo,
float metallic,
float roughness
) {
// ...
}

Cross-Backend Comparison

When comparing generated shaders across backends, look for:

Naming Consistency

GLSL (Source)MSL (Metal)HLSL/WGSL
a_positiona_positiona_position
v_normalv_normalv_normal
u_camerau_camerau_camera
s_albedos_albedos_albedo

Semantic Mapping

SPIRV-Cross maps Vulkan GLSL to backend equivalents:

GLSLMetalNotes
gl_Positionout.positionVertex output
gl_FragCoordin.positionFragment input
gl_VertexIndexvertex_idVertex ID
gl_InstanceIndexinstance_idInstance ID
gl_GlobalInvocationIDthread_position_in_gridCompute

Code Review Checklist

Naming

  • Vertex inputs use a_ prefix
  • Stage outputs use v_ prefix
  • Fragment outputs use o_ prefix
  • Uniform blocks use PascalCase
  • Uniform instances use u_ prefix
  • Samplers use s_ prefix
  • Storage buffers use b_ prefix
  • Storage images use i_ prefix
  • Global variables use g_ prefix
  • Local variables use snake_case
  • Constants use UPPER_CASE
  • Functions use camelCase

Layout

  • Explicit locations on all inputs/outputs
  • Explicit set/binding on all resources
  • Consistent descriptor set organization
  • std140 for uniform blocks
  • std430 for storage buffers

Portability

  • No platform-specific extensions
  • Explicit padding in uniform blocks
  • Standard types only
  • Combined image samplers preferred

Performance

  • Minimal texture samples
  • Branchless code where possible
  • Proper precision hints
  • Efficient algorithms

References