Byte Engine Docs

BESL language reference

Write portable Byte Engine shaders with BESL.

Use the Byte Engine Shader Language (BESL) to write shader source that works with the material pipeline, resource reflection, render-model generation, and platform shader generation.

BESL uses Rust-inspired syntax, but it is a separate language. Byte Engine parses the source, resolves it into a semantic graph, applies render-model transformations, and emits code for the active graphics backend.

Backend support differs

Byte Engine can generate GLSL, MSL, and HLSL, but some stages and features aren't available on every backend.

Create a BESL file

A BESL file contains declarations for structs, constants, resource bindings, interface inputs and outputs, helper functions, and a main function.

VertexInput: struct {
	position: vec3f,
	normal: vec3f,
	uv: vec2f,
}

albedo: descriptor<Texture2D, 0, read>;

uv: input vec2f location=0
color: output vec4f location=0

main: fn () -> void {
	let texel: vec4f = sample(albedo, uv);
	color = texel;
}

The parser records the source structure. The lexer then resolves names, types, members, intrinsics, and references.

Declare program elements

Structs

Struct declarations use Name: struct.

Light: struct {
	position: vec3f,
	color: vec3f,
	intensity: f32,
}

Members use name: type. Array members use the compact type form, such as u32[4].

Cluster: struct {
	light_indices: u32[64],
}

Constants

Declare module-level constants for values that are known at compile time.

MAX_LIGHTS: const u32 = 64
OFFSETS: const u32[4] = u32[4](0, 1, 2, 3)

Functions

Declare a function with name: fn.

mod_by_16: fn (x: i32) -> i32 {
	return x % 16;
}

You can pass, return, and index short scalar arrays with two to four elements. Use f32, u16, or u32 elements. BESL lowers these values to native vectors on backends that cannot return arrays directly.

triangle_indices: fn () -> u32[3] {
	return u32[3](4, 8, 15);
}

The main function is the stage entry point after render-model transformation.

main: fn () -> void {
	return;
}

Variables

Declare a local variable with let.

let color: vec4f = vec4f(1.0, 0.0, 1.0, 1.0);

Assignments, member access, and array indexing are expression forms:

output.color = material.color;
let first: u32 = indices[0];

Half-precision floats

Use f16, vec2f16, vec3f16, and vec4f16 when a value can tolerate 16-bit floating-point precision. These types help reduce tightly packed buffer storage and can reduce native shader register use. Arithmetic between matching half-precision values stays half precision.

Use explicit casts when values move between precisions:

let uv32: vec2f = vec2f(0.25, 0.75);
let uv16: vec2f16 = vec2f16(uv32);
let sampled_uv: vec2f = vec2f(uv16);
let weight16: f16 = f16(0.5);
let weight32: f32 = f32(weight16);

Texture intrinsics use vec2f coordinates. Keep UV calculations in vec2f16 when appropriate, then convert at the sampling boundary:

let color: vec4f = sample(albedo, vec2f(uv16));

Generated GLSL requires GL_EXT_shader_explicit_arithmetic_types_float16 when these types are used. Choose f16 only for targets that support native 16-bit floating-point arithmetic and for values that do not need f32 precision.

Control program flow

Use if, for, return, and continue to control program flow.

main: fn () -> void {
	for (let i: u32 = 0; i < 4; i = i + 1) {
		if (i >= 2) {
			continue;
		}
	}
}

Supported operators include:

  • arithmetic: +, -, *, /, %
  • shifts: <<, >>
  • bitwise: &, |
  • assignment: =
  • comparison: ==, !=, <, >, <=, >=
  • logical: &&, ||

Declare a shader interface

Give each input and output a location:

uv: input<vec2f, 0>;
color: output<vec4f, 0>;

Add a positive element count when a mesh stage emits per-primitive values:

out_instance_index: output<u32, 0, 126>;

Task and mesh stages can share payload arrays. Task and compute stages can also declare storage shared by every invocation in one workgroup:

visible_meshlets: task_payload<u32, 32>;
visible_count: workgroup<atomicu32>;
scratch: workgroup<f32, 64>;

Omit the count for one shared value. Add a positive count for shared arrays. Use workgroup_barrier() before reading values written by other invocations.

Use push constants for small uniform values outside normal descriptor bindings:

push_constant: push_constant {
	material_index: u32,
}

Declare resources with descriptors. Each descriptor has a flat resource slot, an access mode, and an optional positive array count:

MaterialData: struct {
	color: vec4f,
}

materials: descriptor<MaterialData, 0, read>;
output_image: descriptor<StorageImage<rgba16>, 1, write>;
texture_sampler: descriptor<Texture2D, 2, read>;
history: descriptor<StorageImage, 3, read_write>;
shadow_maps: descriptor<Texture2DArray, 4, read, 8>;

The resource type determines the reflected descriptor shape:

  • a previously declared struct is a storage buffer
  • Texture2D, Texture2DArray, and Texture3D are sampled textures
  • StorageImage is a load/store image; an optional nested format such as StorageImage<rgba16> declares its typed image format, while an omitted format remains backend-defined

Access is read, write, or read_write. Struct types must be declared before descriptors that reference them. Reflection retains authored names and flat slots for descriptors reachable from main.

Storage-buffer counters use atomicu32 members with atomic_add, atomic_load, and atomic_store. Integer storage images can be read as scalars with image_load_u32:

Counters: struct {
	values: atomicu32[1024],
}

counters: descriptor<Counters, 3, read_write>;
index_image: descriptor<StorageImage<r32ui>, 4, read>;

Standalone .besl assets also require an adjacent .besl.bead file. A compute sidecar supplies its stage and workgroup:

{ "stage": "Compute", "workgroup": [8, 8, 1] }

Task and mesh sidecars additionally supply the limits required by their native pipeline contracts:

{ "stage": "Task", "workgroup": [32, 1, 1], "maximum_mesh_threadgroups": 32 }
{ "stage": "Mesh", "workgroup": [128, 1, 1], "maximum_vertices": 64, "maximum_primitives": 126 }

Vertex and Fragment stages omit workgroup. Task-payload and task-dispatch lowering is currently implemented for Metal; the same BESL remains the sole authored source.

Add raw backend code

BESL can include raw GLSL, HLSL, or MSL snippets with declared input and output dependencies.

Use raw code only as a bridge for unsupported features. Prefer BESL so the semantic graph, binding analysis, and backend emitters can understand the program.

Use intrinsics

Intrinsics are built-in calls that Byte Engine lowers to platform code. Use them for math, texture, image, compute, atomic, and mesh-stage operations.

Task stages use thread_position, thread_idx, workgroup_barrier, and set_task_mesh_output_count. Compute stages use thread_id, thread_idx, threadgroup_position, and workgroup_barrier. Mesh stages use threadgroup_position, thread_idx, set_mesh_output_counts, set_mesh_vertex_position, and set_mesh_triangle.

Math

Scalar and vector math intrinsics include:

  • min
  • max
  • clamp
  • log2
  • pow
  • abs
  • sqrt
  • exp
  • sin
  • cos
  • tan
  • round
  • fract
  • fwidth
  • step
  • smoothstep
  • mix
  • radians
  • inversesqrt
  • dot
  • cross
  • normalize
  • reflect
  • length
let n: vec3f = normalize(normal);
let light: f32 = max(dot(n, direction), 0.0);
let rough: f32 = clamp(roughness, 0.04, 1.0);

Constructors and casts

Vector, matrix, scalar, and array constructors are parsed as calls. The backend generators also special-case casts such as f32(...) and u32(...).

let v: vec3f = vec3f(1.0, 0.0, 0.0);
let i: u32 = u32(4);

Texture and image access

Texture intrinsics include:

  • sample(texture, uv)
  • sample_normal(texture, uv)
  • texture_lod(texture, uv)
  • fetch(texture, coord)
  • fetch_u32(texture, coord)
  • texture_size(texture)

Image intrinsics include:

  • image_load(image, coord)
  • image_load_u32(image, coord)
  • image_size(image)
  • write(image, coord, value)
  • guard_image_bounds(image, coord)
let color: vec4f = sample(texture_sampler, uv);
let texel: vec4f = fetch(texture, coord);

guard_image_bounds(output_image, coord);
write(output_image, coord, color);

guard_image_bounds emits an early return when the coordinate is outside the image. It is useful in compute-style shaders that write to storage images.

Atomics

Atomic intrinsics include:

  • atomic_add(pointer_or_location, value)
  • atomic_load(pointer_or_location)
  • atomic_store(pointer_or_location, value)
  • image_atomic_or(image, coord, value)

Backend support differs. For example, image atomics have explicit lowering in GLSL and HLSL paths, while other atomic forms depend on the target resource representation.

Compute and mesh stage helpers

Compute helpers expose invocation identity:

  • thread_id()
  • thread_idx()
  • threadgroup_position()

thread_idx() returns the linear index inside the local workgroup, including multidimensional workgroups. Call workgroup_barrier() to synchronize shared workgroup reads and writes in task and compute stages.

Mesh-stage helpers include:

  • set_mesh_output_counts(vertex_count, primitive_count)
  • set_mesh_vertex_position(vertex_index, position)
  • set_mesh_triangle(primitive_index, indices)

These are stage-specific. They only make sense when the shader generator is producing the matching stage and backend support exists.

Understand program evaluation

The resource-management shader layer evaluates BESL programs to extract metadata.

Current evaluation includes:

  • used descriptors, with authored name, flat slot, resource shape, count, and read/write flags
  • rough opacity classification for material decisions

Express resources and outputs in BESL instead of hiding them in raw code. When the graph can see a declaration, Byte Engine can reflect, validate, and optimize it.

Follow these guidelines

Keep handwritten BESL small and focused on one domain. Put standalone shader resources beside the code that consumes them. Render-model generators can add repeated material bindings, push constants, and platform details.

Prefer explicit types. Clear declarations are easier to diagnose than inference-like patterns while the parser and lexer continue to mature.

Use raw backend code only when the normal BESL path cannot express what you need yet. Raw snippets are useful, but they reduce what the engine can understand about the shader.

Rust API

On this page