BLOOD CHURCH

by cassandra lugo // portfolio // resume // email // RSS

00010101

`— title: ‘Making a simple visual novel with MoonWorks, Ink, and MoonTools.ECS’ date: 2024-10-20T12:00:00-07:00 draft: true tags: [‘game development’, ’tutorial’, ‘moonworks’, ‘ink’, ‘ECS’]

This is a tutorial on how to make a simple visual novel using MoonWorks , MoonTools.ECS, and ink. It is aimed at a broad audience. No particular familiarity with programming is required, though one may find the learning curve steep if you have absolutely no programming experience.

If you’re new to programming, I highly recommend that you type out all the code rather than just copying and pasting it. this sounds dumb but it really does help you internalize it better.

Click me!

This is an html <details> tag. I’m going to use these throughout the tutorial to answer questions you may have or provide additional details for the curious. If you have a question I don’t answer, or are confused by something, let me know! I might just add an explanation to the tutorial behind one of these.

What's a game engine? How is it different from a framework? These terms don't really have hard and fast definitions. Some people would consider what we're doing to be making a custom engine. I think this is fair, but it also makes it sound much more intimidating and complicated than it really is. I prefer to think of us as making a video game, not a game engine. We're just doing it with a game framework, MoonWorks, and some custom tools, instead of using a game engine.
I heard someone say making a custom engine is a bad idea and you'll never finish a game if you make one.

This isn’t a question but i’ll answer it anyway.

When people hear “game engine,” they mostly think of an extremely complex tool designed to provide as much of what you need to make a game as possible. These are things like Unity, the Unreal Engine, and Godot. Therefore, when you hear “custom engine,” you might assume that someone is trying to replicate the functionality of these engines, which have hundreds of developers who have worked for years or decades to make them, but all by themselves. You’d be correct to think that’s really stupid!

The thing is, building a tool that does something complex that can work for everyone who could possibly want to use it is really hard. Impossible, actually. Which is why these engines like Unreal, Unity, and Godot don’t do that: they make assumptions about how you’re going to want the tools to behave. Sometimes they’re right, but sometimes they’re wrong, and you have to fight the program to get it to do what you want. Sometimes they try to compensate for this by adding options, and this can help, but it increases the complexity of the tool, which makes it both harder to develop (leading to bugs) and harder to use (leading to frustration).

When we make custom tools, we’re not trying to replicate all the functionality of Unity or Unreal. We’re making exactly what we need. Making a tool that suits the needs of everyone who could possibly want to use it is surprisingly hard, but making a tool that just suits the needs of your project can be surprisingly easy. This is why the golden rule of custom tech is this: you are not making an engine, you are making a game. an engine is nothing without a game. orient all the decisions you make about your engine around the specific game you are making.

What is MoonWorks? MoonWorks is a framework for making video games in C# made by my friend Evan Hemsley for use in his game Samurai Gunn 2. He's kindly released his hard work to the general public for free!

MoonWorks exists to solve a few specific problems, and then get out of your way. One of its goals is to allow the easy creation of cross-platform games. This is more challenging than it might sounds. Windows, Linux, and macOS all have completely different APIs for creating windows, drawing to them, playing sound, handling user input, and more. This is to say nothing about consoles, which all have even more baroque ways of handling these things.

To address this, MoonWorks is built on another library called SDL. SDL is a C library that provides a simple interface that, behind the scenes, interfaces with every API you need to make games that run on every major operating system and console, ideally without you having to think about it. You tell SDL to create a window, and it will then determine what platform it’s on and call the appropriate code to create a window on that platform.

Recently as of this writing, Evan finished merging the work he did on MoonWorks into SDL3 as SDL_GPU. All those differences I just mentioned are just on the CPU side. Different platforms also all have different APIs for sending instructions to the GPU to actually draw to the screen! Windows, Linux, and the Nintendo Switch all use the open-source API Vulkan, but the Xbox platforms all require the use of Microsoft’s proprietary DirectX API, and shipping on macOS requires the use of Apple’s proprietary Metal API. SDL_GPU does for the GPU what core SDL does for the CPU and operating system: it provides a simple and standardized interface so you can send shaders and geometry data to the GPU and tell it to draw things without having to write separate code for every platform you’re shipping your game on.

So far, all this good stuff is coming from SDL, so why not just use SDL instead of MoonWorks? Well, first, MoonWorks lets us program in C# instead of C. C# offers us memory safety, which prevents many common bugs, as well as a powerful and flexible static type system, which turns many things that would be runtime errors in C into compile time errors that we can fix more easily. C# also has a more robust standard library with less error-prone APIs for common operations like string handling and file I/O.

MoonWorks also depends on things other than SDL, and gives us access to power beyond what SDL alone can provide. MoonWorks gives us a powerful abstraction over the FAudio API, which is much more powerful and much easier to use than the SDL_mixer API. It also gives us access to Wellspring, Evan’s MSDF font rendering technology, which provides much higher quality font rendering than SDL_ttf and stores the fonts in textures that take up much less graphics memory than standard font bitmaps. Finally, MoonWorks gives us dav1dfile, an API for playing back AV1 compressed video.

All of this is why MoonWorks is my preferred tool for making video games these days.

What is ECS? ECS stands for Entity Component System. This is a software design pattern first created in 2007 for the game Operation Flashpoint: Dragon Rising. Over the following 10+ years, people have discovered that it's actually really generally useful as an approach for programming games.

To understand why ECS is valuable, it’s helpful to consider what people did before ECS.

The most common way of programming games before ECS, and still a very common way today, is called the “actor model.” This is the architecture used by Unity, Godot, and the Unreal Engine, among many, many others.

The goal of both of these programming architectures is to maximize code reuse. We don’t want to write identical or nearly-identical code in a bunch of places in our project. It’s more work, and it causes bugs.

The actor model attempts to achieve code reuse via a programming language feature called inheritance. You have a root class, usually called Actor, and everything in your game inherits from this class. Inheritance allows sub-classes to use code from their parent class, which sounds pretty useful.

The problem comes because in video games, we often have objects that need to mix and match lots of behaviors from lots of different classes. It would often make sense to inherit from more than one parent class. what’s wrong with this?

The main problem is called the “diamond problem”. imagine four classes: A, B, C, and D. A inherits from B and C, and B and C inherit from D. B and C both override functionality in D. which overridden method is A supposed to use?

As a result of the diamond problem, and many other issues, many object oriented programming languages forbid multiple inheritance. or they have heavy restrictions to avoid these problems.

The result is that we have to use all sorts of awkward patterns to share code. Helper functions, giant manager classes, and “tight coupling,” which is when all your actors reference lots of other actors. When systems are very tightly coupled, changing the behavior of one class can easily result in unintentional changes in the behavior of other systems. If you’ve spent a long time making games in Unity, Unreal, or Godot, you’ve seen this before.

ECS attempts to address this. If you’ve done a lot of object-oriented programming, you’ve probably heard the phrase “composition over inheritance.” This is the idea that rather than using inheritance to code share, you should have a class hold references to other classes that implement the behavior it needs. ECS is, in a sense, this notion taken to its logical conclusion.

Tather than actors, ECS has “entities.” An entity is just an ID number, associated with a list of components. Entities contain no logic.

A component is just data, associated with an entity. Components also contain no logic, just data.

All the logic goes into Systems, which use efficiently implemented filters to find entities with certain components. They can create entities, destroy entities, add components, modify components, and remove components.

It’s difficult to explain the massive shift this causes in how you think about architecting games. Rather than trying to explain it to you, it’s better if I show you: look at the rest of this tutorial!

What is MoonTools.ECS? MoonTools.ECS is an ECS library developed by Evan Hemsley for his game Samurai Gunn 2, influenced by more than a decade of commercial game programming experience.

In general, the philosophy of MoonTools.ECS is that it is built for leisure, not for speed. It is fast, and Evan has spent a lot of time making it as fast as it possibly can be, but the driving principle behind its design has always been ergonomics.

What is ink? Ink is a narrative scripting language developed by Inkle studios for their games such as A Highland Song, 80 Days, Heaven's Vault, and Overboard. It's designed to be very friendly to use for writers to make complex branching narratives. Its interpreter is programmed in C#, which makes it very easy to integrate. I'm not going to be covering the ins and outs of Ink programming in this tutorial, so if you want to learn more, check out [the official documentation](https://github.com/inkle/ink/blob/master/Documentation/WritingWithInk.md). It's very good!
What's a visual novel? Why are we making one? A visual novel is a popular kind of narrative game from Japan. The genre began in 1992 with the release of Otogiriso for the Super Famicom. Visual novels present the player with text and images to tell a story, and often (but not always) allow the player to make choices that affect the outcome of the story. Visual novels are closely related to, but not to be confused with, both dating simulators (such as Tokimeki Memorial) and Japanese graphical adventure games (such as The Portopia Serial Murder Case). Some examples of popular visual novels include *Tsukihime*, *Higurashi When They Cry*, *428: Shibuya Scramble*, *Dream Daddy: A Dad Dating Simulator* (which is a visual novel and not a dating simulator, despite its name), and *Slay the Princess*.

Why make a visual novel? Because this tutorial is going to be focused on the fundamentals of computer graphics, and visual novels are very little other than simple computer graphics. We’re going to learn how to put images and text on the screen from as close to scratch as I can realistically recommend you go.

Also, many people perpetuate the complete falsehood that ECS is “bad for UI programming,” and I figure the best way to disprove this statement is to make a game that is almost 100% UI!

One more word before we get started: programming is hard. Making video games is hard. Everyone finds it hard. So don’t feel like you’re dumb if it’s hard to wrap your head around some of the concepts in here. Stick with it, and you’ll be an expert in no time!

I. Setup

In this section, we will go over all the prerequisite setup tasks necessary before we actually start programming. We’ll learn about installing Git, .NET, and a programming-oriented text editor, creating a .NET project using the command line, and using Git submodules to manage our dependencies.

i. Prerequisites

There’s a few things you have to have installed before we can get started:

Follow the instructions on their respective pages to install all of these.

If you’re on Windows, make sure you install Git Bash while you’re installing Git for Windows.

Help! I don't know how to use the command line! Don't panic! It's really quite straightforward.

On Windows, once you’ve installed Git, you should have a program on your computer called Git Bash. If you’re on macOS, you should have a program already on your computer called “Terminal.” If you’re on Linux: Don’t lie to me, you already know how to use the command line.

When you open these programs you’ll be presented with a box where you can type commands and the computer will do them. We use the command line for a bunch of reasons, namely that, especially when writing custom tools, command line programs are much easier to develop and maintain than graphical ones.

When you type a command, first you type the command’s name, like ls, cd, vim, git, etc. Then, separated by spaces, you can provide arguments to the command.

The two most important commands are:

ls, which lists every file and folder in the current directory.

And cd, which stands for “change directory.” you can type cd and then the name of a folder to move into that folder. you can also use the absolute path, like C:/Users/You/Documents/, or reference folder inside of folders in the current directory, like ./SomeFolder/SomeOtherFolder/. The . means “the current directory,” and you can also type cd .. to go up a directory, cd ../../ to go up two directory levels, etc.

So, for example, you could run ls in my home folder, (called ~ by convention), which is the default location for a terminal. You would then see:

cass@asuka ~> ls                                             
Applications/ Documents/    Movies/       Public/       blog/
Desktop/      Downloads/    Music/        Splice/       go/
Developer/    Library/      Pictures/     VulkanSDK/    org/

If you run ls on its own like this, it shows you what’s in the current directory. If you run it with an argument, like ls Documents, it can show you what’s in the directory you’ve passed as an argument.

I could then type cd blog and would then move into the blog directory, where I keep the files that make up this very website. If i ran ls again, I would see:

cass@asuka ~/blog (main)> ls                                 ``
archetypes/    cutcontent.md  i18n/          public/
assets/        data/          layouts/       static/
content/       hugo.toml      openring.sh*   themes/archetypes/    cutcontent.md  i18n/          public/
assets/        data/          layouts/       static/
content/       hugo.toml      openring.sh*   themes/

You can run cd on its own without an argument, but there’s no reason to ever do that because it does nothing.

If you’ve ever played an old text adventure game like Zork or Colossal Cave Adventure, you can think of cd as kind of like saying MOVE, and ls as kind of like saying LOOK.

Note also how the prompt cass@asuka ~/blog (main)> has changed to reflect my new location in the directory blog. My prompt is also configured to show me in parentheses which git branch I’m on.

This probably looks different to your command prompt. If you’re on Linux or using Git Bash for Windows, you’re probably using bash, and if you’re on macOS, you’re probably using zshell. Bash and zshell are called “shells,” and a shell is the computer programmer term for the program that lets you, the user, interact with the operating system. Bash and zshell are command line shells, but the graphical environment you’re familiar with in Windows or macOS is also a shell, just a graphical one.

My prompt probably looks different from yours because I use a newer, less popular shell called fish, but you don’t have to worry about that if you don’t want to.

You can create a new directory from the command line with the mkdir command. You can create a file from the command line with the touch command.

Anyway, there’s a lot more to using the command line than this, but we’ll go over it as necessary.

From within Git Bash on Windows or your Terminal on macOS and Linux, cd into the folder where you want to keep your project. Then run the following commands, one after another:

mkdir lib
mkdir src
dotnet new console -n VNTutorial -o src/
dotnet new sln
dotnet sln VNTutorial.sln add ./src/VNTutorial.csproj

This will create two new directores: src (source) and lib (libraries). Then, it will create a new .NET project in your src. I named mine VNTutorial, but you can name yours whatever you like; just substitute that name in whenever you see VNTutorial. This consists of two files: Program.cs and VNTutorial.csproj. Program.cs is our first and currently only source file. It contains the code that makes up our program. Right now, that’s just this:

// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");

The first line is a comment. Comments are informational text that you can put in your code to give more information to yourself about what different things do. They have no impact on the final program, they’re just to provide additional information.

The second line is a method call, or a function call. In this case, it’s to the method WriteLine in the namespace Console. Inside the parentheses are the arguments, in this case just one argument, the string "Hello, World!".

What's a string?

string is the main type used to hold textual data in most programming languages. It’s some characters in a specific order. Most programming languages have you wrap the text in quotes. You can type quotes themselves using a backslash, like "Hello, \"World!\"", which would encode the text Hello, "World!" The term “string” comes from symbolic logic, which is why it doesn’t make any sense. Sorry.

Next, we use the command dotnet new sln to create a new Visual Studio Solution file. In just a bit we’re going to be editing the .csproj files by hand, but I recommend only ever editing .sln files with the dotnet sln command, because they’re really finicky. Here, we use dotnet sln add to add the .csproj file we just created to the solution.

A solution file is C#’s way of grouping together all your .csproj files that you use to create your application. We’re going to have a few more .csproj files from our other dependencies, so this is important.

Your overall file structure should now look something like this:

- VNTutorial/
    - VNTutorial.sln
    - lib/
    - src/
        - obj/
        - Program.cs
        - VNTutorial.csproj
    

Back in the command line, if you cd into src and type dotnet run, you should see the text “Hello, world!” appear in your terminal. If it does, you set up everything correctly! Congratulations!

ii. Installing MoonWorks, MoonTools.ECS, and ink

First, we need to set up git. Git is a version control software, which means it helps us keep track of the changes we make to our code.

First, create a file in your project folder next to the solution file called .gitignore. Yes, just the extension, no filename. .gitignore is the special file that git checks to find what it needs to… ignore! In that file, put the following lines:

bin/
obj/

bin and obj are the folders where the C# build system puts the output of the build process, and we don’t want to track those in version control because they’re binary files that are a purely deterministic output of the code.

Next, run git init from the command line in your project folder. This will create a new git repository. Next, run git add -A. This stages every file in the directory so the changes will be included in the next commit. Now, run git commit -m "init". You can put anything you want between the quotes, but generally you want it to give a concise indication of what you changed. This will store the changes in your git repository forever, so in the future, you’ll always be able to “checkout” this commit and go back to the way things were.

This is not a git tutorial, git is an enormous topic best covered in its own tutorial. For now, just run the commands I tell you to. In general, you’ll want to run git add -A and git commit -m "some message" every time you add a new feature to your game, or make some other significant change.

We’re setting up git primarily so we can use a feature called submodules. Git submodules allow us to include other git repositories inside our repository.

Create a new directory called lib (short for “libraries”) in the project folder with the command mkdir lib. cd into lib and run the following commands, one after another:

git submodule add https://github.com/MoonsideGames/MoonWorks
git submodule add https://github.com/MoonsideGames/MoonTools.ECS
git submodule add https://github.com/inkle/ink
git submodule update --init --recursive

This will download all of our dependencies, and git submodule update --init --recursive will recursively downloud our dependencies dependencies, and those dependencies dependencies, etc, to make sure we have all the code we need.

Now, when you ls, you should see three directories in the lib folder called MoonWorks, MoonTools.ECS, and ink.

Next, open VNTutorial.csproj in your text editor. It should look something like this:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

First, we need to make some changes to the settings.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <PublishSingleFile>true</PublishSingleFile>
    <SelfContained>true</SelfContained>
    <Nullable>disable</Nullable>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>

</Project>

We’ve changed Nullable to disable, which will get rid of a lot of annoying warnings. We also need to set AllowUnsafeBlocks to true, because MoonWorks uses unsafe code in several places to interface with its C dependencies. Finally, we set SelfContained and PublishSingleFile to reduce the amount of crap the C# build system generates in the output directory.

Why disable Nullable? What even is it?

Ok. So. null is a special value in many programing languages, including C#, which indicates the absence of a value. null has been called a “billion dollar mistake” by its inventor, Tony Hoare. I generally agree with this sentiment. Other programming langauges, typically those in the functional lineage of ML-type languages, such as Rust, Haskell, and OCaml, instead use “option types” which require the programmer to explicitly handle the case of there being no value. C#, C++, C, Java, and most other programming languages, however, generally don’t require you to handle null values explicitly and will just crash if one appears where you haven’t handled it.

After about 20 years of the status quo, C# attempted to address this with nullable variable annotations, a well-intentioned idea that in my experience doesn’t really do anything. Even with Nullable enabled in the csproj, the annotations are entirely optional and failure to use them is met with a warning, not an error. The MoonWorks and MoonTools.ECS codebases do not use nullable annotations, and so neither will we, because otherwise we’ll drown in a sea of compiler warnings.

What's unsafe code? Why do we need it?

C# is what is called a “managed” language. This means that unlike in C or C++, you do not have direct access to pointers. A pointer is a number that tells you where in memory a value is located, kind of like a street address for your RAM. Instead of pointers, C# relies on the abstraction of “reference types,” where the language runtime handles the pointers for you. When the variable holding the reference is no longer in use, it will be cleaned up by the garbage collector. This prevents memory leaks, and also prevents use-after-free bugs that can cause crashes or worse.

MoonWorks is, however, interfacing with C libraries like SDL, which can only work with raw pointers. unsafe is C#’s way of letting you interface directly with pointers, which lets you interact with C code more easily (and can sometimes let you implement faster algorithms), but having to tag the methods you use pointers in as unsafe communicates to you and everyone who reads the code in the future that they need to be careful not to cause any memory leaks or other memory bugs, because the garbage collector no longer has their backs.

Now, we can add our libraries:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <PublishSingleFile>true</PublishSingleFile>
    <SelfContained>true</SelfContained>
    <Nullable>disable</Nullable>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="../lib/MoonWorks/MoonWorks.csproj">
        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
    </ProjectReference>
    <ProjectReference Include="../lib/MoonTools.ECS/MoonTools.ECS.csproj">
        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
    </ProjectReference>
    <ProjectReference Include="../lib/ink/ink-engine-runtime/ink-engine-runtime.csproj">
        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
    </ProjectReference>
   </ItemGroup>

</Project>

We now add these ProjectReference tags with the Include attribute pointing to the .csproj files of the libraries we want to include.

What is ExcludeFromSingleFile?

Much, much later, when we build this game, we’re going to tell the build system to pack most of our standard library dependencies into the executable instead of creating separate .dll files for them in the build folder. This will just make the folder output cleaner. These dependencies, however, need to be built into separate .dll files to work. ExcludeFromSingleFile tells the build system to do that even if we tell it to create a single-file application.

Now, if you cd into src and run dotnet run again, you should still see Hello, World! in your terminal, now potentially with some additional warnings from the compiler in yellow that you can safely ignore.

There’s one last step to make sure MoonWorks can work correctly. The program compiles correctly now because we’re not using any MoonWorks methods in our code. MoonWorks, however, has additional dependencies that must be pre-built. You can download these from the Moonside Games website, linked in the readme for MoonWorks. Extract the moonlibs folder and place it next to the .sln file, the lib directory, and the src directory.

While you’re at it, add moonlibs/ to the .gitignore:

bin/
obj/
moonlibs/

These are dynamic library files that Evan has pre-built based on the MoonWorks dependencies. You could also build them yourself, if so inclined.

Now, we need to write some additional code to copy these files over to our output directory. Create a file in src called CopyMoonlibs.targets:

<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">

    <Target Name="Runtime ID"  AfterTargets="Build">
        <Message Text="Runtime ID: $(RuntimeIdentifier)" Importance="high"/>
    </Target>

    <ItemGroup Condition="$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))">
        <Content Include="..\moonlibs\x64\**\*.*" >
            <Link>%(RecursiveDir)%(Filename)%(Extension)</Link>
            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
        </Content>
    </ItemGroup>
    <ItemGroup Condition="$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))">
        <Content Include="..\moonlibs\lib64\**\*.*" >
            <Link>%(RecursiveDir)%(Filename)%(Extension)</Link>
            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
        </Content>
    </ItemGroup>
    <ItemGroup Condition="$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))">
        <Content Include="..\moonlibs\macos\**\*.*" >
            <Link>%(RecursiveDir)%(Filename)%(Extension)</Link>
            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
        </Content>
    </ItemGroup>
</Project>

This is a lot of crap to say that every time the project builds, it should check which platform the game is building for and copy the files from the appropriate folder to the build directory.

Now, to actually use this file, we add a line to our VNTutorial.csproj:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="../lib/MoonWorks/MoonWorks.csproj">
        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
    </ProjectReference>
    <ProjectReference Include="../lib/MoonTools.ECS/MoonTools.ECS.csproj">
        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
    </ProjectReference>
    <ProjectReference Include="../lib/ink/ink-engine-runtime/ink-engine-runtime.csproj">
        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
    </ProjectReference>
   </ItemGroup>

	<Import Project="./CopyMoonlibs.targets" />
</Project>

Now, if you dotnet run and cd into src/bin/Debug/net8.0/ and ls, you should see the dependencies in there with names referencing SDL3, FAudio, IRO, Wellspring, and dav1dfile. The exact names will vary depending on whether you’re on Windows, macOS, or Linux.

A note for macOS users

You may run into some trouble in the next steps. This may be because macOS’s dynamic linker is dumber than rocks. To work around this, cd into the moonlibs/macos directory and run the following commands:

install_name_tool -add_rpath <YOUR PROJECT DIRECTORY>/moonlibs/macos/ libFAudio.0.dylib
install_name_tool -add_rpath <YOUR PROJECT DIRECTORY>/moonlibs/macos/ libIRO.1.dylib
install_name_tool -add_rpath <YOUR PROJECT DIRECTORY>/moonlibs/macos/ libSDL3.0.dylib
install_name_tool -add_rpath <YOUR PROJECT DIRECTORY>/moonlibs/macos/ libdav1dfile.1.dylib
install_name_tool -add_rpath <YOUR PROJECT DIRECTORY>/moonlibs/macos/ libWellspring.1.dylib

This will tell macOS to look in the correct place to find these dylibs. Make sure to provide the full, absolute path to the dylib locations, so for instance mine would be ~/Documents/Projects/VNTUtorial/.

II. An Intro To Computer Graphics

In this section, we’ll build out the renderer for our visual novel, and learn the basics of modern computer graphics in the process. We’ll learn how to create a window for our game to draw to, how to draw to the screen, and how to take advantage of the GPU for efficient rendering of 2D sprites. We’ll also learn about font rendering and how to draw text to the screen.

i. Creating a window

The first step to making a game is creating a window for the game to live in. First, delete the contents of Program.cs.

The first thing we need to do is add our using directives and namespace:

using System;
using MoonWorks;
using MoonWorks.Graphics;

namespace VNTutorial;
What's using? `using` is how we tell C# which parts of the standard library and other library code we want to use in a given file. We have to be explicit about it because we don't generally want everything available everywhere, because lots of things have the same name in different places and that would be confusing. We just want the stuff we actually need, so we use `using`.
What's a namespace? A namespace is a way of grouping together classes, methods, types, etc under a common name to make it easy to reference, and to allow references to be disambiguated. For instance, both the `MoonWorks` and `System` namespaces have classes within them called `Math`, so giving them namespaces lets us specify whether we're referring to `MoonWorks.Math` or `System.Math`.

Again, you can call it anything you want, this is just what I’m calling mine.

Now, we need to create a class to represent our program.

class Program : Game
{
    public Program(
        WindowCreateInfo windowCreateInfo,
        FrameLimiterSettings frameLimiterSettings,
        ShaderFormat availableShaderFormats,
        int targetTimestep = 60,
        bool debugMode = false
    ) : base(
        windowCreateInfo,
        frameLimiterSettings,
        availableShaderFormats,
        targetTimestep,
        debugMode
    ) {}

    protected override void Update(TimeSpan delta)
    {

    }

    protected override void Draw(double alpha)
    {
    }

    static void Main(string[] args)
    {
    }
}
What's a class?

In C#, a class is a type of object. Classes are ways of grouping methods together under a common name. You can also create an instance of a class with the new keyword. You can kind of think of the code we write for a class as a prototype or description of what data and functionality that class has. We can then, in other parts of our code, create as many instances of this class as we like, which can each hold different values in its fields.

If you find this confusing, don’t worry, so did I. Just follow along and all will become clear. Maybe also take a look at the official C# documentation.

First we have our class name, Program, the standard entry point name in C#. Our Program class inherits from Game, which is MoonWorks central class that handles the basics of window creation, graphics, and input.

Then we have our constructor, which takes five arguments I’ll explain in more detail in a moment, and passes them to the Game constructor via base.

We then have two empty-for-now override functions, which are special functions from Game. Update is where we’ll put all our game logic, and Draw is where we’ll put all our rendering logic.

Finally, we have Main, the entry point for our program. This method will be called first-thing when our program starts. Let’s fill it in with our setup code:

static void Main(string[] args)
{
    var debugMode = false;

    #if DEBUG
    debugMode = true;
    #endif

    var windowCreateInfo = new WindowCreateInfo(
        "VNTutorial",
        1280,
        720,
        ScreenMode.Windowed
    );

    var frameLimiterSettings = new FrameLimiterSettings(
        FrameLimiterMode.Capped,
        144
    );

    var game = new Program(
        windowCreateInfo,
        frameLimiterSettings,
        ShaderFormat.SPIRV, // change to ShaderFormat.MSL if on macos
        60,
        debugMode
    );

    game.Run();
}

First, we use the C# preprocessor to detect if we’re in DEBUG mode, and set the debugMode variable accordingly. The bit between #if DEBUG and #endif will only run if we’re building in DEBUG mode.

Next, we create a WindowCreateInfo object, which stores our window settings: the title (“VNTutorial”), the width (1280) and height (720), and whether the window should be launched in Windowed or Fullscreen mode. (here, Windowed).

next we set up the FrameLimiterSettings, setting up a framerate capped at 144FPS.

TODO: Why cap the framerate?

Finally, we create our game by running the Program constructor and passing in our settings objects, and run it with game.Run().

If you run dotnet run now, you should see a window with a black screen.

Success!

ii. Clearing the screen

The first step to drawing to the screen is, strangely, clearing the screen. In computer graphics, the first step to rendering a frame is (almost) always to clear the frame by writing the same color to every pixel.

Let’s fill in our Draw method to do that:

protected override void Draw(double alpha)
{
    var cmdbuf = GraphicsDevice.AcquireCommandBuffer();
    var swapchainTexture = cmdbuf.AcquireSwapchainTexture(MainWindow);

    if(swapchainTexture != null)
    {
        var renderPass = cmdbuf.BeginRenderPass(
            new ColorTargetInfo(swapchainTexture, Color.CornflowerBlue)
        );
        cmdbuf.EndRenderPass(renderPass);
    }

    GraphicsDevice.Submit(cmdbuf);
}

In MoonWorks, we construct our frames using a CommandBuffer. We “acquire” a command buffer by calling GraphicsDevice.AcquireCommandBuffer and store it in a variable, cmdbuf. We can then call methods on cmdbuf to store commands in the buffer.

We then have to acquire a “swapchain texture.” The swapchain texture is a texture (image) that represents the screen we are going to draw on.

TODO: What's GraphicsDevice?
##
TODO: What's a texture?
What's a swapchain?

To understand the swapchain, let’s first consider what would happen if we tried to just send pixel data directly to the screen.

The game takes time to draw the frame, then your GPU and monitor takes time to display it. Your monitor shows each frame on screen for a fixed amount of time; most monitors show 60 frames every second, but some show more. Most games, however, take less than 1/60th of a second to draw a frame, and the amount of time it takes to draw a frame is not constant and will almost never be a clean fraction of the monitor’s refresh rat. After the game finishes drawing one frame, it will immediately start to draw the next frame.

So what would happen is that the game would draw a frame, the monitor would display it, but in the time it took for the monitor to display that frame, the game would have overwritten part of the frame with data from the next frame. This is what is known as “screen tearing.”

So instead of just using one texture to represent the screen, we use several textures, in a data structure called a swapchain. There are generally at least 3 frames in the swapchain, though there are often more. One frame is the one being shown by the monitor, which the game doesn’t touch.

One of the other frames is the one we get when we call AcquireSwapchainTexture; it’s set aside for drawing and the GPU won’t send it to the monitor to be displayed until we’re done with it.

When the monitor needs to display the next frame, it can grab the least recently updated texture in the swapchain, which are all completed frames. When the game is ready to draw another frame, it can also grab any of the other textures in the swapchain that aren’t the frame being displayed, and start overwriting whatever was already in there with the new frame. When that frame is done, it gets released and can be shown on the monitor.

This completley decouples showing frames from drawing frames. It means that the game can draw frames as fast as it wants, even so much faster than the monitor refresh rate that drawn frames get overwritten by new frames before the monitor can display them. It also means that the monitor always has new frames to pull from, and never has to wait on the renderer to draw.

In the first few frames that our game is running, the swapchain might not be created yet, so we have to check if it’s null before we draw to it.

If it’s not null, we create a new RenderPass with BeginRenderPass. We provide BeginRenderPass with an object called the ColorTargetInfo, which stores the settings for the render pass. In this case, we’re telling it to draw to the swapchainTexture (you can have a render pass draw to any texture you like), and we’re also passing it a “clear color” of CornflowerBlue. The clear color is the color that will be written to each pixel in the texture when the render pass begins.

All we want to do here is clear the screen, so we don’t have to do anything else before calling EndRenderPass.

Finally, we call GraphicsDevice.Submit, which sends the commmand buffer to the GPU, where all our commands will be executed.

If you run the game now, you should see the cornflower blue of success:

iii. Loading an image

Before we can draw to the screen, we need something to draw. Create a new folder in the same place as your lib and src folders called Content, and add it to the gitignore:

bin/
obj/
moonlibs/
Content/

We’ll develop a more principled system for organizing and importing content later, and this folder will eventually be the output folder for our content builder script. For now, we’re going to put a texture in it manually.

Create another folder inside that folder called Textures, and inside that folder, put this image:

(You can also use literally any png image you want.)

Now we need to update our .csproj file to copy our content folder to the build directory where our compiled executable ends up. We can do that with this ItemGroup:

   <ItemGroup>
    <Content Include="..\Content\**\*.*">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </Content>
  </ItemGroup>

This tells the C# compiler to copy everything inside the content folder to the output directory.

Now, in our Program.cs, let’s add a new variable to the top of the Program class:

class Program : Game
{

    Texture Ravioli;

(You can call it something else if you used a different image.)

Now, let’s fill our constructor with the code necessary for loading the image.

public Program(
    WindowCreateInfo windowCreateInfo,
    FrameLimiterSettings frameLimiterSettings,
    ShaderFormat availableShaderFormats,
    int targetTimestep = 60,
    bool debugMode = false
) : base(
    windowCreateInfo,
    frameLimiterSettings,
    availableShaderFormats,
    targetTimestep,
    debugMode
)
{
    var resourceUploader = new ResourceUploader(GraphicsDevice);
    Ravioli = resourceUploader.CreateTexture2DFromCompressed(
        Path.Join(System.AppContext.BaseDirectory, "Textures", "ravioli.png"),
        TextureFormat.R8G8B8A8Unorm,
        TextureUsageFlags.Sampler
    );
    resourceUploader.Upload();
    resourceUploader.Dispose();
}

ResourceUploader is the MoonWorks object we use to load various graphics resources to the GPU. We first create a ResourceUploader, then call CreateTexture2DFromCompressed.

The first argument is the path of the image we want to load, which we construct with Path.Join. To use Path.Join, add using System.IO; to the top of the file where the rest of the using directives are.

System.AppContext.BaseDirectory is a variable that holds the name of the folder where our executable is, no matter where we move it after building. We put ravioli.png in the Textures folder, so we add that as an argument, and then finally we give it the filename.

After that, we need to pass in a TextureFormat. This tells MoonWorks and the GPU how we want the binary data of the image laid out. R8G8B8A8Unorm means that we have four color channels, Red, Green, Blue, and Alpha (transparency), they all take up 8 bits, and those bits represent a floating-point number from 0 to 1 for each color channel (“UNORM”).

TODO: What's a floating-point number?

Next, we have the TextureUsageFlags. Here, we’ve set it as a Sampler, which means a texture that the GPU can read from (“Sample”) to produce color data to send to the monitor. We call this sampling because, depending on how the texture is used, each input pixel of the texture may not correspond to an output pixel on the screen; it may be distorted based on perspective and distance, or the shader may choose output colors by interpolating between two colors in the texture.

Finally, we Upload the resource uploader, and then Dispose of it, because we no longer need it. In a much more complicated game, you might keep a resource uploader around to upload textures on the fly as the game is running.

If you run the game now, nothing should have changed.

iv. Drawing a texture

Now, we can update our Draw method to draw this texture to the screen:

protected override void Draw(double alpha)
{
    var cmdbuf = GraphicsDevice.AcquireCommandBuffer();
    var swapchainTexture = cmdbuf.AcquireSwapchainTexture(MainWindow);

    if(swapchainTexture != null)
    {
        var renderPass = cmdbuf.BeginRenderPass(
            new ColorTargetInfo(swapchainTexture, Color.CornflowerBlue)
        );
        cmdbuf.EndRenderPass(renderPass);

        cmdbuf.Blit(new BlitInfo
        {
                Source = new BlitRegion(Ravioli),
                Destination = new BlitRegion
                {
                    Texture = swapchainTexture.Handle,
                    X = swapchainTexture.Width / 2,
                    Y = swapchainTexture.Height / 2,
                    W = Ravioli.Width,
                    H = Ravioli.Height
                },
                Filter = Filter.Nearest
        });
    }

    GraphicsDevice.Submit(cmdbuf);
}

We keep everything we already wrote, but we add a call to Blit. Blit is the MoonWorks method that lets us copy one texture onto another texture. We have to pass in a BlitInfo object to tell it what textures we want to copy and how. We first specify the source BlitRegion, which in this case is just the entire texture. The destination BlitRegion is more complicated: we want to copy the ravioli onto a region of the swapchain texture that is the same size as the ravioli. We could scale it up by doubling the W and H parameters, and we can move it around the screen with the X and Y parameters. Here I’ve just put it in the center of the screen.

Something that may be surprising to new graphics programmers is the coordinate space we’re working in here: (0, 0) is in the top left corner of the screen, and (Width, Height) (in our case, (1280, 720)) is the bottom right corner of the screen, so to move down the screen we increase the Y coordinate. This may seem counterintuitive at first, but it actually makes a lot of math easier.

TODO: better filter explanation

Finally, Filter is the rule that MoonWorks will follow when it needs to draw a pixel that doesn’t exist in the texture. Imagine we were drawing this texture at 2x scale: how should it handle the extra pixels that just aren’t there in the source texture? Nearest is short for the “nearest neighbor” algorithm, the simplest answer to this question: just use the color of the closest pixel in the source texture. The most common alternative is some kind of linear interpolation, which uses a linear equation to guess what color would come between the nearest pixels of the texture.

If you run the game now, you should see our little ravioli in the middle of the screen:

v. Drawing a texture, but less bad (sprite batching)

So, this technique is really bad and you should almost never use it in a real game. There are several reasons for this.

First, you may notice that while our source ravioli image has transparency, it’s just drawing as black for us. This is because Blit can only be used outside of a render pass, while MoonWorks various transparency blend modes can only be used via a graphics pipeline, which can only be bound inside a render pass. Blit simply can’t handle transparency at all.

Similarly, because Blit operations don’t pass through a pipeline, we can’t use shaders to do any kind of fancy or useful rendering effects on our blitted sprite. It’s always just going to copy the pixels exactly as they appear in the source image.

Most subtly and most importantly, however, this approach has terrible performance. If we were to draw a bunch of sprites like this, each one would be blitted onto the swapchain texture one at a time, in the order we drew them. This is kind of like if you tried to bake cookies by putting each one in the oven one at a time, waiting for it to finish cooking, and then taking it out. We can do better.

There’s more, though: If we were to render multiple sprites with this approach, we would have to sort them by depth and draw them from back to front in order for them to render correctly, and this sorting step would be very inefficient.

Fortunately, graphics programmers have developed rendering techniques that allow us to draw all of our sprites at once, and to have the GPU always draw them in the correct order without having to sort them first.

Strap in, because while the last few sections have been brief, this one will be a bit of a journey.

What we’re about to build is called a “sprite batcher.” There are lots of techniques for sprite batching, and the one we’re going to use is based on the ComputeSpriteBatch example in the MoonWorksGraphicsTests.

The basic idea behind a sprite batcher is that instead of drawing each sprite as separate geometry, we’re going to fuse them all together into a single piece of geometry containing all our sprites, and then draw that in one fell swoop. An important thing to keep in mind here is that in modern computer graphics, there is no such thing as a 2D game. Even when we’re rendering 2D graphics, the GPU “thinks” of them as flat planes made out of vertices and edges positioned in a 3D space.

Now, we could construct this batched geometry on the CPU; there’s an example of that in the graphics tests too, but the thing is that all the operations required to fuse the geometry together are the kinds of matrix math the GPU is really good at. It would really be much better to do these computations in parallel on the GPU instead of in series on the GPU.

Enter compute shaders: a type of shader that lets us run almost any computation on the GPU we want. We’re going to write a simple compute shader that will take in all of our sprites and transform them into the geometry we want for the batching process and pass it on to the vertex shader. This will result in an enormous performance boost compared to the CPU bound approach.

Why care about this kind of extreme performance in a game like a visual novel, though? Well, for one, I think leaving easy-to-get performance on the table for the sake of simplicity is a little silly. In most visual novels, you won’t be drawing more than a dozen or two sprites on screen at a time, and honestly, the terrible Blit method could probably handle that. The real benefit here is the freedom it offers you as an artist and designer.

For instance, if at some point in your game you want to do some kind of really complicated particle effect, if you use this system, you’ll just be able to do it with your normal sprite rendering tech; you won’t have to totally switch gears and use an awkward, highly optimized particle generation subsystem just to see reasonable performance.

In general, I believe in grabbing performance where you can easily get it, to provide as much wiggle room as possible for whatever crazy things you can dream up wanting to add to your game.

this section will be sub-subdivided into yet more individual lettered sections, with the goal that the code should be compilable at the end of each section. However, the result should not change until the very last step.

a. The Structure Of A Renderer

To get started, let’s split out our renderer code from Program.cs. Create a new file in the same directory as Program.cs called Renderer.cs:

using MoonWorks;
using MoonWorks.Graphics;
using MoonWorks.Math.Float;
using Buffer = MoonWorks.Graphics.Buffer;
using MoonTools.ECS;
using System.Runtime.InteropServices;

public class Renderer : MoonTools.ECS.Renderer
{
    public Renderer(World world, Window window, GraphicsDevice graphicsDevice) : base(world)
    {
    }
    
    public void Draw(CommandBuffer cmdbuf, Texture renderTexture)
    {
    }
}

First, some new usings. The moonworks Math library comes in two flavors, Float and Fixed. Fixed is useful for games that use networking and can’t have floating point arithmetic errors without risk of desync. Our game is single-player, so we can use Float.

Buffer is a type in the C# standard library, so we can use this using Buffer = syntax to clarify that whenever we say Buffer, we mean MoonWorks.Graphics.Buffer. We also are going to need System.Runtime.InteropServices for sending our sprite data to the GPU.

We’re also using MoonTools.ECS for the first time, though we won’t be using it for anything in-depth until part IV. We’re just setting up this Renderer class as a MoonTools.ECS.Renderer, which will later give us access to the MoonTools.ECS methods for reading and filtering entities by their components. Inheriting from MoonTools.ECS.Renderer requires us to have a constructor that takes a World argument, and we also are going to take arguments of type Window and GraphicsDevice because we’ll need them.

Let’s hook this up in Program.cs. First, we need to create some new variables at the top of the class:

class Program : Game
{
    World World = new World();
    Renderer Renderer;

World is the MoonTools.ECS object that is going to hold all of our entities and components in our entity component system. Again, we’ll cover it in much greater detail in part IV.

You can also get rid of the Texture Ravioli; line, we’ll be redefining that inside our renderer.

Now, delete the contents of the Program constructor, and replace it with:

public Program(
    WindowCreateInfo windowCreateInfo,
    FrameLimiterSettings frameLimiterSettings,
    ShaderFormat availableShaderFormats,
    int targetTimestep = 60,
    bool debugMode = false
) : base(
    windowCreateInfo,
    frameLimiterSettings,
    availableShaderFormats,
    targetTimestep,
    debugMode
)
{
    Renderer = new Renderer(World, MainWindow, GraphicsDevice);
}

And then, you can replace Draw with:

protected override void Draw(double alpha)
{
    var cmdbuf = GraphicsDevice.AcquireCommandBuffer();
    var swapchainTexture = cmdbuf.AcquireSwapchainTexture(MainWindow);

    Renderer.Draw(cmdbuf, swapchainTexture);

    GraphicsDevice.Submit(cmdbuf);
}

Now we’re passing off the responsibility of actually drawing to our Renderer, and just acquiring the command buffer and swapchain texture and then passing them to the renderer’s Draw method.

b. Shaders And You

Here is where we must take a somewhat lengthy detour and explain how computers even render graphics. The first and most important piece of information to keep in mind is that you have been lied to: there is no such thing as a 2D video game. Well, there used to be, but not anymore.

Now, obviously, many video games sure look 2D, and obviously all video games are actually 2D in that you view them on a flat two-dimensional screen that provides only the illusion of three-dimensional depth.

TODO: explanation of shaders, rendering, etc

Our game needs three shaders. Let’s tackle the vertex and fragment shaders first, because they are the simplest.

Create a new folder in your top-level project folder, alongside lib and src and Content, called ContentSource. This is where we will store our game content that needs to be built before it can be used by our game. I believe it’s best to think of shaders as a kind of content, rather than strictly as source code.

Create a folder within ContentSource called Shaders, and create a file within that folder called TexturedQuadColorWiothMatrix.vert.

#version 450

layout (location = 0) in vec4 Position;
layout (location = 1) in vec2 TexCoord;
layout (location = 2) in vec4 Color;

layout (location = 0) out vec2 outTexCoord;
layout (location = 1) out vec4 outColor;

layout (set = 1, binding = 0) uniform UniformBlock
{
    mat4x4 MatrixTransform;
};

void main()
{
    outTexCoord = TexCoord;
    outColor = Color;
    gl_Position = MatrixTransform * Position;
}

This is the first shader that will run, our vertex shader. This is the entire shader, and it’s all we’ll need.

First, we create our shader inputs and outputs. Unlike the functions you may be used to, these inputs ad outputs take the form of top-level varibles in the file. The inputs are the variables marked in, while the outputs are the variables marked out.

In modern graphics APIs, we also have to specify the layout in memory of our variables, which is as simple as just specifying 0, 1, 2, etc.

After that, we specify our uniforms. While the Position, TexCoord, and Color inputs vary per-vertex, the uniform variables are the same for every vertex in the draw call.

Our only uniform in our UniformBlock is a 4x4 matrix, which represents the combined model, view, and projection matrices for each sprite.

Our main method is only three lines here. We pass the Color and TexCoord variables to the output unmodified.

We then set the special gl_Position variable, which represents the output of the vertex shader, to the input position multiplied by the matrix.

c. Renderer Initialization

Now, back in Renderer.cs, let’s set up the variables we’re going to use.

public class Renderer : MoonTools.ECS.Renderer
{
    Window Window;
    GraphicsDevice GraphicsDevice;

    ComputePipeline ComputePipeline;
    GraphicsPipeline RenderPipeline;
    Sampler Sampler;
    TransferBuffer TransferBuffer;
    Buffer SpriteComputeBuffer;
    Buffer SpriteVertexBuffer;
    Buffer SpriteIndexBuffer;
    Texture Ravioli;
    System.Random Random = new System.Random();

    const int MAX_SPRITE_COUNT = 8192;

Window and GraphicsDevice are storage for the Window and GraphicsDevice we’ll pass in from Program.

MoonWorks’ rendering is built around the concept of “pipelines,” which is inherited from the modern graphics APIs (Vulkan, D3D12, Metal) that it uses behind-the-scenes. A pipeline is a complete set of render settings: what shaders you’re going to use, and all the types of data you’re going to pass into them. We’ll see more in a moment when we actually build these pipelines.

A Sampler is the way that a shader reads from a texture. The sampler will determine the filter that the shader will use when sampling colors from the texture, whether it’s linear or nearest neighbor or something else.

A TransferBuffer is the data structure that is used for packing data to transfer to the GPU.

A Buffer is a simple data structure consisting of a bunch of data packed into a contiguous region of memory. Here, we’re using it to store data for the compute shader, as well as the more typical VertexBuffer and IndexBuffer. The VertexBuffer is a list of every vertex in the object we’re rendering. The IndexBuffer is a list that tells the GPU which order to draw the vertices in.

The reason we need both of these is because most graphics pipelines perform something called back face culling. Basically, each triangle that we render has a front side and a back side. We determine this with something called a winding order, which is the “direction” we think of the vertices as going around the triangle. If we think of, for instance, the “first” vertex (or really, “zeroth”) as being the bottom left, then the next vertex as being the top, and then the next vertex as being the bottom right, we have a “clockwise” winding order. You can also have a counter-clockwise winding order. It doesn’t matter, as long as it’s consistent.

The upshot is that the GPU can tell when rendering which side of the triangle it’s on by whether the vertices are going clockwise or counter-clockwise, and it can use this information to not render “back faces,” which are the faces wound backwards that are going to be pointing towards the inside of objects. This cuts the amount of drawing needed to construct a scene in half, because the GPU doesn’t have to bother drawing triangle faces that are never going to be seen.

This is why, in most video games, if you clip inside an object, you can just see out, you don’t see the “back” or “inside” of the object.

The index buffer also allows us to use fewer vertices in total. A rectangle has four vertices, but we can only make a rectangle out of two triangles stuck together, because the GPU only understands triangles. Naively, you might assume this would be six vertices, three for each triangle, but the vertices on the edge where the triangles meet are going to be in the exact same place! We can use the index buffer to just use the same vertex multiple times so we only have to send four vertices for each rectangle.

TODO: visual example of backface culling

Even though our game is 2D, everything is 3D in the computer, so all our sprites are actually rectangles formed by putting two triangles together on a flat plane. This means they have back faces, and we want to cull those so the GPU doesn’t waste time drawing every sprite twice.

Next we have our Texture Ravioli; declaration from before, and a System.Random object that we’ll use to position the sprites in a bit.

Finally, we have MAX_SPRITE_COUNT: all sprite batching techniqures require you setting a cap on how many sprites you’ll be able to render. For this example, we’re using 8192, better known as 213. This is probably way more than we’ll ever need to draw in a visual novel. You can set it to whatever you want, really.

Next, we need a few data structures. You can just put these inside the Renderer class, between the constructor and the variable declarations. The first is one to store the data we’re going to send to the compute shader:

[StructLayout(LayoutKind.Explicit, Size = 48)]
struct ComputeSpriteData
{
    [FieldOffset(0)]
    public Vector3 Position;

    [FieldOffset(12)]
    public float Rotation;

    [FieldOffset(16)]
    public Vector2 Size;

    [FieldOffset(32)]
    public Vector4 Color;
}

If you’re used to C#, this probably looks different than any struct you’ve ever seen. If you’re unfamiliar with C#, this probably still looks crazy. This is C#’s syntax for specifying the layout of a struct in memory. By default, C# can do things you aren’t expecting with the memory layout of a struct. Within C#, that’s fine and rarely a problem. When we send this data to the GPU, however, it needs to be laid out in a very specific way.

TODO: better explanation of offset

The StructLayout decorator at the top tells C# that we’re explicitly laying out this struct in memory, and that it’s going to be 48 bytes. Each FieldOffset tells C# how many bytes from the start of the struct’s memory adddress to put the variable it’s attached to.

Here, we’re sending to the compute shader four pieces of information about each sprite: its position in 3D space, a rotation angle, its scale along the X and Y axes, and a color tint parameter.

Next, we need a data structure for the vertex data:

[StructLayout(LayoutKind.Explicit, Size = 48)]
struct PositionTextureColorVertex : IVertexType
{
    [FieldOffset(0)]
    public Vector4 Position;

    [FieldOffset(16)]
    public Vector2 TexCoord;

    [FieldOffset(32)]
    public Vector4 Color;

    public static VertexElementFormat[] Formats { get; } =
    [
        VertexElementFormat.Float4,
        VertexElementFormat.Float2,
        VertexElementFormat.Float4
    ];

    public static uint[] Offsets { get; } =
    [
        0,
        16,
        32
    ];
}

While the previous struct was per-sprite, this struct is per-vertex, so there will be four of them for each sprite. Here, we’re storing the position of each vertex (in local space), the location on the sprite texture where the sprite is coming from, and the color tint for that vertex.

Now, let’s fill in our constructor.

public Renderer(World world, Window window, GraphicsDevice graphicsDevice) : base(world)
{
    GraphicsDevice = graphicsDevice;
    Window = window;

    var resourceUploader = new ResourceUploader(GraphicsDevice);
    Ravioli = resourceUploader.CreateTexture2DFromCompressed(
        Path.Join(System.AppContext.BaseDirectory, "Textures", "ravioli.png"),
        TextureFormat.R8G8B8A8Unorm,
        TextureUsageFlags.Sampler
    );
    resourceUploader.Upload();
    resourceUploader.Dispose();

We take the GraphicsDevice and Window and store them in their appropriate variables. Next you’ll see the same texture loading code as before, but in Renderer.cs now.

Now, we load the shaders:

Shader vertShader = Shader.Create(
    GraphicsDevice,
    Path.Join(System.AppContext.BaseDirectory, "Shaders", "TexturedQuadColorWithMatrix.vert.spv"),
    "main",
    new ShaderCreateInfo
    {
        Stage = ShaderStage.Vertex,
        Format = ShaderFormat.SPIRV,
        NumUniformBuffers = 1
    }
);

Shader fragShader = Shader.Create(
    GraphicsDevice,
    Path.Join(System.AppContext.BaseDirectory, "Shaders", "TexturedQuadColor.frag.spv"),
    "main",
    new ShaderCreateInfo
    {
        Stage = ShaderStage.Fragment,
        Format = ShaderFormat.SPIRV,
        NumSamplers = 1
    }
);

d. The Draw Method

III. Content

A game is nothing without content. For us, content means all the parts that make a game worth playing: the art assets, the music and sound effects, and the text that makes up the game’s dialogue and narrative. In this section, we’ll learn how to build a content pipeline that transforms assets freshly exported from their respective disciplines tools into the formats most useful for us to include in the game engine. We’ll learn a new programming language, Python, and use it to build a script to pack sprites into efficient sprite sheets. We’ll also use this python script to perform some basic code generation to allow us to access asset data easily from within our C# code.

IV. User Interface Logic

Now that we have all the assets for our game loading and drawing, it’s time to make the part you might consider “gameplay.” In this section, we’ll focus on learning the basics of the ECS pattern, and using it to make the user interface for our visual novel. We’ll build systems to handle mouse input and click on virtual buttons that we can connect to any functionality we desire. We’ll also touch briefly on how to play sound effects and music.

V. Ink Integration

Now that we have a complete visual novel engine, we’ll build the systems necessary to put a narrative into it. We’ll learn how to use the Ink API to read compiled Ink story data, and how to hook that data up to our existing systems so we can control our game from Ink. We’ll learn how to use Ink not only to control what text is shown on the screen, but also what sprites are used for our characters and backgrounds, and what sounds and music are playing.

VI. Finishing Touches

Now that we have an almost-complete game, we’ll turn it into something you can actually ship. We’ll talk about some basic techniques for game state management so we can build a main menu, pause menu, and save/load system.