Modules
Reusable code organization with modules for sharing functions, structs, and constants across shaders.
Pressure-test the syntax
Take the concept from this page into the playground and deliberately break a pass, binding, or type signature to see how the compiler responds.
Try a Live EditModules in BWSL provide a way to organize and reuse code across multiple shaders. They allow you to define functions, structs, constants, and enums that can be imported into pipelines or other modules.
Modules are file-scope declarations. A source file can contain only modules, only pipelines, or a mix of file-scope modules and pipelines. A module declaration is not valid inside a pipeline block.
Defining a Module
A module is declared with the module keyword followed by the module name:
module Math {
const float PI = 3.14159265358979323846;
const float TAU = 6.28318530717958647692;
square :: (float x) -> float {
return x * x;
}
}
File-Scope Modules
Modules can live in their own .bwsl files or next to pipelines in the same source file:
module LocalMath {
scale :: (float x) -> float {
return x * 2.0;
}
}
pipeline UsesLocalModule {
import LocalMath
pass "Main" {
compute "Main" [1, 1, 1] {
float value = LocalMath::scale(4.0);
uint idx = input.global_id.x;
}
}
}
File-scope modules are registered before pipelines are parsed, so a pipeline can import a module declared later in the same file:
pipeline FileScopeModuleAfterPipeline {
import LateHelpers
pass "Main" {
compute "Main" [1, 1, 1] {
float value = LateHelpers::scale(2.0);
uint idx = input.global_id.x;
}
}
}
module LateHelpers {
scale :: (float x) -> float {
return x * 2.0;
}
}
Submodules
Submodules let a separate declaration extend an existing module namespace:
module Lighting {
const float PI = 3.14159265359;
lambert :: (float3 n, float3 l) -> float {
return saturate(dot(n, l));
}
}
submodule LightingBRDF extends Lighting {
wrappedLambert :: (float3 n, float3 l, float wrap) -> float {
return saturate((dot(n, l) + wrap) / (1.0 + wrap));
}
}
Import the parent module, then access both parent and submodule declarations through the parent name:
pipeline UsesLighting {
import Lighting
pass "Main" {
compute "Main" [1, 1, 1] {
float ndotl = Lighting::wrappedLambert(float3(0.0, 1.0, 0.0), float3(0.0, 1.0, 0.0), 0.35);
uint idx = input.global_id.x;
}
}
}
Inside a submodule body, declarations already in the parent module are available unqualified:
submodule LightingDebug extends Lighting {
debugDiffuse :: (float3 n, float3 l) -> float {
return lambert(n, l);
}
}
Submodules can live in the same file as the parent module, or in separate .bwsl files found through the same module search roots as normal modules. When the parent module is loaded, the compiler scans those roots for submodule ... extends ParentName declarations and folds them into the parent.
Rules:
submoduledeclarations are file-scope only- the parent module must exist or be resolvable
- import the parent module, not the submodule name
- a submodule name cannot conflict with an existing module name
- duplicate submodule declarations are rejected
Submodule Names Are Not Imports
A declaration like submodule LightingBRDF extends Lighting contributes members to Lighting. Code should import Lighting; import LightingBRDF is rejected because LightingBRDF is not a standalone module namespace.
Importing Modules
To use a module in a pipeline or another module, use the import statement:
pipeline MyPipeline {
import Math
pass "MainPass" {
vertex {
output.position = float4(0.0, 0.0, 0.0, 1.0);
}
fragment {
float x = 0.5;
float result = Math::square(x);
output.color = float4(result, result, result, 1.0);
}
}
}
Multiple imports can be comma-separated:
import Math, Noise
Embedded Standard Modules
The compiler embeds the standard library modules, so imports such as Math, Random, Noise, Color, Compression, Debug, Packing, PBR, Globals, PostFX, Sampling, SDF, and Spaces work without adding a -modules search path:
pipeline UsesStdlib {
import Math, Debug
pass "Main" {
compute "Main" [1, 1, 1] {
float value = Math::safe_rcp(2.0);
float3 color = Debug::heatmap(value);
}
}
}
Project modules still come from the input file's directory or paths passed with -modules. Standard-library module names are reserved by the embedded library; a disk file named Math.bwsl, for example, cannot override import Math.
Project module names must be unique within a compilation unit. Declaring the same module name twice is a diagnostic instead of a last-one-wins override.
Import Aliases
Use as when a module name should be shorter locally or when two modules would otherwise be awkward to distinguish:
pipeline MaterialPreview {
import PBR as BRDF
shade :: (BRDF::PBRMaterial mat, float3 normal) -> float3 {
return mat.albedo * saturate(normal.y);
}
}
After an alias is declared, module-qualified access can use the alias:
float3 lit = BRDF::calculateDirectLighting(mat, N, V, L, radiance);
Aliases are scoped to the pipeline or module that declares them. An alias declared inside one module does not leak into an importing pipeline.
Accessing Module Members
Module members are accessed using the :: (scope resolution) operator:
float angle = Math::PI * 0.5;
float doubled = Math::square(value);
Using Declarations
using ModuleName makes an already imported module available for unqualified function and constant lookup:
pipeline UtilityPass {
import Math as M
using M
pass "Main" {
compute "Main" [1, 1, 1] {
float wave = triangle_wave(0.25);
float angle = PI * wave;
}
}
}
using does not import a module by itself. The module must already be imported in the same scope, either by its real name or by an alias.
Use unqualified lookup sparingly for modules whose names are part of the surrounding convention. Module-qualified calls remain clearer when several libraries export similar helper names.
Type Aliases
using Alias = Type creates a scoped type alias:
pipeline MaterialDemo {
import PBR as BRDF
using Material = BRDF::PBRMaterial
shade :: (Material mat, float3 normal) -> float3 {
return mat.albedo * saturate(normal.y);
}
}
Type aliases can target built-in types, local custom types, or module-qualified custom types. They do not create a new nominal type; they only give an existing type another name in the current scope.
Module Contents
Modules can contain:
- Imports: Dependencies on other modules
- Using declarations: Module unqualified lookup or scoped type aliases
- Constants: Compile-time constant values
- Functions: Reusable shader functions
- Structs: Custom data types
- Enums: Named enum and payload enum types
Constants
module Math {
const float PI = 3.14159265358979323846;
const float TAU = 6.28318530717958647692;
const float E = 2.71828182845904523536;
const float PHI = 1.61803398874989484820;
const float EPSILON = 1e-6;
const float SQRT_2 = 1.41421356237309504880;
}
Functions
Functions use the :: syntax for declaration:
module Math {
square :: (float x) -> float {
return x * x;
}
cube :: (float x) -> float {
return x * x * x;
}
// Function overloading is supported
square :: (float3 x) -> float3 {
return x * x;
}
}
Structs
module PBR {
struct PBRMaterial {
float3 albedo;
float roughness;
float metallic;
float ao;
};
}
Module-defined structs can be used as ordinary shader types from imported modules, including as resource payload types. In a resources {} block, spell module-defined resource types with member access syntax:
module RenderTypes {
struct FrameData {
float4 scaleOffset;
};
}
pipeline UsesModuleResource {
import RenderTypes
attributes {
position: float3
}
resources {
frame: RenderTypes.FrameData
}
pass "Main" {
use attributes { position }
use resources { frame }
vertex {
float2 p = attributes.position.xy * resources.frame.scaleOffset.xy;
p += resources.frame.scaleOffset.zw;
output.position = float4(p, attributes.position.z, 1.0);
}
fragment {
output.color = float4(1.0);
}
}
}
Use Module::Type or an alias-derived type alias in expression and function type positions, and Module.Type when declaring a module-defined resource payload.
Module Dependencies
Modules can import other modules:
module PBR {
import Math
// Fresnel-Schlick Approximation
fresnelSchlick :: (float cosTheta, float3 F0) -> float3 {
return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}
// Lambertian Diffuse uses Math::PI
lambertianDiffuse :: (float3 albedo) -> float3 {
return albedo / Math::PI;
}
}
Imports, aliases, and using declarations inside a module stay local to that module.
Example: Math Module
Here's a comprehensive Math module with common utilities:
module Math {
// Constants
const float PI = 3.14159265358979323846;
const float TAU = 6.28318530717958647692;
const float E = 2.71828182845904523536;
const float EPSILON = 1e-6;
const float SQRT_2 = 1.41421356237309504880;
const float INV_PI = 0.31830988618379067154;
// Range Mapping
inverse_lerp :: (float a, float b, float value) -> float {
return (value - a) / (b - a);
}
remap :: (float value, float in_min, float in_max, float out_min, float out_max) -> float {
float t = (value - in_min) / (in_max - in_min);
return out_min + t * (out_max - out_min);
}
// Safe Operations
safe_divide :: (float a, float b) -> float {
if (abs(b) > EPSILON) {
return a / b;
}
return 0.0;
}
safe_normalize :: (float3 v) -> float3 {
float len_sq = dot(v, v);
if (len_sq > EPSILON * EPSILON) {
return v * rsqrt(len_sq);
}
return float3(0.0, 1.0, 0.0);
}
// Power Shortcuts
square :: (float x) -> float {
return x * x;
}
cube :: (float x) -> float {
return x * x * x;
}
// 2D Transformations
rotate_2d :: (float2 p, float angle) -> float2 {
float c = cos(angle);
float s = sin(angle);
return float2(p.x * c - p.y * s, p.x * s + p.y * c);
}
// Step Functions
linear_step :: (float edge0, float edge1, float x) -> float {
return saturate((x - edge0) / (edge1 - edge0));
}
// Perlin's improved smoothstep (quintic)
smootherstep :: (float edge0, float edge1, float x) -> float {
float t = saturate((x - edge0) / (edge1 - edge0));
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}
// Easing Functions
ease_in_quad :: (float t) -> float {
return t * t;
}
ease_out_quad :: (float t) -> float {
return t * (2.0 - t);
}
ease_in_out_quad :: (float t) -> float {
if (t < 0.5) {
return 2.0 * t * t;
}
return 1.0 - 2.0 * (1.0 - t) * (1.0 - t);
}
// Wave Functions
triangle_wave :: (float x) -> float {
return abs(fract(x) * 2.0 - 1.0);
}
}
Example: PBR Module
A module for physically-based rendering calculations:
module PBR {
import Math
struct PBRMaterial {
float3 albedo;
float roughness;
float metallic;
float ao;
};
// Fresnel-Schlick Approximation
fresnelSchlick :: (float cosTheta, float3 F0) -> float3 {
return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}
// GGX/Trowbridge-Reitz Normal Distribution Function
distributionGGX :: (float NdotH, float roughness) -> float {
float alpha = roughness * roughness;
float alphaSqr = alpha * alpha;
float denom = NdotH * NdotH * (alphaSqr - 1.0) + 1.0;
return alphaSqr / (Math::PI * denom * denom);
}
// Smith's Geometry Function (Schlick-GGX)
geometrySchlickGGX :: (float NdotV, float roughness) -> float {
float k = (roughness + 1.0) * (roughness + 1.0) / 8.0;
return NdotV / (NdotV * (1.0 - k) + k);
}
geometrySmith :: (float NdotV, float NdotL, float roughness) -> float {
float ggx1 = geometrySchlickGGX(NdotV, roughness);
float ggx2 = geometrySchlickGGX(NdotL, roughness);
return ggx1 * ggx2;
}
// Lambertian Diffuse
lambertianDiffuse :: (float3 albedo) -> float3 {
return albedo / Math::PI;
}
// Complete Cook-Torrance Specular BRDF
cookTorranceSpecular :: (float3 N, float3 V, float3 L, float3 F, float roughness) -> float3 {
float3 H = normalize(V + L);
float NdotH = max(dot(N, H), 0.0);
float NdotV = max(dot(N, V), 0.0);
float NdotL = max(dot(N, L), 0.0);
float D = distributionGGX(NdotH, roughness);
float G = geometrySmith(NdotV, NdotL, roughness);
float3 numerator = D * F * G;
float denominator = 4.0 * NdotV * NdotL + 0.001;
return numerator / denominator;
}
// Complete Direct Lighting Calculation
calculateDirectLighting :: (PBRMaterial mat, float3 N, float3 V, float3 L, float3 radiance) -> float3 {
float NdotL = max(dot(N, L), 0.0);
float3 H = normalize(V + L);
float VdotH = max(dot(V, H), 0.0);
float3 F0 = lerp(float3(0.04), mat.albedo, mat.metallic);
float3 F = fresnelSchlick(VdotH, F0);
float3 kD = (1.0 - F) * (1.0 - mat.metallic);
float3 diffuse = kD * lambertianDiffuse(mat.albedo);
float3 specular = cookTorranceSpecular(N, V, L, F, mat.roughness);
return (diffuse + specular) * radiance * NdotL;
}
}
Using PBR in a Pipeline
pipeline PBRShader {
import PBR as BRDF
import Math
using Material = BRDF::PBRMaterial
resources {
mvp: mat4
model: mat4
normalMatrix: mat3
cameraPos: float3
lightDir: float3
lightColor: float3
albedoMap: texture2D
roughnessMap: texture2D
metallicMap: texture2D
aoMap: texture2D
materialSampler: sampler
}
pass "Lighting" {
use resources {
mvp, model, normalMatrix, cameraPos, lightDir, lightColor,
albedoMap, roughnessMap, metallicMap, aoMap, materialSampler
}
vertex {
output.position = resources.mvp * float4(attributes.position, 1.0);
output.worldPos = (resources.model * float4(attributes.position, 1.0)).xyz;
output.normal = normalize(resources.normalMatrix * attributes.normal);
}
fragment {
Material mat;
mat.albedo = sample(resources.albedoMap, resources.materialSampler, input.uv).rgb;
mat.roughness = sample(resources.roughnessMap, resources.materialSampler, input.uv).r;
mat.metallic = sample(resources.metallicMap, resources.materialSampler, input.uv).r;
mat.ao = sample(resources.aoMap, resources.materialSampler, input.uv).r;
float3 N = normalize(input.normal);
float3 V = normalize(resources.cameraPos - input.worldPos);
float3 L = normalize(resources.lightDir);
float3 color = BRDF::calculateDirectLighting(mat, N, V, L, resources.lightColor);
color *= mat.ao;
output.color = float4(color, 1.0);
}
}
}
Performance
Modules are parsed once and cached to avoid redundant work. When a module is imported, only the functions and types that are actually used are folded into the final shader. This keeps compiled shaders lean and avoids code bloat.
See Also
- Language Overview - BWSL syntax and concepts
- Operators - Operator reference
- Intrinsics - Built-in functions