💡 Tip: It's recommended using the 02_custom_bindgroup.html version of this tutorial as copying code works best there regarding padding and formatting. Note that the generated .html/.pdf versions can lag behind this .md file - the .md is the source of truth.
⚠️ Build issues? See Troubleshooting at the end of this tutorial for help reading build errors from the terminal.
In Tutorial 01, you learned the engine bind groups (Frame, Scene, Material, Object) that occupy @group(0..3). Now you'll learn how to add your own custom bind group - at @group(1), a slot this shader's engine roles leave free - to pass additional data to a shader, and that the engine discovers it for you by reflecting the WGSL, so you only name it on the C++ side.
What you'll learn:
- Adding a custom bind group in a free slot (WebGPU allows only 4 bind groups per pipeline; a custom group takes an index whose engine role your shader does not use)
- How the engine reflects a custom group out of your WGSL - no hand-written binding layout
- Naming the group on the ShaderDescriptor so per-object data can target it
- Implementing preRender() to provide per-object data via BindGroupDataProvider
- Texture tiling and offset manipulation in shaders
What you'll build: A tiled floor with controllable tiling and offset, perfect for scrolling textures or adjusting texture scale per object.
What's provided:
- Working unlit shader from Tutorial 01
- Tutorial project with scene setup
- Empty CustomRenderNode.h class to implement
Why add custom bind groups?
The engine reserves @group(0..3) for its own roles:
- Group 0: Frame - camera matrices, time (once per frame)
- Group 1: Scene - lights, shadows, environment, clusters (once per frame)
- Group 2: Material - textures, colors, properties (per material)
- Group 3: Object - transform (per object)
But what if you want per-object data that's NOT a transform or material? Examples: - Scrolling water at different speeds - Individual object animations - Per-object effects or parameters
Custom bind groups solve this: WebGPU limits a pipeline to 4 bind groups (indices 0..3 - the spec default, and Dawn's hard maximum), so there is no fifth slot for custom data. Instead, your custom group takes over an index whose engine role your shader does not use. This tutorial's unlit shader pulls in Frame (0), Material (2) and Object (3) but no Scene group, so @group(1) is free. You declare the group in WGSL and the engine reflects it - it reads the bindings, types and sizes straight out of your shader. On the C++ side you only give the group a name and a reuse policy; naming the slot in the descriptor is what tells the engine it is a custom group rather than the default engine role.
This tutorial builds directly on Tutorial 01. You should have completed the unlit shader first.
examples/tutorial/assets/shaders/unlit_custom.wgsl - Copy unlit.wgsl and extend with TileUniformsexamples/tutorial/main.cpp - Register new shader with custom bind groupexamples/tutorial/CustomRenderNode.h - Implement preRender() to provide dataThe main.cpp has one commented line (similar to Tutorial 01) if it was not modified previously:
// floorModel->getSubmeshes()[0].material = floorMaterial->getHandle();
You'll uncomment this at the end once everything is ready.
Open examples/tutorial/assets/shaders/unlit_custom.wgsl.
This file already contains the complete unlit shader from Tutorial 01:
- VertexInput and VertexOutput structs
- Frame (@group(0)) and Object (@group(3)) uniforms, pulled in via #include "engine://core/frame_uniforms.wgsl" and #include "engine://core/object_uniforms.wgsl"
- Material bind group (@group(2)) declared inline
- Vertex shader (vs_main) and fragment shader (fs_main)
You'll extend this by adding a custom bind group at @group(1) - the slot this shader's unused Scene role leaves free - for tiling parameters.
Your Task:
Open examples/tutorial/assets/shaders/unlit_custom.wgsl and find the comment: // Tutorial 02 - Step 3
Add this code:
struct TileUniforms
{
tileOffset: vec2f,
tileSize: vec2f,
}
What it contains:
- tileOffset - UV offset (for scrolling or shifting texture)
- tileSize - UV scale (how many times to repeat texture)
WebGPU alignment:
Each vec2f is 8 bytes, so total is 16 bytes - perfect for uniform buffer alignment (must be multiple of 16).
Your Task:
In unlit_custom.wgsl, find the comment: // Tutorial 02 - Step 4
Add this code:
@group(1) @binding(0)
var<uniform> tileUniforms: TileUniforms;
Important: WebGPU allows at most 4 bind groups per pipeline (indices 0..3) - the spec default limit, and the hard maximum on Dawn. The indices only default to the engine roles (Frame, Scene, Material, Object); a slot whose role your shader does not pull in is free for custom data. This unlit shader uses Frame (0), Material (2) and Object (3) but no Scene group, so @group(1) is available. A custom group at an index the shader already uses (here 0, 2 or 3) would collide with that engine group, and an index of 4 or higher fails pipeline creation (bindGroupLayoutCount (5) is larger than the maximum allowed (4)). If a shader needed all four engine roles, there would be no free slot - fold the extra data into the material group instead. The engine reflects whatever you declare here.
Bind group slots:
- Group 0: Frame (engine role) - pulled in via #include when your shader needs camera data
- Group 1: Scene (engine role) - unused by this shader, so our custom TileUniforms takes it
- Group 2: Material, Group 3: Object (engine roles this shader uses)
Each group can have multiple bindings (0, 1, 2...) for different resources within that group.
Your Task:
In unlit_custom.wgsl, find the comment: // Tutorial 02 - Step 5
Update the fragment shader to apply tiling before texture sampling:
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4f {
// Apply tiling and offset to UV coordinates
let tiledUV = input.texCoord * tileUniforms.tileSize + tileUniforms.tileOffset;
// Sample texture with modified UVs
let textureColor = textureSample(baseColorTexture, textureSampler, tiledUV);
let finalColor = textureColor * unlitMaterialUniforms.color;
return finalColor;
}
What this does:
1. input.texCoord - Original UV from mesh (0-1 range)
2. * tileUniforms.tileSize - Scale UV (e.g., ×4 = repeat texture 4 times)
3. + tileUniforms.tileOffset - Shift UV (e.g., +0.5 = scroll halfway)
4. Sample texture at modified coordinates
Example values:
- tileSize = (2, 2) - Texture repeats 2×2 times across surface
- tileOffset = (0.5, 0) - Texture shifted 50% to the right
Your Task:
Open examples/tutorial/main.cpp and find the comment: // Tutorial 02 - Step 6
The shader is registered with a ShaderDescriptor - a small declaration the engine fills out by reflecting the WGSL. You don't list bindings or sizes; reflection recovers them from the @group(1) block you wrote. You only have to do two things:
In the descriptor, change the path line to the tiling shader:
unlitShader.path = PathProvider::getShaders("unlit_custom.wgsl"); // was "unlit.wgsl"
The Frame, Material and Object groups are still discovered automatically (Frame/Object from the #includes, Material from the structs the shader declares), so nothing else about those changes.
Right after the material group is assigned, add the custom group entry:
unlitShader.groups[1] = {
"TileUniforms", // Group name (targeted from preRender)
engine::rendering::BindGroupType::Custom, // Mark as a custom group
engine::rendering::BindGroupReuse::PerObject, // Cache one per object
{} // No per-binding overrides - reflection fills these
};
Naming the entry is what claims the slot: an unnamed @group(1) would fall back to the engine's Scene role, but a named descriptor entry defines a custom group at any index.
The registration call below it is already in place and stays the same:
shaderRegistry.registerShader(shaderFactory.buildFromDescriptor(unlitShader));
What you're telling the engine:
- The group at @group(1) is called "TileUniforms" (your preRender() will target this name)
- It's a custom group, cached per object
- The empty {} means "no overrides" - the binding, its type and its size all come from reflecting the shader
How the binding happens:
📝 Note:
buildFromDescriptor()expands the#includes, reflects the resulting WGSL, and builds the bind group layouts from what it finds. For@group(1)it reads yourTileUniformsdeclaration directly - the binding index, the fact that it's a uniform, and its 16-byte size all come from the shader. The descriptor only supplies the things WGSL can't express: the engine-facing name and the reuse policy.Advanced Readers: See WgslReflector.cpp for the reflection pass and WebGPUShaderFactory.cpp (
buildFromDescriptor) for how the reflected groups become bind group layouts.
Why reflection instead of hand-listing bindings?
- The shader is the source of truth: you declared TileUniforms once in WGSL; reflection reuses that rather than making you restate the size and bindings in C++ (which could drift)
- Less to get wrong: no manual byte counts or stage flags for the custom group
- Still explicit where it matters: you choose the group name and reuse policy, which are engine concepts the shader can't express
Your Task:
Open examples/tutorial/CustomRenderNode.h and find the comment: // Tutorial 02 - Step 7
The struct is already defined:
struct TileUniforms
{
glm::vec2 tileOffset;
glm::vec2 tileSize;
};
Implement the preRender() method:
virtual void preRender(std::vector<engine::rendering::BindGroupDataProvider> &outProviders) override
{
auto dataProvider = engine::rendering::BindGroupDataProvider::create(
"unlit", // Shader name (must match registration)
"TileUniforms", // Bind group name (must match groups[1] in registration)
tileUniforms, // Uniform data (struct instance)
engine::rendering::BindGroupReuse::PerObject, // Cache per object
getId() // Instance ID (node's unique ID)
);
outProviders.push_back(dataProvider);
}
Understanding preRender():
This method is called by the scene graph before rendering each frame. It's your opportunity to provide updated data to the GPU.
BindGroupDataProvider::create() parameters:
1. Shader name - Which shader needs this data
2. Bind group name - Which bind group in that shader (the name you gave groups[1])
3. Data - Actual uniform data (will be copied to GPU)
4. Reuse policy - When to rebind (PerObject means cache per unique object)
5. Instance ID - Unique identifier for caching (use node ID for per-object)
Why PerObject reuse? If you have 10 floor tiles with different tiling, each gets its own cached bind group. The renderer only rebinds when switching between different objects.
Your Task:
Open main.cpp and find the comment: // Tutorial 02 - Step 8
Update the code to use CustomRenderNode:
// Change from ModelRenderNode to CustomRenderNode
auto floorNode = std::make_shared<demo::CustomRenderNode>(floorModel);
floorNode->tileUniforms.tileOffset = glm::vec2(0.2f, 0.0f);
floorNode->tileUniforms.tileSize = glm::vec2(4.0f, 4.0f);
floorNode->getTransform().setLocalScale(glm::vec3(10.0f, 1.0f, 10.0f));
rootNode->addChild(floorNode);
What this does: - Creates custom node with floor model - Sets tileOffset to shift texture 20% to the right - Sets tileSize to 4×4 (repeat texture 16 times total) - Scales floor physically to 10×10 units
Your Task:
Open main.cpp and find the comment: // Tutorial 02 - Step 9
Uncomment the material assignment line:
// Uncomment this line:
floorModel->getSubmeshes()[0].material = floorMaterial->getHandle();
Why uncomment now? Now that the shader is complete and registered, the engine can create the render pipeline successfully.
# Windows
scripts\build-example.bat tutorial Debug WGPU
# Linux
bash scripts/build-example.sh tutorial Debug WGPU
VS Code: Press F5 to build and run.
Where the executable lands: build directories are per backend. On Windows a WGPU build goes to examples\build\tutorial\Windows\Debug-WGPU (a DAWN build uses Debug-DAWN; only a Dawn-from-source build - fourth argument SOURCE - uses the plain Debug folder, and Emscripten/EMDAWN builds go under Emscripten\Debug). On Linux/Mac the build directory is examples/build/tutorial/<Linux|Mac>/Debug.
You should see: - ✅ Floor with tiled cobblestone (4×4 repetitions) - ✅ Texture slightly offset to the right (20% shift) - ✅ Fourareen object with PBR lighting (unchanged)
Compare to Tutorial 01: - Tutorial 01: Texture stretched across entire floor (1×1) - Tutorial 02: Texture repeated 4×4 times with smaller tiles
Lifecycle of custom bind group data:
1. Scene graph traversal:
└─ Scene calls preRender() on each node
2. CustomRenderNode::preRender():
├─ Reads tileUniforms member variable
├─ Creates BindGroupDataProvider with data
└─ Adds provider to outProviders vector
3. Scene collects all providers:
└─ Passes them to Renderer::renderFrame()
4. Renderer processes providers:
├─ FrameCache::processBindGroupProviders() creates GPU buffers
├─ Caches bind group by (shaderName, bindGroupName, instanceId)
└─ Uploads data to GPU via writeBuffer()
5. During rendering:
├─ BindGroupBinder::bind() checks cache
├─ Finds cached bind group by instanceId
└─ Binds it at the correct index (in this case @group(1)) in the shader
6. Shader execution:
└─ Fragment shader reads tileUniforms.tileOffset and tileSize
Key insight: You provide data in preRender(), the engine handles caching and GPU upload automatically.
Why preRender() Works - The Timing:
Remember WebGPU's command recording model:
1. Scene Traversal: Scene calls preRender() on all nodes \u2192 Collects bind group providers
2. GPU Upload: Engine processes providers \u2192 Creates buffers \u2192 Writes data with queue.writeBuffer()
3. Command Recording: Engine records draw commands \u2192 Binds groups \u2192 Issues draw()
4. Submission: Complete command buffer sent to GPU queue
5. GPU Execution: Commands execute, reading the fresh data you provided
This is why updating tileUniforms in preRender() works - the data reaches the GPU before the draw command executes!
Why PerObject for TileUniforms?
The reuse policy determines when bind groups are cached and rebound:
| Policy | When it Changes | Example Use Case |
|---|---|---|
| Global | Never (constant for whole frame) | Default textures, global settings |
| PerFrame | Once per frame | Camera, time, global lighting |
| PerObject | Per object | Object transforms, per-object parameters |
| PerMaterial | Per material | Material textures, material properties |
For custom bind groups:
- Use PerObject when data is unique per object instance (like our tiling)
- Use PerFrame if data updates every frame but is shared (like a global animation time)
- Use PerMaterial if data is shared by objects with same material
Performance impact:
- PerObject with unique data = creates many bind groups (one per object)
- PerObject with shared data = reuses cached bind groups automatically
The engine's FrameCache handles the caching and BindGroupBinder the binding - you just specify the policy.
1. Animate the offset - In CustomRenderNode, add an update() method:
Implement the UpdateNode behavior.
class CustomRenderNode : public engine::scene::nodes::ModelRenderNode, public engine::scene::nodes::UpdateNode
Override the default update(float deltaTime) method.
virtual void update(float deltaTime) override {
tileUniforms.tileOffset.x += deltaTime * 0.1f; // Scroll right
}
2. Different tiling per object - Create multiple floor nodes:
auto floor1 = std::make_shared<demo::CustomRenderNode>(floorModel);
floor1->tileUniforms.tileSize = glm::vec2(2.0f, 2.0f);
floor1->getTransform().setLocalPosition(glm::vec3(-5.0f, 0.0f, 0.0f));
floor1->getTransform().setLocalScale(glm::vec3(5.0f, 1.0f, 5.0f));
rootNode->addChild(floor1);
auto floor2 = std::make_shared<demo::CustomRenderNode>(floorModel);
floor2->tileUniforms.tileSize = glm::vec2(8.0f, 8.0f);
floor2->getTransform().setLocalPosition(glm::vec3(5.0f, 0.0f, 0.0f));
floor2->getTransform().setLocalScale(glm::vec3(5.0f, 1.0f, 5.0f));
rootNode->addChild(floor2);
3. Add rotation - Extend TileUniforms:
Adjust C++ Struct
// CustomRenderNode.h
struct TileUniforms
{
glm::vec2 tileOffset = glm::vec2(0.0f);
glm::vec2 tileSize = glm::vec2(1.0f);
glm::vec4 rotation = glm::vec4(0.0f);
// Rotation in .x, padding in .yzw for 16-byte alignment
};
Adjust WGSL Struct
struct TileUniforms {
tileOffset: vec2f, // Offset
tileSize: vec2f, // Scale
rotation: vec4f, // rotation.x = angle in radians, yzw = padding
}
Define WGSL Helper Function
fn rotate2D(uv: vec2f, angle: f32) -> vec2f {
let cosA = cos(angle);
let sinA = sin(angle);
return vec2f(
uv.x * cosA - uv.y * sinA,
uv.x * sinA + uv.y * cosA
);
}
Update Fragment Shader
let rotatedUV = rotate2D(input.texCoord - 0.5, tileUniforms.rotation.x) + 0.5;
let tiledUV = rotatedUV * tileUniforms.tileSize + tileUniforms.tileOffset;
Update cpp code to set a rotation of your choice
floorNode->tileUniforms.rotation = glm::vec4(0.5f, 0.0f, 0.0f, 0.0f);
WebGPU bind group creation:
When you call BindGroupDataProvider::create(), the engine internally:
wgpu::BufferUsage::Uniform | CopyDstqueue.writeBuffer()device.createBindGroup()FrameCache::customBindGroupCache[key] where key = (shader, bindGroup, instanceId)Next frame: - Same object? Reuse cached bind group, just update buffer if data changed - Different object? Create new bind group or fetch from cache by different instanceId
Memory management:
- Bind groups live in FrameCache::customBindGroupCache and persist across frames - each one is created exactly once, then only its buffer contents are refreshed via updateBuffer()
- The per-frame cleanup (FrameCache::endFrame()) drops only truly per-frame data (lights, shadow requests, render targets); the custom bind group cache survives it
- The cache is only emptied by the full FrameCache::clear(), which runs on scene changes
Buffer sizing - the capacity is fixed at creation:
- The auto-created buffer behind your custom group is sized at the layout's minBindingSize - exactly the size of the struct you declared in WGSL (for a runtime-sized array binding, that is ONE array element)
- WebGPUBindGroup::updateBuffer() clamps any write that would exceed the buffer size and logs a warning (updateBuffer: write ... exceeds buffer ... - clamping) - the excess bytes are dropped, not uploaded
- A fixed-size struct like our 16-byte TileUniforms never hits this. If you ever provide growing data (e.g. a runtime-sized array), preallocate a buffer at full capacity and hand it to the factory as an override - see how DebugPass does it in DebugPass.cpp (bufferFactory().createStorageBuffer(...) at max capacity, then a BindGroupResource override passed into createBindGroup()) - or keep the data fixed-size
✅ Custom Bind Groups - Extend shader capabilities with custom data in a free bind group slot, discovered by reflection
✅ BindGroupDataProvider - Simple API to pass CPU data to GPU shaders
✅ preRender() Lifecycle - Called before rendering, perfect for per-frame updates
✅ Reuse Policies - Control caching behavior (PerFrame, PerObject, PerMaterial)
✅ Node Customization - Extend ModelRenderNode to add custom shader data
WebGPU Concepts Learned: - WebGPU's 4-bind-group limit, and reusing a free index for custom data - Custom uniform buffer creation and caching - Alignment requirements for uniform structs (16-byte boundaries) - Efficient resource reuse via caching policies
In Tutorial 03, you'll write a glass shader and learn:
- The Scene bind group (@group(1)): lights and shadow maps via #include
- Fresnel-style rim lighting and a specular highlight (view-vector math)
- Receiving shadows with the engine's shared calculate_shadow() (cascades, bias, PCF)
- Transparency: the Transparent feature flag, alpha blending, and why transparent and custom-shader materials render back-to-front in the forward pass
Next Tutorial: 03_glass_shader.md
⚠️ Important: When building via the VS Code task (which runs scripts/build-example.bat / build-example.sh), the task system may report success even if the build actually failed. You MUST check the terminal output to see the real result.
What to look for in terminal:
1. Scroll to the very end of the terminal output
2. Look for [SUCCESS] Example 'tutorial' built successfully! - if this appears, build succeeded
3. If you see [ERROR] Build failed. (or [ERROR] CMake configuration failed.) - the build failed regardless of task status
Common build issues:
- Missing semicolons - WGSL requires ; at end of statements
- Struct size mismatch - tileUniforms size must be 16 bytes (2 × vec2f)
- Bind group naming - the "TileUniforms" name in groups[1] must match the group your preRender() targets; the @group(1) index in the shader must match the groups[1] key in the descriptor
- CMake cache - Delete the example's build folder (examples\build\tutorial) then rebuild clean
"Unable to find bind group 'TileUniforms'"
- Check shader name matches in preRender() ("unlit") and registration as well as material assignment
- Ensure bind group name matches exactly (case-sensitive) between groups[1] and preRender()
- Verify the custom group's index does not collide with an engine group the shader actually uses (here 0, 2 and 3)
"bindGroupLayoutCount (5) is larger than the maximum allowed (4)" / "BindGroupBinder: group index 4 exceeds wgpu's max of 4"
- The custom group is declared at @group(4) or higher - WebGPU pipelines are limited to 4 bind groups (indices 0..3). Move the custom group into a slot the shader's engine roles leave free (for this shader, @group(1)), and key the descriptor entry accordingly (groups[1])
Floor appears stretched/wrong
- Verify TileUniforms struct size matches shader (16 bytes = 2 × vec2f)
- Check reuse policy is PerObject in both registration and preRender()
Data doesn't update
- Ensure preRender() is called (check node is enabled and in scene graph)
- Verify you're modifying the member variable, not a local copy
- Check instance ID is correct in BindGroupDataProvider::create()
- A log warning updateBuffer: write ... exceeds buffer ... - clamping means your C++ struct is larger than the WGSL struct - the write is clamped to the GPU buffer's size, so the tail of your data never reaches the shader
Shader compilation error
- Ensure the custom group's index is not one the shader's engine roles already occupy (0, 2, 3 here) and stays below WebGPU's limit of 4 bind groups
- Ensure TileUniforms struct is defined before use
- In Debug builds the shader validator is fatal, so the load fails fast with the offending line
- Verify there are no missing ;
If errors are unclear:
1. Run the tutorial - shader reflection and validation happen at load time
2. Check the console / run_out.log - shader compile and validation errors are printed there with the line/column
3. In Debug builds a malformed custom group (wrong index, redeclared engine struct) fails the load with a structured diagnostic