Opening

The first two posts got the editor interface and basic rendering in place, but the engine is still an empty shell — you can only hardcode logic in C++. To make a genuinely usable game engine, you have to support scripting so developers can iterate on game logic quickly. Unity uses C# scripts, UE uses Blueprints + C++, Godot uses GDScript. I went with C# — simple syntax, good enough performance, and the Mono runtime is open-source.

This post covers two things: the C# scripting system and shader programming support. The first makes game logic programmable; the second makes rendering effects programmable. Together, they bring real extensibility to the engine.

C# Scripting System

img

Introduction

The C# scripting system is built on the Mono runtime. Mono is the open-source implementation of .NET, able to run C# code on Windows, Linux, and Mac. Our engine embeds Mono into the C++ program, and from there we can load and execute C# scripts.

Dynamic Loading of Mono

Since I don’t want Mono statically linked into the engine (avoids dragging a pile of libs into the release and version conflicts are a pain to deal with), we use dynamic loading: first LoadLibraryA to load mono-2.0-sgen.dll (the sgen variant, which uses the SGen GC), then GetProcAddress to resolve the function pointers one by one. Mono has a lot of API (30+ functions), all turned into function pointers via typedef:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typedef MonoDomain* (*mono_jit_init_t)(const char*);
typedef void (*mono_jit_cleanup_t)(MonoDomain*);
typedef MonoAssembly* (*mono_domain_assembly_open_t)(MonoDomain*, const char*);
typedef MonoImage* (*mono_assembly_get_image_t)(MonoAssembly*);
typedef MonoClass* (*mono_class_from_name_t)(MonoImage*, const char*, const char*);
typedef MonoObject* (*mono_object_new_t)(MonoDomain*, MonoClass*);
typedef MonoMethod* (*mono_class_get_method_from_name_t)(MonoClass*, const char*, int);
typedef MonoObject* (*mono_runtime_invoke_t)(MonoMethod*, void*, void**, MonoObject**);
typedef void (*mono_add_internal_call_t)(const char*, void*);
typedef MonoClassField* (*mono_class_get_field_from_name_t)(MonoClass*, const char*);
typedef void (*mono_field_set_value_t)(MonoObject*, MonoClassField*, void*);
typedef void (*mono_set_assemblies_path_t)(const char*);
// ... another dozen or so similar typedefs

static HMODULE g_monoDll = nullptr;
static mono_jit_init_t p_mono_jit_init = nullptr;
static mono_jit_cleanup_t p_mono_jit_cleanup = nullptr;
static mono_domain_assembly_open_t p_mono_domain_assembly_open = nullptr;
// ... corresponding function pointers

Loading and Resolving

Load the DLL and resolve the functions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
bool MonoRuntime::Initialize(const std::string& monoLibPath)
{
// 1. Try multiple paths to load the mono DLL
std::vector<std::string> tryPaths;
if (!monoLibPath.empty())
tryPaths.push_back(monoLibPath + "/mono-2.0-sgen.dll");

// Environment variable MONO_PATH
char* monoPathEnv = nullptr;
if (_dupenv_s(&monoPathEnv, &monoPathLen, "MONO_PATH") == 0 && monoPathEnv) {
tryPaths.push_back(std::string(monoPathEnv) + "/bin/mono-2.0-sgen.dll");
}

// Registry SOFTWARE\Mono / Ximian / Novell InstallRoot
for (auto& regKey : { "SOFTWARE\\Mono", "SOFTWARE\\Ximian", "SOFTWARE\\Novell" }) {
// Read InstallRoot via RegOpenKeyExA + RegQueryValueExA
// Append /bin/mono-2.0-sgen.dll
}

// Relative paths as fallback
tryPaths.push_back("3rdParty/Mono/mono-2.0-sgen.dll");
tryPaths.push_back("../../3rdParty/Mono/mono-2.0-sgen.dll");

for (const auto& path : tryPaths) {
g_monoDll = LoadLibraryA(path.c_str());
if (g_monoDll) break;
}
if (!g_monoDll) return false;

// 2. Use GetProcAddress to resolve all function pointers
p_mono_jit_init = (mono_jit_init_t)GetProcAddress(g_monoDll, "mono_jit_init");
p_mono_jit_cleanup = (mono_jit_cleanup_t)GetProcAddress(g_monoDll, "mono_jit_cleanup");
p_mono_domain_assembly_open =
(mono_domain_assembly_open_t)GetProcAddress(g_monoDll, "mono_domain_assembly_open");
// ... 30+ GetProcAddress calls

if (!p_mono_jit_init || !p_mono_jit_cleanup || !p_mono_domain_assembly_open) {
FreeLibrary(g_monoDll);
g_monoDll = nullptr;
return false;
}

// 3. Set the assembly search path
p_mono_set_assemblies_path("3rdParty/Mono");

// 4. Create the JIT domain
p_mono_config_parse(nullptr);
g_domain = p_mono_jit_init("DittoEngine");
if (!g_domain) {
FreeLibrary(g_monoDll);
g_monoDll = nullptr;
return false;
}

DITTO_LOG_INFO("[Mono] Runtime initialized");
return true;
}

The upside of this approach: no need to ship mono-2.0-sgen.lib in the release, and DLL version conflicts are easier to deal with. The downside is one extra DLL lookup and function resolution at startup, but that overhead is negligible.

mono_jit_init("DittoEngine") — the "DittoEngine" here is just the domain name, name it whatever you like. After this, you can use p_mono_domain_assembly_open to load DittoEngine.dll (the engine API assembly), which contains the C# wrappers for GameObject, Transform, Rigidbody, and other engine APIs. Game scripts inherit from the MonoBehaviour base class and override Start, Update, etc. to implement game logic.

Script Compilation

C# script compilation involves two DLLs that you need to keep straight:

  • DittoEngine.dll: the engine’s C# API (Transform, Rigidbody, MonoBehaviour, etc.), analogous to Unity’s UnityEngine.dll. This DLL is pre-built once using 3rdParty/Mono/Build DLL.cmd from DittoEngine.cs:

    1
    "%CSC%" /target:library /nostdlib+ /reference:mscorlib.dll /out:DittoEngine.dll DittoEngine.cs
  • GameScripts.dll: the compiled output of the user’s game scripts, regenerated every time a script is modified.

User-written .cs files need to be compiled into GameScripts.dll to be loaded by Mono. Compilation uses Microsoft’s Roslyn compiler (csc.exe):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
bool CSharpScriptSystem::CompileScript(const std::string& csPath, std::string& outDllPath)
{
// Locate csc.exe
std::string roslynPath = FindMSBuildPath();
std::string cscPath = roslynPath + "\\csc.exe";

// Locate dependency assemblies
std::string dittoEngineDll = FindDittoEngineDll();
std::string monoMscorlib = FindMonoMscorlib();
std::string netstandardDll = FindNetStandardDll();

// Build the compile command
std::string cmd = "cmd /c \"\"" + cscPath + "\""
+ " /target:library"
+ " /nostdlib+"
+ " /reference:\"" + monoMscorlib + "\""
+ " /reference:\"" + netstandardDll + "\""
+ " /reference:\"" + dittoEngineDll + "\""
+ " /out:\"" + outDllPath + "\""
+ " \"" + csPath + "\""
+ " 2>&1\"";

int result = system(cmd.c_str());
return result == 0 && fs::exists(outDllPath);
}

Three assemblies need to be referenced during compilation:

  • mscorlib.dll: Mono’s core library
  • netstandard.dll: .NET Standard API
  • DittoEngine.dll: the engine API

The compiled DLL is saved to the project’s Temp directory, with a unique filename (timestamp) generated on each compile, so hot reload is supported.

Loading Scripts

Loading a script is two steps: load the assembly, then create the script instance:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
std::shared_ptr<ScriptInstance> MonoRuntime::LoadScript(const std::string& dllPath, const std::string& className)
{
// 1. Load the assembly
MonoAssembly* assembly = mono_domain_assembly_open(m_rootDomain, dllPath.c_str());
if (!assembly)
{
DITTO_LOG_ERROR_STREAM("[Mono] Failed to load assembly: " << dllPath);
return nullptr;
}

MonoImage* image = mono_assembly_get_image(assembly);

// 2. Find the class
MonoClass* klass = mono_class_from_name(image, "", className.c_str());
if (!klass)
{
DITTO_LOG_ERROR_STREAM("[Mono] Class not found: " << className);
return nullptr;
}

// 3. Create an instance
MonoObject* instance = mono_object_new(m_rootDomain, klass);
mono_runtime_object_init(instance);

auto scriptInstance = std::make_shared<ScriptInstance>();
scriptInstance->instance = instance;
scriptInstance->klass = klass;
scriptInstance->assembly = assembly;
scriptInstance->image = image;

return scriptInstance;
}

Calling Script Methods

Calling the script’s Start, Update, etc.:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void MonoRuntime::CallStart(std::shared_ptr<ScriptInstance> scriptInstance)
{
if (!scriptInstance || !scriptInstance->instance) return;

MonoMethod* method = mono_class_get_method_from_name(scriptInstance->klass, "Start", 0);
if (method)
{
MonoObject* exception = nullptr;
mono_runtime_invoke(method, scriptInstance->instance, nullptr, &exception);
if (exception)
DITTO_LOG_ERROR("[Mono] Exception in Start()");
}
}

void MonoRuntime::CallUpdate(std::shared_ptr<ScriptInstance> scriptInstance)
{
if (!scriptInstance || !scriptInstance->instance) return;

MonoMethod* method = mono_class_get_method_from_name(scriptInstance->klass, "Update", 0);
if (method)
{
MonoObject* exception = nullptr;
mono_runtime_invoke(method, scriptInstance->instance, nullptr, &exception);
if (exception)
DITTO_LOG_ERROR("[Mono] Exception in Update()");
}
}

Internal Calls

Scripts need to be able to access engine functionality — get a Transform, modify a Rigidbody, and so on. This is done via Internal Calls: register some functions on the C++ side, and the C# side can then call them:

1
2
3
4
5
6
7
8
9
10
void CSharpScriptSystem::RegisterInternalCalls()
{
MonoRuntime::AddInternalCall("DittoEngine.Transform::GetPosition", (void*)Internal_Transform_GetPosition);
MonoRuntime::AddInternalCall("DittoEngine.Transform::SetPosition", (void*)Internal_Transform_SetPosition);
MonoRuntime::AddInternalCall("DittoEngine.Rigidbody::GetVelocity", (void*)Internal_Rigidbody_GetVelocity);
MonoRuntime::AddInternalCall("DittoEngine.Rigidbody::SetVelocity", (void*)Internal_Rigidbody_SetVelocity);
MonoRuntime::AddInternalCall("DittoEngine.Input::GetKeyNative", (void*)Internal_Input_GetKey);
MonoRuntime::AddInternalCall("DittoEngine.Debug::Log", (void*)Internal_Debug_Log);
// ... more Internal Calls
}

C++ side implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
extern "C" {

void Internal_Transform_GetPosition(void* transform, float* outPos)
{
if (!transform || !outPos) return;
TransformComponent* trans = static_cast<TransformComponent*>(transform);
outPos[0] = trans->position.x;
outPos[1] = trans->position.y;
outPos[2] = trans->position.z;
}

void Internal_Transform_SetPosition(void* transform, float x, float y, float z)
{
if (!transform) return;
TransformComponent* trans = static_cast<TransformComponent*>(transform);
trans->position = glm::vec3(x, y, z);
trans->localDirty = true;
trans->UpdateTransform();
}

void Internal_Debug_Log(void* msg)
{
std::string message = MonoRuntime::GetStringFromMono((MonoString*)msg);
CSharpScriptSystem::LogToConsole("[C#] " + message);
}

}

C# side declaration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace DittoEngine
{
public class Transform
{
[MethodImpl(MethodImplOptions.InternalCall)]
private static extern void GetPosition(IntPtr transformPtr, out Vector3 position);

[MethodImpl(MethodImplOptions.InternalCall)]
private static extern void SetPosition(IntPtr transformPtr, float x, float y, float z);

public Vector3 position
{
get { GetPosition(nativePtr, out var pos); return pos; }
set { SetPosition(nativePtr, value.x, value.y, value.z); }
}
}
}

Field Serialization

public fields on a script need to be editable in the Inspector. This requires parsing the script source to extract field information:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
void CSharpScriptComponent::ParseScriptFields()
{
fields.clear();
if (scriptPath.empty()) return;

std::ifstream file(scriptPath);
std::stringstream buffer;
buffer << file.rdbuf();

// Strip comments
std::string source = StripComments(buffer.str());

// Parse statement by statement
std::string stmt;
for (char c : source)
{
if (c == ';')
{
ParseFieldDeclaration(stmt);
stmt.clear();
}
else if (c == '{' || c == '}')
{
stmt.clear();
}
else
{
stmt += c;
}
}
}

Parsing a single field declaration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
void CSharpScriptComponent::ParseFieldDeclaration(const std::string& statement)
{
std::string s = Trim(statement);

// Parse attributes: [SerializeField], [HideInInspector]
bool hasSerializeField = false, hideInInspector = false;
while (!s.empty() && s[0] == '[')
{
size_t close = s.find(']');
std::string attr = s.substr(1, close - 1);
if (attr.find("SerializeField") != std::string::npos) hasSerializeField = true;
if (attr.find("HideInInspector") != std::string::npos) hideInInspector = true;
s = Trim(s.substr(close + 1));
}
if (hideInInspector) return;

// Parse modifiers: public, private, static, const
std::vector<std::string> tokens;
std::stringstream ss(s);
std::string token;
while (ss >> token) tokens.push_back(token);

bool isPublic = false, isStatic = false, isConst = false;
size_t i = 0;
for (; i < tokens.size(); ++i)
{
if (tokens[i] == "public") isPublic = true;
else if (tokens[i] == "static") isStatic = true;
else if (tokens[i] == "const") isConst = true;
else if (!IsModifier(tokens[i])) break; // Hit the type name
}

// Only serialize public or [SerializeField] fields, skip static/const
if (isStatic || isConst) return;
if (!isPublic && !hasSerializeField) return;

if (i >= tokens.size()) return;
std::string typeName = tokens[i++];

// Map the type
ScriptFieldType fieldType;
if (!MapFieldType(typeName, fieldType)) return;

// Parse field name and default value
if (i >= tokens.size()) return;
std::string fieldName = tokens[i];
std::string defaultValue;

// Extract default value (if any)
size_t eqPos = s.find('=');
if (eqPos != std::string::npos)
defaultValue = Trim(s.substr(eqPos + 1));

ScriptField field(fieldName, fieldType);
AssignDefault(field, defaultValue);
fields.push_back(field);
}

Supported field types: float, int, bool, string, Vector2, Vector3, Vector4. These show up in the Inspector as drag bars, input fields, checkboxes, etc.

Hot Reload

Modifying a script doesn’t require restarting the engine — recompile and reload, and the changes take effect immediately. Each frame we check the script file’s modification time; if it changed, hot reload kicks in:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
bool CSharpScriptComponent::ShouldReload()
{
if (scriptPath.empty() || !fs::exists(scriptPath)) return false;

try
{
fs::file_time_type currentTime = fs::last_write_time(scriptPath);
if (currentTime > m_lastWriteTime)
return true;
}
catch (const fs::filesystem_error&) {}

return false;
}

void CSharpScriptComponent::HotReloadScript()
{
DITTO_LOG_INFO_STREAM("[CSharpScript] HotReload: " << scriptPath);

// Unload old instance
if (scriptInstance)
{
MonoRuntime::UnloadScript(scriptInstance);
scriptInstance.reset();
}

// Recompile
static int s_reloadCounter = 0;
s_reloadCounter++;
std::string uniqueName = fs::path(scriptPath).stem().string() + "_" + std::to_string(s_reloadCounter);
std::string newDllPath = "Temp/" + uniqueName + ".dll";

if (!CSharpScriptSystem::CompileScript(scriptPath, newDllPath))
{
DITTO_LOG_ERROR("[CSharpScript] HotReload compile failed");
return;
}

// Load new instance
scriptInstance = MonoRuntime::LoadScript(newDllPath, scriptName);
if (!scriptInstance) return;

// Re-link to GameObject
if (gameObject)
{
MonoClass* klass = MonoRuntime::GetClassFromObject(scriptInstance->instance);
MonoMethod* setNativeMethod = MonoRuntime::GetMethod(klass, "SetNativeGameObject", 1);
if (setNativeMethod)
{
void* goPtr = gameObject;
void* args[1] = { &goPtr };
MonoRuntime::InvokeMethod(scriptInstance->instance, setNativeMethod, args);
}
}

// Re-parse fields
ParseScriptFields();
started = false;

m_lastWriteTime = fs::last_write_time(scriptPath);
}

Usage Example

A simple rotation script:

1
2
3
4
5
6
7
8
9
10
11
12
13
using DittoEngine;

public class RotateScript : MonoBehaviour
{
public float speed = 50.0f;
public Vector3 axis = new Vector3(0, 1, 0);

void Update()
{
float deltaTime = Time.deltaTime;
transform.rotation += axis * speed * deltaTime;
}
}

Drop this script onto a GameObject and it’ll start rotating at runtime. Change speed or axis and the change takes effect immediately.

Shader Programming

img

Introduction

Beyond game logic, rendering effects also need to be programmable. The engine ships with a few built-in shaders (Phong lighting, sprite rendering, etc.), but users will inevitably want custom effects — toon shading, outlines, dissolves, and so on. So we need to support writing and loading custom shaders.

Shader Resource Management

Shader files (.shader) live under Assets/Shaders. When the engine starts, it scans that directory and loads all shaders:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
void Renderer::LoadShaders()
{
std::string shadersPath = "Assets/Shaders";
if (!fs::exists(shadersPath)) return;

for (const auto& entry : fs::recursive_directory_iterator(shadersPath))
{
if (entry.path().extension() == ".shader")
{
std::string name = entry.path().stem().string();
LoadShader(name, entry.path().string());
}
}
}

void Renderer::LoadShader(const std::string& name, const std::string& path)
{
// Parse the shader file, extract vertex and fragment source
ShaderSource source = ParseShaderFile(path);

// Compile and link
GLuint program = CompileShaderProgram(source.vertexSource, source.fragmentSource);
if (program == 0)
{
DITTO_LOG_ERROR_STREAM("[Renderer] Failed to compile shader: " << name);
return;
}

// Cache
m_shaderCache[name] = program;
DITTO_LOG_INFO_STREAM("[Renderer] Loaded shader: " << name);
}

Shader File Format

Shader files borrow from Unity’s ShaderLab, but the underlying language is HLSL/CG (not GLSL):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
Shader "Ditto/Lit_Toon"
{
Properties
{
_MainTex ("Main Texture", 2D) = "white" {}
_Color ("Color", Color) = (1, 1, 1, 1)
}

SubShader
{
Tags { "RenderType" = "Opaque" "Queue" = "Geometry" }
Pass
{
CGPROGRAM
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float2 uv : TEXCOORD0;
};

struct v2f
{
float4 pos : SV_Position;
float3 worldPos : TEXCOORD0;
float3 normal : TEXCOORD1;
float2 uv : TEXCOORD2;
};

v2f vert(appdata v)
{
v2f o;
o.pos = ObjectToClipPos(v.vertex);
o.worldPos = mul(ObjectToWorld(), v.vertex).xyz;
o.normal = ObjectToWorldNormal(v.normal);
o.uv = v.uv;
return o;
}

fixed4 frag(v2f i) : SV_Target
{
float3 normal = normalize(i.normal);
float3 lightDir = WorldSpaceLightDir(i.worldPos);
float ndotl = max(0.0, dot(normal, lightDir));
float toon = ndotl > 0.5 ? 1.0 : 0.45;

fixed4 albedo = tex2D(_MainTex, i.uv) * _Color;
fixed3 lit = albedo.rgb * LightColor0.rgb * toon;
return fixed4(lit, albedo.a);
}
ENDCG
}
}

Fallback "Ditto/Lit_Toon"
}

A few things worth noting:

  • The shader name uses the Category/Name format (e.g. Ditto/Lit_Toon)
  • The Pass is wrapped in CGPROGRAM/ENDCG, written in standard HLSL/CG
  • Parameters declared in the Properties block (_MainTex, _Color) are exposed in the Inspector
  • Tags mark the render type and queue
  • Fallback specifies a fallback shader

Shader Parsing

Properties parsing lives in its own module, Engine/Graphics/Shaders/ShaderAsset.cpp. Plain string::find doesn’t cut it because the Properties block contains nested braces (e.g. texture default values like "white" {}); a naive search for } will terminate too early. So we use std::regex combined with brace-depth tracking:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
static void ParseProperties(const std::string& source, ShaderAsset& asset)
{
// 1. Find the start of the Properties block
size_t props = source.find("Properties");
if (props == std::string::npos) return;
size_t open = source.find('{', props);
if (open == std::string::npos) return;

// 2. Track brace depth to find the matching '}'
int depth = 1;
size_t close = open + 1;
for (; close < source.size(); ++close)
{
if (source[close] == '{') ++depth;
else if (source[close] == '}' && --depth == 0) break;
}
if (close >= source.size()) return;

// 3. Extract block content and use regex to match each type
std::string block = source.substr(open + 1, close - open - 1);

// Color type: _Color ("Color", Color) = (1, 1, 1, 1)
std::regex colorRe(R"SHADER(([_A-Za-z]\w*)\s*\(\s*"([^"]*)"\s*,\s*Color\s*\)\s*=\s*\(\s*([-+0-9.eE]+)\s*,\s*([-+0-9.eE]+)\s*,\s*([-+0-9.eE]+)\s*,\s*([-+0-9.eE]+)\s*\))SHADER");

// Float type: _Speed ("Speed", Float) = 1.0
std::regex floatRe(R"SHADER(([_A-Za-z]\w*)\s*\(\s*"([^"]*)"\s*,\s*Float\s*\)\s*=\s*([-+0-9.eE]+))SHADER");

// Range type: _Power ("Power", Range(0, 10)) = 1
std::regex rangeRe(R"SHADER(([_A-Za-z]\w*)\s*\(\s*"([^"]*)"\s*,\s*Range\s*\(\s*([-+0-9.eE]+)\s*,\s*([-+0-9.eE]+)\s*\)\s*\)\s*=\s*([-+0-9.eE]+))SHADER");

// Texture type: _MainTex ("Tex", 2D) = "white" {}
std::regex textureRe(R"SHADER(([_A-Za-z]\w*)\s*\(\s*"([^"]*)"\s*,\s*2D\s*\)\s*=\s*"([^"]*)"\s*\{\s*\})SHADER");

// ... iterate over each type using std::sregex_token_iterator
}

Why not just use string::find("}") to terminate? Because the Properties block contains nested {} (e.g. the texture default value "white" {}). For this kind of lexical nesting problem, the right approach is brace-depth tracking — which is also why this parser lives in its own ShaderAsset module: it’s complex enough that it doesn’t belong scattered inside Renderer.

The CGPROGRAM/ENDCG blocks in a Pass use the same idea: start from CGPROGRAM, track depth until the matching ENDCG, and everything in between is the complete vertex+fragment shader source.

Material

A Material is bound to a Shader and stores the values of the shader’s parameters:

1
2
3
4
5
6
7
8
9
10
11
struct Material
{
std::string shaderName;
std::map<std::string, float> floatParams;
std::map<std::string, glm::vec4> colorParams;
std::map<std::string, std::string> textureParams;

void SetFloat(const std::string& name, float value) { floatParams[name] = value; }
void SetColor(const std::string& name, const glm::vec4& value) { colorParams[name] = value; }
void SetTexture(const std::string& name, const std::string& path) { textureParams[name] = path; }
};

Material files (.mat) are saved as JSON:

1
2
3
4
5
6
7
{
"shader": "Ditto/Lit_Toon",
"parameters": {
"_Color": [1.0, 0.5, 0.3, 1.0],
"_MainTex": "Textures/character_diffuse.png"
}
}

Using Shaders

At render time, shader parameters are set according to the Material:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
void Renderer::RenderWithMaterial(const Material& mat)
{
GLuint program = GetShader(mat.shaderName);
glUseProgram(program);

// Set float parameters
for (const auto& [name, value] : mat.floatParams)
{
GLint loc = glGetUniformLocation(program, name.c_str());
if (loc != -1) glUniform1f(loc, value);
}

// Set color parameters
for (const auto& [name, value] : mat.colorParams)
{
GLint loc = glGetUniformLocation(program, name.c_str());
if (loc != -1) glUniform4fv(loc, 1, &value[0]);
}

// Set texture parameters
int texUnit = 0;
for (const auto& [name, path] : mat.textureParams)
{
GLint loc = glGetUniformLocation(program, name.c_str());
if (loc != -1)
{
GLuint tex = LoadTexture(path);
glActiveTexture(GL_TEXTURE0 + texUnit);
glBindTexture(GL_TEXTURE_2D, tex);
glUniform1i(loc, texUnit);
texUnit++;
}
}

// Draw...
}

Shader Hot Reload

Just like scripts, shaders also support hot reload. After modifying a .shader file, the engine automatically recompiles and reloads it:

1
2
3
4
5
6
7
8
9
10
11
12
13
void Renderer::CheckShaderReload()
{
for (auto& [name, path] : m_shaderPaths)
{
auto lastWriteTime = fs::last_write_time(path);
if (lastWriteTime > m_shaderModifyTimes[name])
{
DITTO_LOG_INFO_STREAM("[Renderer] Reloading shader: " << name);
LoadShader(name, path);
m_shaderModifyTimes[name] = lastWriteTime;
}
}
}

Call CheckShaderReload periodically in the editor’s update loop, and you’ve got hot reload.

Closing Notes

At this point, the engine’s scripting system is in pretty good shape. C# scripts can access all the engine’s functionality — Transform, Rigidbody, Input, Physics, Audio, etc. — and the hot reload loop makes iteration fast. The shader system makes rendering effects programmable, so users can write custom lighting models, post-processing effects, and so on.

Still a long way from Unity and UE, but plenty for a personal project. If I keep filling in the gaps, the next targets would be animation, particle systems, navigation meshes, and so on — though probably not anytime soon. The thesis is done, but spring recruitment continues, so I can’t really stop and rest just yet.