Ember's renderer compiles GLSL 4.5 core shader assets through a small custom parser that:
- Splits a single
.glslfile into vertex/fragment stages (and optionally geometry/compute). - Scans
// @UIProperty(...)annotations so uniforms become editable widgets in the inspector. - Injects engine-supplied
#definemacros after each#versionline. - Watches the file on disk and hot-reloads any non-engine shader whose source changes at runtime.
This document is the reference for authoring those shaders. It mirrors the behavior of
Ember/src/Ember/Render/ShaderParser.cpp and the
preset templates emitted by the Material → Create Shader editor workflow.
- File Layout
- Creating a Shader from the Editor
- Render Queues
- Vertex Attribute Locations
- Engine-Supplied Uniforms
- Exposing Properties to the Editor
- Macros
- Hot Reload
- Fallback Shader
- Worked Examples
- Tips & Gotchas
A shader is a single .glsl file that uses #shader directives to mark each stage. Anything
between two #shader lines (or between the last #shader and end-of-file) belongs to that stage.
#shader vertex
#version 450 core
// vertex code...
#shader fragment
#version 450 core
// fragment code...Recognized stage names: vertex, fragment. Each stage must declare its own #version line —
the parser does not duplicate it for you.
The fastest way to start is from the Material Component in the Inspector:
- Add a
MaterialComponentto an entity and click Create to make a new material. - With the material selected and no shader assigned, the Shader combo shows an
Addbutton. - Click Add to open the Create New Shader modal. Pick a name and a Render Queue.
- Click Create — a templated
.glslis written to<Project>/Assets/Shaders/<Name>.glsl, loaded as a non-engineShaderasset, assigned to the material, and opened in VS Code.
The generated template already contains the correct outputs for the queue you picked and a few
// @UIProperty uniforms with sensible defaults so the material renders something visible
immediately after creation. The first frame after SetShader (or after hot reload introduces new
properties), the material panel seeds defaults for any uniform it doesn't yet have — see
Default Values.
Each material declares a RenderQueue which controls which framebuffer the shader writes into
and how the engine composites the result. The fragment shader's layout(location = N) out list
must match the queue's expected attachments — using the wrong layout is the most common cause
of black or transparent surfaces on new shaders.
Opaque materials write into the deferred G-buffer. The engine's lighting pass reads these targets to compute lit color later. The fragment shader does not apply lighting itself.
layout(location = 0) out vec4 AlbedoRoughness; // rgb = albedo, a = roughness
layout(location = 1) out vec4 NormalMetallic; // rgb = world-space normal, a = metallic
layout(location = 2) out vec4 PositionAO; // rgb = world position, a = ambient occlusion
layout(location = 3) out vec4 EmissionOut; // rgb = emissive, a = unused
layout(location = 4) out int EntityID; // engine-managed picking bufferSet
EntityID = u_EntityID;(see Engine-Supplied Uniforms) so the editor's entity picking continues to work.
Forward materials shade directly to the lit color target and contribute to bloom. Use this for self-lit / unlit effects, emissive props, decals, etc.
layout(location = 0) out vec4 OutColor; // lit color (rgb), alpha typically 1.0
layout(location = 1) out vec4 BrightColor; // pixels above 1.0 luminance for the bloom pass
layout(location = 2) out int EntityID;Typical emissive extraction:
BrightColor = vec4(max(OutColor.rgb - vec3(1.0), vec3(0.0)), 1.0);Same attachments and layout as Forward, but the renderer enables alpha blending and depth-write
is disabled. Write the alpha channel of OutColor to control the surface's opacity.
layout(location = 0) out vec4 OutColor;
layout(location = 1) out vec4 BrightColor;
layout(location = 2) out int EntityID;
void main()
{
OutColor = vec4(color, opacity); // alpha drives the blend
BrightColor = vec4(0.0);
EntityID = u_EntityID;
}The engine binds mesh data to fixed attribute locations. Declare only the ones you need:
| Location | Attribute | Type | Notes |
|---|---|---|---|
0 |
v_Position |
vec3 |
Object-space position. Always present. |
1 |
v_Normal |
vec3 |
Object-space normal. |
2 |
v_TextureCoord |
vec2 |
UV0. |
3 |
v_Tangent |
vec3 |
Object-space tangent (may be zero if the mesh didn't ship one — see StandardGeometry.glsl for a robust fallback). |
4 |
v_Bitangent |
vec3 |
Object-space bitangent. |
Skinned meshes add bone indices/weights at additional locations — see
Ember/assets/shaders/StandardGeometrySkinned.glsl if you need those.
The renderer uploads these every frame. Declare the ones you use exactly as shown.
layout(std140, binding = 0) uniform CameraData
{
mat4 u_ViewProjection;
};| Uniform | Type | Set by |
|---|---|---|
u_Transform |
mat4 |
The entity's world transform. Multiply your v_Position by this in the vertex shader. |
u_EntityID |
int |
The picking ID for this entity. Write it to the EntityID framebuffer output to keep selection working. |
Anything else declared as uniform is treated as a material uniform. The material owns its
value and uploads it on Bind(). Pair these with a // @UIProperty annotation to expose them in
the inspector.
A // @UIProperty(...) comment on the line immediately above a uniform declaration tells
the parser to register that uniform as an editor property:
// @UIProperty(Name = "Albedo", Type = Color3)
uniform vec3 u_Albedo;
// @UIProperty(Name = "Roughness", Type = Slider, Min = 0.0, Max = 1.0)
uniform float u_Roughness;The annotation arguments are Key = Value pairs, comma-separated, all optional except Type.
String values may be wrapped in double quotes.
| Key | Applies to | Description |
|---|---|---|
Name |
All | Display name in the inspector. Defaults to the uniform name with the u_ prefix kept. |
Type |
All (required) | One of the Property Types below. |
Min |
Float, Slider, vector types |
Lower bound for the widget and for normalization. |
Max |
Float, Slider, vector types |
Upper bound. |
Step |
Numeric drag widgets | Drag sensitivity (default 0.005). |
Normalize |
Numeric | If true, the editor remaps the user-entered [Min, Max] value into [0, 1] before storing it on the material. |
Type |
GLSL uniform | Editor widget |
|---|---|---|
Float |
float |
Drag float (uses Min/Max/Step). |
Float2 |
vec2 |
Two drag floats. |
Float3 |
vec3 |
Three drag floats. |
Float4 |
vec4 |
Four drag floats. |
Color3 |
vec3 |
Color picker (no alpha). |
Color4 |
vec4 |
Color picker with alpha. |
Slider |
float |
Slider in [Min, Max]. |
Texture |
sampler2D |
Drag-and-drop texture slot. |
When the editor first encounters a property whose uniform isn't yet stored on the material, it seeds a sensible default so the widget appears and the GPU doesn't read zeros. This also runs after hot-reload, so newly-introduced properties get a default automatically.
| Property type | Seeded default |
|---|---|
Float |
0.0 |
Slider |
midpoint of [Min, Max] |
Float2 / Float3 / Float4 |
zero vector |
Color3 / Color4 |
white (1, 1, 1[, 1]) |
Texture |
A default 1×1 fallback texture chosen by uniform name: |
• names containing normal or bump → flat-normal (0.5, 0.5, 1.0) texture |
|
• names containing emiss or ao → black texture |
|
| • everything else → white texture |
Texture sampler slots are assigned in the order textures appear in your // @UIProperty list
(not by layout(binding = ...)). At draw time the material walks Shader::GetProperties(),
binds each ShaderPropertyType::Texture to the next free unit, and uploads its sampler int —
so as long as your annotations are in a stable order, the binding slot is deterministic.
// Slot 0
// @UIProperty(Name = "Albedo Map", Type = Texture)
uniform sampler2D u_AlbedoTex;
// Slot 1
// @UIProperty(Name = "Normal Map", Type = Texture)
uniform sampler2D u_NormalTex;Engine code can pass a ShaderMacros map when loading a shader. Each entry becomes a #define
injected immediately after every #version line in the file, so your code can branch on it:
#shader fragment
#version 450 core
// (parser inserts injected defines here)
#if MAX_BONES > 0
// skinned path
#endifUser-authored project shaders are loaded without macros by default. The built-in engine shaders
use macros for things like MAX_BONES, MAX_DIRECTIONAL_LIGHTS, and INVALID_ENTITY_ID.
When a non-engine shader's .glsl file changes on disk:
- The editor's per-frame
AssetManager::PollShaderHotReload()notices the newlast_write_timeand callsShader::Reload(). - The shader is re-parsed (including macros and
// @UIPropertyannotations). - The GPU program is recompiled and relinked. If compile/link fails, the shader's program id
stays
0and binds substitute the fallback shader (solid pink) instead of leaving the previous program active. - Any new
// @UIPropertyuniforms get seeded with defaults on the next material panel render (see Default Values).
Engine shaders that ship with the executable are intentionally skipped by the hot-reload poller.
To trigger a reload, just save the file. There's no editor button.
Ember/assets/shaders/Fallback.glsl is a regular Shader asset — loaded by AssetManager::LoadDefaults
with UUID = 8, parsed and compiled like every other shader. It renders solid pink and is the
substitute used whenever another shader fails to compile or link. If you see pink on a surface,
check the editor log for the actual GLSL compile error.
#shader vertex
#version 450 core
layout(location = 0) in vec3 v_Position;
layout(std140, binding = 0) uniform CameraData { mat4 u_ViewProjection; };
uniform mat4 u_Transform;
void main()
{
gl_Position = u_ViewProjection * u_Transform * vec4(v_Position, 1.0);
}
#shader fragment
#version 450 core
layout(location = 0) out vec4 OutColor;
layout(location = 1) out vec4 BrightColor;
layout(location = 2) out int EntityID;
// @UIProperty(Name = "Color", Type = Color3)
uniform vec3 u_Color;
// @UIProperty(Name = "Emission", Type = Float, Min = 0.0, Max = 50.0, Step = 0.05)
uniform float u_Emission;
uniform int u_EntityID;
void main()
{
vec3 finalColor = u_Color * u_Emission;
OutColor = vec4(finalColor, 1.0);
BrightColor = vec4(max(OutColor.rgb - vec3(1.0), vec3(0.0)), 1.0);
EntityID = u_EntityID;
}#shader vertex
#version 450 core
layout(location = 0) in vec3 v_Position;
layout(location = 1) in vec3 v_Normal;
layout(location = 2) in vec2 v_TextureCoord;
layout(std140, binding = 0) uniform CameraData { mat4 u_ViewProjection; };
uniform mat4 u_Transform;
out vec2 TexCoord;
out vec3 WorldNormal;
out vec3 WorldPosition;
void main()
{
TexCoord = v_TextureCoord;
WorldNormal = mat3(u_Transform) * v_Normal;
vec4 worldPos = u_Transform * vec4(v_Position, 1.0);
WorldPosition = worldPos.xyz;
gl_Position = u_ViewProjection * worldPos;
}
#shader fragment
#version 450 core
in vec2 TexCoord;
in vec3 WorldNormal;
in vec3 WorldPosition;
layout(location = 0) out vec4 AlbedoRoughness;
layout(location = 1) out vec4 NormalMetallic;
layout(location = 2) out vec4 PositionAO;
layout(location = 3) out vec4 EmissionOut;
layout(location = 4) out int EntityID;
// @UIProperty(Name = "Albedo", Type = Color3)
uniform vec3 u_Albedo;
// @UIProperty(Name = "Roughness", Type = Slider, Min = 0.0, Max = 1.0)
uniform float u_Roughness;
// @UIProperty(Name = "Metallic", Type = Slider, Min = 0.0, Max = 1.0)
uniform float u_Metallic;
// @UIProperty(Name = "Albedo Map", Type = Texture)
uniform sampler2D u_AlbedoMap;
uniform int u_EntityID;
void main()
{
vec3 albedo = u_Albedo * texture(u_AlbedoMap, TexCoord).rgb;
AlbedoRoughness = vec4(albedo, u_Roughness);
NormalMetallic = vec4(normalize(WorldNormal), u_Metallic);
PositionAO = vec4(WorldPosition, 1.0);
EmissionOut = vec4(0.0);
EntityID = u_EntityID;
}#shader fragment
#version 450 core
in vec3 WorldPosition;
layout(location = 0) out vec4 OutColor;
layout(location = 1) out vec4 BrightColor;
layout(location = 2) out int EntityID;
// @UIProperty(Name = "Tint", Type = Color3)
uniform vec3 u_Tint;
// @UIProperty(Name = "Opacity", Type = Slider, Min = 0.0, Max = 1.0)
uniform float u_Opacity;
// @UIProperty(Name = "Wave Speed", Type = Float, Min = 0.0, Max = 5.0, Step = 0.05)
uniform float u_WaveSpeed;
uniform int u_EntityID;
void main()
{
// ...wave/displacement math...
OutColor = vec4(u_Tint, u_Opacity);
BrightColor = vec4(0.0);
EntityID = u_EntityID;
}- Place
// @UIPropertyon the line directly above theuniform. Blank lines or other comments between them break the association — the parser only consumes the nextuniformline after seeing the annotation. - Don't
#versionmore than once per stage. Each stage's first non-#shaderline should be its own#version 450 core. The parser doesn't reorder anything. - A pink mesh means the shader failed to compile. Check the log. The fallback substitutes automatically; you don't need to revert anything.
- Match the queue's output layout exactly. A forward shader writing G-buffer outputs (or vice versa) typically renders black/transparent because the writes go to the wrong attachments.
- Always write
u_EntityIDto the entity-id output so picking keeps working. - Hot reload only watches non-engine shaders. Editing files under
Ember/assets/shaders/while the editor is running won't trigger a reload of the in-process program; rebuild the engine or copy the file into your project'sAssets/Shaders/folder during development. - Texture slots are assigned by annotation order, not
layout(binding=...). Keep the order consistent if you serialize material-uniform overrides. - The asset UUID is persistent across reloads. Once a shader is created and saved into the
project's asset registry, materials referencing it survive renames of the file as long as the
.ebaregistry entry isn't deleted.