Compare commits

...

74 Commits

Author SHA1 Message Date
burkkyy 0f0b0d1434 Merge pull request 'Adding in sandbox + Logger upgrade' (#80) from sandbox into main
Reviewed-on: #80
2026-10-06 01:20:21 -07:00
burkkyy 777e407d67 sandbox simplfy 2026-10-06 01:16:28 -07:00
burkkyy e32e5f911f sandbox copying current application 2026-10-05 02:17:32 -07:00
burkkyy 5b2d50a925 stubbing out conway game of life 2026-10-05 02:16:55 -07:00
burkkyy f6f4f8b7c9 fixes to buffer class 2026-10-05 02:16:37 -07:00
burkkyy b6098d4dc6 New logger 2026-10-04 23:14:17 -07:00
burkkyy b31c120691 bumping project cxx version to 23 2026-10-04 22:54:07 -07:00
burkkyy 3c3fcfdd6b removing fmt 2026-10-04 22:52:07 -07:00
burkkyy 9538add3ac Using version 1.12.0 of fmt in project 2026-10-04 22:43:09 -07:00
burkkyy 31089f3539 adding fmt third_party library + adding cloc support 2026-10-04 22:41:42 -07:00
burkkyy 6cc8bf4262 adding in sandbox 2026-10-04 01:03:47 -07:00
burkkyy c99c720e55 Merge pull request 'Adding 3D support' (#77) from dev/adding-3d-support into main
Reviewed-on: #77
2026-10-02 20:12:46 -07:00
burkkyy 6e48b0605c adding and integrating ImGuizmo 2026-10-02 11:36:33 -07:00
burkkyy 379589fe8c renaming viewport panel to scene panel 2026-09-30 22:10:08 -07:00
burkkyy b5b0eb9497 Huge changes for 3d 2026-09-30 18:53:16 -07:00
burkkyy cd2e80cfca Merge pull request '2D scene rendering + image loading' (#15) from issue-14/2d-scene-support-spritecomponent-2d-render-system into main
Reviewed-on: #15
2026-09-30 15:56:33 -07:00
burkkyy ab7c850121 Move barriers and frame submit to synchronization2
Image barriers now carry their own stage masks and are recorded with
pipelineBarrier2, so barriers flushed together no longer share the
union of their stages. AccessState uses the 64-bit stage and access
flags, and the frame submit uses submit2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:51:33 -07:00
burkkyy 1ba1442cc4 Require GPU-driven device features
Enable descriptor indexing (bindless), buffer device address, scalar
block layout, indirect draw count, shader draw parameters, timeline
semaphores and synchronization2 alongside dynamic rendering. A single
feature list drives both physical device selection and device creation.
The VMA allocator is created with buffer device address support.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:51:33 -07:00
burkkyy 7d87503eba restructuing dev command 2026-09-30 02:42:45 -07:00
burkkyy 9f3ad5f372 Adding tracy command for ease of use 2026-09-30 02:22:55 -07:00
burkkyy 812d50127e Adding in tracy profiler 2026-09-30 02:04:59 -07:00
burkkyy 97ff85ea8a Added VMA 2026-09-30 01:31:59 -07:00
burkkyy fc70475538 renaming dir 2026-09-27 02:02:19 -07:00
burkkyy 0f9c7ffc20 view -> sceneView in app class 2026-09-26 02:18:12 -07:00
burkkyy 0b09aeb5d9 More changes 2026-09-26 02:03:11 -07:00
burkkyy 8a82df59ca cool viewport 2026-09-26 01:50:09 -07:00
burkkyy b24945ec9d Bunch of changes + fixes 2026-09-26 01:49:45 -07:00
burkkyy e05926ad74 simplified build 2026-09-25 23:12:04 -07:00
burkkyy 571472a204 adding screenshot of using engine editor as a showcase 2026-09-25 23:09:56 -07:00
burkkyy a21a842b5b Initial claude dump 2026-09-25 22:59:01 -07:00
burkkyy 5644782a1f Adding in simple alpha blending 2026-09-25 22:34:25 -07:00
burkkyy b44fe94d5c image view example 2026-09-25 22:33:30 -07:00
burkkyy fe516bb1ef helper for starting up editor 2026-09-25 22:23:44 -07:00
burkkyy 171634ca01 stb_image added 2026-09-25 22:23:31 -07:00
burkkyy 97dc08890b Merge pull request 'Add offscreen render targets and rename AssetRegistry to ResourceRegistry' (#8) from dev into main
Reviewed-on: #8
2026-09-25 20:39:26 -07:00
burkkyy 6e12d8a710 changes 2026-09-25 20:39:04 -07:00
burkkyy 341249bb96 scene_view concept 2026-09-25 20:23:12 -07:00
burkkyy 0d145f615d changes 2026-09-25 20:22:29 -07:00
burkkyy 4ebae57cf8 Moving stuff around 2026-09-25 20:21:25 -07:00
burkkyy 09706507cf adding compiled shaders to project 2026-09-25 20:09:51 -07:00
burkkyy 1ccb591959 build system update 2026-09-25 19:58:11 -07:00
burkkyy 86b36f27c6 moving dir name 2026-09-25 19:32:33 -07:00
burkkyy 15932879e8 CLAUDE updates 2026-09-25 19:21:41 -07:00
burkkyy ebc0543535 better dev command 2026-09-25 19:04:01 -07:00
burkkyy 75fc14f4ac handy invalid handle macro 2026-09-24 23:00:45 -07:00
burkkyy b5d28ce3eb math.hpp added 2026-09-24 23:00:02 -07:00
burkkyy ae8db07d6f Simplified handles 2026-09-23 23:24:23 -07:00
burkkyy 97267f1a26 stuff 2026-09-24 05:31:38 +00:00
burkkyy 6a50cdb4e8 Formatting 2026-09-23 00:42:47 -07:00
burkkyy a98eac3dc7 laptop changes 2026-09-23 04:20:43 +00:00
burkkyy 39253771f1 starting scene renderer stuff 2026-09-21 11:34:29 -07:00
burkkyy ce54f2ffcc PM stuff 2026-09-21 01:33:39 -07:00
burkkyy 72e4ae79e2 gitea issue helper 2026-09-21 01:30:21 -07:00
burkkyy 4d037e1250 moving stuff around 2026-09-21 01:15:58 -07:00
burkkyy 650b4d813f moving files to better dirs 2026-09-21 01:10:13 -07:00
burkkyy 8fbabda095 Render Graph concept started! 2026-09-21 01:03:16 -07:00
burkkyy 2569eff130 changes 2026-09-21 00:30:11 -07:00
burkkyy f3c0f61eb1 Big changes 2026-09-21 00:26:50 -07:00
burkkyy 16f7bb7da3 Upgrading pool pattern 2026-09-20 20:52:09 -07:00
burkkyy fe40d4a629 Simply 2026-09-20 18:58:51 -07:00
burkkyy 5e1caabde6 Add offscreen render targets and rename AssetRegistry to ResourceRegistry
Add a RenderTarget resource so the scene can render offscreen instead of
straight to the swapchain. The editor uses it to show the scene inside the
Viewport panel.

- Rename AssetRegistry to ResourceRegistry (AssetTraits -> ResourceTraits,
  assets() -> resources()) and move the pools from engine/src/assets to
  engine/src/resources. It now covers non-asset GPU resources as well.
- Replace loadMesh() with create<T>(...), and add create<RenderTarget>(),
  requestResize(), getExtent() and isAlive().
- Add RenderTarget and RenderTargetPool with RenderTargetHandle.
  Resizes are deferred and applied at the start of the next frame, when the
  GPU is idle.
- Add Application::setSceneTarget(). With no target set, the scene still
  renders to the swapchain. With a target set, the scene renders offscreen
  and the swapchain receives only the UI. Frame extent and camera projection
  follow the target's size.
- Add sophia::UI (Application::ui()) with image() and getTextureId() to draw
  a render target in an ImGui window, sizing the target to the available
  space. ImGuiRenderer now registers the target textures for this.
- ViewportPanel takes the UI and a target, and draws the scene image in place
  of the flat background fill.
- Update the testbed, the boids example, and the glossary for the rename.
2026-09-20 16:21:22 -07:00
burkkyy 1485ce01f3 Merge pull request 'Supporting setting panel layout' (#7) from issue-5/support-default-editor-panel-layout-and-locked-panels into main
Reviewed-on: #7
2026-09-19 22:44:30 -07:00
burkkyy 625ed87538 Removing comment 2026-09-19 22:43:30 -07:00
burkkyy 98b3d4a552 Formatting 2026-09-19 22:40:26 -07:00
burkkyy 0e26ccae98 Removed comment 2026-09-19 22:38:47 -07:00
burkkyy 7d9633fb33 Formatting 2026-09-19 22:36:50 -07:00
burkkyy 7837acdf92 removing comments 2026-09-19 22:36:24 -07:00
burkkyy f814ad969f making config editable 2026-09-19 21:13:41 -07:00
burkkyy da6bda7ca7 Supporting setting panel layout 2026-09-19 19:41:39 -07:00
burkkyy ace69f613e Merge pull request 'Made console command registry a better usage pattern' (#4) from dev into main
Reviewed-on: #4
2026-09-19 00:57:44 -07:00
burkkyy e72ada5b40 branch-from command 2026-09-19 00:56:40 -07:00
burkkyy dffb7b7972 Changes:
- editor project
- build system fixes
- dummy data editor panels mock
2026-09-16 02:28:42 -07:00
burkkyy 169ff4a277 Made console command registry a better usage pattern 2026-09-16 01:59:34 -07:00
burkkyy 8824f64225 Merge pull request 'Huge changes' (#3) from dev into main
Reviewed-on: #3
2026-09-15 02:36:50 -07:00
176 changed files with 15528 additions and 1343 deletions
+8
View File
@@ -0,0 +1,8 @@
third_party
build
__dump
traces
.vscode
.claude
.git
imgui.ini
+31
View File
@@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: ""
labels: ""
assignees: ""
---
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Something
2. ...
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.
+23
View File
@@ -0,0 +1,23 @@
---
name: Feature request
about: Suggest an idea for this project
title: ""
labels: ""
assignees: ""
---
Relates to:
- TODO
## Context
TODO
## Implementation
TODO
## Additional resources
TODO
+17
View File
@@ -0,0 +1,17 @@
resolves TODO
## Context
TODO
## Implementation
TODO
## Screenshots
TODO
## Testing Instructions
1. Build engine and run editor via `bin/dev editor`
+7 -1
View File
@@ -24,7 +24,6 @@ CMakeUserPresets.json
*.bin *.bin
*.out *.out
*.spv *.spv
*.spv.h
# Runtime data # Runtime data
pids pids
@@ -33,6 +32,13 @@ pids
*.pid.lock *.pid.lock
imgui.ini imgui.ini
# Python bytecode (bin/dev and bin/dev_commands)
__pycache__/
# Tracy captures
traces/
*.tracy
# dotenv environment variables file # dotenv environment variables file
.envrc .envrc
.env .env
+6
View File
@@ -8,3 +8,9 @@
path = third_party/imgui path = third_party/imgui
url = https://github.com/ocornut/imgui.git url = https://github.com/ocornut/imgui.git
branch = docking branch = docking
[submodule "third_party/tracy"]
path = third_party/tracy
url = https://github.com/wolfpld/tracy.git
[submodule "third_party/ImGuizmo"]
path = third_party/ImGuizmo
url = https://github.com/CedricGuillemet/ImGuizmo.git
+2 -2
View File
@@ -41,8 +41,8 @@
"LD_LIBRARY_PATH": "/home/caleb/.local/share/vulkan-sdk/1.4.357.1/x86_64/lib/VulkanLoader/lib:/home/caleb/.local/share/vulkan-sdk/1.4.357.1/x86_64/lib", "LD_LIBRARY_PATH": "/home/caleb/.local/share/vulkan-sdk/1.4.357.1/x86_64/lib/VulkanLoader/lib:/home/caleb/.local/share/vulkan-sdk/1.4.357.1/x86_64/lib",
"VK_ADD_LAYER_PATH": "/home/caleb/.local/share/vulkan-sdk/1.4.357.1/x86_64/share/vulkan/explicit_layer.d" "VK_ADD_LAYER_PATH": "/home/caleb/.local/share/vulkan-sdk/1.4.357.1/x86_64/share/vulkan/explicit_layer.d"
}, },
"cmake.buildDirectory": "${workspaceFolder}/build", "cmake.useCMakePresets": "always",
"cmake.generator": "Ninja", "cmake.copyCompileCommands": "${workspaceFolder}/build/compile_commands.json",
"cmake.configureOnOpen": true, "cmake.configureOnOpen": true,
"files.associations": { "files.associations": {
".clang-format": "yaml", ".clang-format": "yaml",
+17
View File
@@ -0,0 +1,17 @@
# Sophia
## Dev helper (`bin/dev`)
`bin/dev` wraps common project tasks. Run `bin/dev bash-completions` to list commands.
### Creating issues
Use `bin/dev issue` to create Gitea issues instead of calling the API or `tea` directly:
```sh
bin/dev issue "<title>" [-b "<body>"] [-l <label>]... [-a <user>]... [--open]
```
- Labels are repo label names (`bug`, `enhancement`, `question`, ...).
- The Gitea API cannot add an issue to a project. After creating an issue, tell the user its URL so they can add it to the "sophia" project (issue sidebar > Projects), or pass `--open`.
- Only create issues when the user asks; issues are visible on the remote.
+4 -4
View File
@@ -1,11 +1,11 @@
cmake_minimum_required(VERSION 3.22.2) cmake_minimum_required(VERSION 3.25.2)
set(NAME sophia) set(NAME sophia)
project(${NAME} VERSION 0.0.0) project(${NAME} VERSION 0.0.0)
enable_language(CXX) enable_language(CXX)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
@@ -15,8 +15,6 @@ endif()
if(CMAKE_BUILD_TYPE STREQUAL "Debug") if(CMAKE_BUILD_TYPE STREQUAL "Debug")
message(STATUS "Debug build: enabling debug logging") message(STATUS "Debug build: enabling debug logging")
else()
add_compile_definitions(NDEBUG)
endif() endif()
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
@@ -39,5 +37,7 @@ endif()
add_subdirectory(third_party) add_subdirectory(third_party)
add_subdirectory(engine) add_subdirectory(engine)
add_subdirectory(editor)
add_subdirectory(testbed) add_subdirectory(testbed)
add_subdirectory(sandbox)
add_subdirectory(examples) add_subdirectory(examples)
+66
View File
@@ -0,0 +1,66 @@
{
"version": 6,
"configurePresets": [
{
"name": "base",
"hidden": true,
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"cacheVariables": {
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON"
}
},
{
"name": "debug",
"displayName": "Debug",
"inherits": "base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug"
}
},
{
"name": "release",
"displayName": "Release (with debug info)",
"inherits": "base",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "RelWithDebInfo"
}
},
{
"name": "asan",
"displayName": "Debug + AddressSanitizer/UBSan",
"inherits": "debug",
"cacheVariables": {
"CMAKE_CXX_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer",
"CMAKE_C_FLAGS": "-fsanitize=address,undefined -fno-omit-frame-pointer"
}
},
{
"name": "tsan",
"displayName": "Debug + ThreadSanitizer",
"inherits": "debug",
"cacheVariables": {
"CMAKE_CXX_FLAGS": "-fsanitize=thread",
"CMAKE_C_FLAGS": "-fsanitize=thread"
}
}
],
"buildPresets": [
{
"name": "debug",
"configurePreset": "debug"
},
{
"name": "release",
"configurePreset": "release"
},
{
"name": "asan",
"configurePreset": "asan"
},
{
"name": "tsan",
"configurePreset": "tsan"
}
]
}
+10 -4
View File
@@ -1,5 +1,7 @@
# sophia # sophia
![showcase](./media/showcase.png)
## Getting Started ## Getting Started
COMING SOON COMING SOON
@@ -8,17 +10,21 @@ COMING SOON
### Dependencies ### Dependencies
- [cmake](https://cmake.org/) - [cmake](https://cmake.org/) 3.25+
- [Ninja](https://ninja-build.org/)
- [Vulkan SDK](https://vulkan.lunarg.com/) - With validation layers - [Vulkan SDK](https://vulkan.lunarg.com/) - With validation layers
### Build instructions ### Build instructions
Builds use the presets in `CMakePresets.json` (`debug`, `release`), each in its own `build/<preset>` directory:
```sh ```sh
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug cmake --preset debug
cd build cmake --build --preset debug
make -j
``` ```
Or with the dev helper: `dev build [preset] [--fresh]`.
## Third Party libraries used ## Third Party libraries used
- [GLFW](https://github.com/glfw/glfw) - Window and input handling - [GLFW](https://github.com/glfw/glfw) - Window and input handling
+117 -99
View File
@@ -1,21 +1,28 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import json
import os import os
import re import shlex
import shutil import shutil
import struct
import subprocess import subprocess
import sys import sys
from helpers.gitea import GiteaCommand
from helpers.renderdoc import RenderDocCommand
from helpers.shaders import ShadersCommand
from helpers.tracy import TracyCommand
COMMAND_TO_METHOD = { COMMAND_TO_METHOD = {
"branch-from": "branch_from",
"bash-completions": "bash_completions", "bash-completions": "bash_completions",
} }
METHOD_TO_COMMAND = {method: command for command, method in COMMAND_TO_METHOD.items()} METHOD_TO_COMMAND = {method: command for command, method in COMMAND_TO_METHOD.items()}
WAIT_FOR_PROCESS = "wait_for_process" WAIT_FOR_PROCESS = "wait_for_process"
class DevHelper: class DevCommand(GiteaCommand, RenderDocCommand, ShadersCommand, TracyCommand):
SHADER_EXTENSIONS = (".vert", ".frag", ".comp", ".geom", ".tesc", ".tese") PROJECT_NAME = "sophia"
DEFAULT_PRESET = "debug"
def __init__(self): def __init__(self):
self.last_exit_status = 0 self.last_exit_status = 0
@@ -23,6 +30,9 @@ class DevHelper:
# Core logic # Core logic
def call(self, *args, **kwargs): def call(self, *args, **kwargs):
if not args: if not args:
print("Usage: dev <command> [args...]\n\nCommands:")
for command in self._commands():
print(f" {command}")
return return
command, rest = args[0], args[1:] command, rest = args[0], args[1:]
@@ -32,37 +42,67 @@ class DevHelper:
return method(*rest, **kwargs) return method(*rest, **kwargs)
# Primary command wrappers # Primary command wrappers
##
# Configures and builds a CMake preset (see CMakePresets.json).
# dev build [preset] [--fresh]
# The preset defaults to debug and builds into build/<preset>. --fresh
# discards the preset's CMake cache before configuring.
def build(self, *args, **kwargs): def build(self, *args, **kwargs):
command = f"rm -rf build && cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug" preset, fresh = self._parse_preset_args(args, "build")
self._exec_in_root(command, replace=True) self._build_preset(preset, fresh)
def renderdoc(self, *args, **kwargs): ##
qrenderdoc = self._find_qrenderdoc() # Builds the debug preset (incrementally), then runs the editor from
self._exec_in_root(qrenderdoc, replace=True) # build/debug. Any arguments are passed through to the editor.
# dev editor [args...]
def editor(self, *args, **kwargs):
preset = self.DEFAULT_PRESET
self._build_preset(preset, fresh=False)
def shaders(self, *args, **kwargs): preset_dir = os.path.join(self._project_root(), "build", preset)
shaders_dir = os.path.join(self._project_root(), "engine", "src", "shaders") binary = os.path.join(preset_dir, "bin", "editor")
if not os.path.isdir(shaders_dir): print(f"Running: {shlex.join([binary, *args])}")
raise ValueError(f"Shaders directory not found: {shaders_dir}") os.chdir(preset_dir)
os.execv(binary, [binary, *args])
glslc = self._find_glslc() ##
# Builds the debug preset (incrementally), then runs the testbed from
# build/debug. Any arguments are passed through to the testbed.
# dev testbed [args...]
def testbed(self, *args, **kwargs):
preset = self.DEFAULT_PRESET
self._build_preset(preset, fresh=False)
shader_paths = sorted( preset_dir = os.path.join(self._project_root(), "build", preset)
os.path.join(root, filename) binary = os.path.join(preset_dir, "bin", "testbed")
for root, _dirs, files in os.walk(shaders_dir) print(f"Running: {shlex.join([binary, *args])}")
for filename in files os.chdir(preset_dir)
if filename.endswith(self.SHADER_EXTENSIONS) os.execv(binary, [binary, *args])
)
if not shader_paths: ##
print(f"No shaders found in {shaders_dir}") # Builds the debug preset (incrementally), then runs the raw Vulkan
return # sandbox from build/debug. Any arguments are passed through.
# dev sandbox [args...]
def sandbox(self, *args, **kwargs):
preset = self.DEFAULT_PRESET
self._build_preset(preset, fresh=False)
for shader_path in shader_paths: preset_dir = os.path.join(self._project_root(), "build", preset)
self._generate_shader_header(glslc, shader_path) binary = os.path.join(preset_dir, "bin", "sandbox")
print(f"Running: {shlex.join([binary, *args])}")
os.chdir(preset_dir)
os.execv(binary, [binary, *args])
# Custom helpers # Custom helpers
##
# Counts lines of code from the project root, skipping paths listed in
# .clocignore. Any arguments are passed through to cloc.
# dev cloc [args...]
def cloc(self, *args, **kwargs):
self._run_in_root(["cloc", "--exclude-list-file=.clocignore", *args, "."])
def ownit(self, *args, **kwargs): def ownit(self, *args, **kwargs):
file_or_directory = args[0] if args else None file_or_directory = args[0] if args else None
if file_or_directory is None: if file_or_directory is None:
@@ -76,83 +116,61 @@ class DevHelper:
raise NotImplementedError(f"Not implemented for platform {sys.platform}") raise NotImplementedError(f"Not implemented for platform {sys.platform}")
def bash_completions(self, *args, **kwargs): def bash_completions(self, *args, **kwargs):
completions = sorted( print(" ".join(self._commands()))
METHOD_TO_COMMAND.get(name, name)
for name in vars(DevHelper)
if not name.startswith("_") and name != "call" and callable(getattr(DevHelper, name))
)
print(" ".join(completions))
# Private helpers # Private helpers
def _find_glslc(self): def _commands(self):
glslc = shutil.which("glslc") return sorted(
if glslc is None: METHOD_TO_COMMAND.get(name, name)
raise RuntimeError( for name in dir(DevCommand)
"glslc not found on PATH (install the Vulkan SDK)" if not name.startswith("_") and name != "call" and callable(getattr(DevCommand, name))
)
return glslc
def _find_qrenderdoc(self):
qrenderdoc = shutil.which("qrenderdoc")
if qrenderdoc is None:
raise RuntimeError(
"qrenderdoc not found on PATH (install RenderDoc)"
)
return qrenderdoc
def _generate_shader_header(self, glslc, shader_path):
relative_path = os.path.relpath(shader_path, self._project_root())
print(f"Compiling shader: {relative_path}")
result = subprocess.run(
[glslc, shader_path, "-o", "-"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
) )
def _parse_preset_args(self, args, command):
preset = None
fresh = False
for arg in args:
if arg == "--fresh":
fresh = True
elif arg.startswith("-"):
raise ValueError(f"Unknown option: {arg}")
elif preset is None:
preset = arg
else:
raise ValueError(f"Usage: dev {command} [preset] [--fresh]")
preset = preset or self.DEFAULT_PRESET
presets = self._configure_presets()
if preset not in presets:
raise ValueError(f"Unknown preset: {preset} (available: {', '.join(presets)})")
return preset, fresh
def _configure_presets(self):
with open(os.path.join(self._project_root(), "CMakePresets.json")) as f:
presets = json.load(f)["configurePresets"]
return [preset["name"] for preset in presets if not preset.get("hidden")]
def _build_preset(self, preset, fresh):
configure = ["cmake", "--preset", preset]
if fresh:
configure.append("--fresh")
self._run_in_root(configure)
self._copy_compile_commands(preset)
self._run_in_root(["cmake", "--build", "--preset", preset])
# Editor tooling reads build/compile_commands.json, so point it at the
# most recently configured preset.
def _copy_compile_commands(self, preset):
build_dir = os.path.join(self._project_root(), "build")
source = os.path.join(build_dir, preset, "compile_commands.json")
if os.path.isfile(source):
shutil.copyfile(source, os.path.join(build_dir, "compile_commands.json"))
def _run_in_root(self, command):
print(f"Running: {shlex.join(command)}")
result = subprocess.run(command, cwd=self._project_root())
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError( sys.exit(result.returncode)
f"failed to compile {shader_path}: {result.stderr.decode()}"
)
spirv = result.stdout
if len(spirv) % 4 != 0:
raise RuntimeError(
f"invalid SPIR-V output for {shader_path} (size not a multiple of 4)"
)
word_count = len(spirv) // 4
words = struct.unpack(f"<{word_count}I", spirv)
filename = os.path.basename(shader_path)
variable_name = f"{filename.replace('.', '_')}_spv"
header_path = shader_path + ".spv.h"
body_lines = [
" " + ", ".join(f"0x{word:08x}" for word in words[i:i + 4]) + ","
for i in range(0, len(words), 4)
]
header = "\n".join([
"#pragma once",
"",
"#include <array>",
"#include <cstdint>",
"",
"namespace sophia::shaders {",
"",
f"inline constexpr std::array<uint32_t, {word_count}> {variable_name} = {{",
*body_lines,
"};",
"",
"} // namespace sophia::shaders",
"",
])
with open(header_path, "w") as f:
f.write(header)
print(f"Wrote {os.path.relpath(header_path, self._project_root())} "
f"({word_count * 4} bytes)")
def _wait_for_process_with_logging(self, command): def _wait_for_process_with_logging(self, command):
process = self._exec(command, capture=True) process = self._exec(command, capture=True)
@@ -197,4 +215,4 @@ class DevHelper:
if __name__ == "__main__": if __name__ == "__main__":
DevHelper().call(*sys.argv[1:]) DevCommand().call(*sys.argv[1:])
+156
View File
@@ -0,0 +1,156 @@
import json
import re
import shlex
import shutil
import subprocess
import sys
# Mixed into DevCommand (bin/dev), which provides PROJECT_NAME, _project_root
# and _exec_in_root.
class GiteaCommand:
##
# Checks out a branch named after a Gitea issue URL.
# If the issue is from this project's repo, the branch name will be:
# issue-<issue_number>/<issue_title>
# If the issue is from another repo (presumably upstream), the branch name will be:
# <issue_owner>-issue-<issue_number>/<issue_title>
def branch_from(self, *args, **kwargs):
issue_url = args[0] if args else None
if issue_url is None:
raise ValueError("Must provide a Gitea issue URL.")
base_url, issue_repo, issue_number = self._parse_issue_url(issue_url)
issue_title = self._fetch_issue_title(base_url, issue_repo, issue_number)
branch_name = self._format_branch_name(issue_repo, issue_number, issue_title)
exists = subprocess.run(
["git", "-C", self._project_root(), "rev-parse", "--verify", "--quiet", f"refs/heads/{branch_name}"],
stdout=subprocess.DEVNULL,
).returncode == 0
flag = "" if exists else "-b "
self._exec_in_root(f"git checkout {flag}{shlex.quote(branch_name)}", replace=True)
##
# Creates an issue on this project's Gitea repo.
# dev issue <title> [-b|--body <text>] [-l|--label <name>]... [-a|--assignee <user>]... [-o|--open]
# The body is read from stdin if --body is not given and stdin is piped.
# Gitea's API cannot add issues to a project (only the web UI can), so the
# issue URL is printed and --open opens it in the browser to do so.
def issue(self, *args, **kwargs):
title, body, labels, assignees, open_in_browser = self._parse_issue_args(args)
repo = self._origin_repo()
if repo is None:
raise RuntimeError("could not determine the Gitea repo from the origin remote")
payload = {"title": title, "body": body}
if labels:
label_ids = self._fetch_label_ids(repo)
unknown = [label for label in labels if label not in label_ids]
if unknown:
raise ValueError(f"Unknown label(s): {', '.join(unknown)} (available: {', '.join(sorted(label_ids))})")
payload["labels"] = [label_ids[label] for label in labels]
if assignees:
payload["assignees"] = assignees
issue = self._tea_api(f"repos/{repo}/issues", payload)
print(f"Created issue #{issue['number']}: {issue['title']}")
print(issue["html_url"])
print(f"Add it to the project from the issue sidebar: Projects > {self.PROJECT_NAME}")
if open_in_browser:
opener = shutil.which("xdg-open") or shutil.which("open")
if opener is None:
raise RuntimeError("xdg-open not found on PATH")
subprocess.Popen([opener, issue["html_url"]], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _parse_issue_url(self, url):
match = re.match(r"^(https?://[^/]+)/([^/]+)/([^/]+)/issues/(\d+)", url)
if match is None:
raise ValueError(f"Not a Gitea issue URL: {url}")
return match[1], f"{match[2]}/{match[3]}", match[4]
def _fetch_issue_title(self, base_url, repo, issue_number):
if shutil.which("tea") is None:
raise RuntimeError("tea not found on PATH (install gitea tea and run `tea login add`)")
endpoint = f"{base_url}/api/v1/repos/{repo}/issues/{issue_number}"
print(f"Running: tea api {endpoint}")
result = subprocess.run(["tea", "api", endpoint], stdout=subprocess.PIPE, text=True)
if result.returncode != 0:
raise RuntimeError(f"failed to fetch issue title from {endpoint}")
return json.loads(result.stdout)["title"]
def _parse_issue_args(self, args):
title = None
body = None
labels = []
assignees = []
open_in_browser = False
args = list(args)
while args:
arg = args.pop(0)
if arg in ("-b", "--body", "-l", "--label", "-a", "--assignee"):
if not args:
raise ValueError(f"{arg} requires a value")
value = args.pop(0)
if arg in ("-b", "--body"):
body = value
elif arg in ("-l", "--label"):
labels.append(value)
else:
assignees.append(value)
elif arg in ("-o", "--open"):
open_in_browser = True
elif arg.startswith("-"):
raise ValueError(f"Unknown option: {arg}")
elif title is None:
title = arg
else:
title = f"{title} {arg}"
if not title:
raise ValueError("Must provide an issue title. Usage: dev issue <title> [-b <body>] [-l <label>]... [-a <user>]... [-o]")
if body is None:
body = "" if sys.stdin.isatty() else sys.stdin.read()
return title, body, labels, assignees, open_in_browser
def _tea_api(self, endpoint, payload=None):
if shutil.which("tea") is None:
raise RuntimeError("tea not found on PATH (install gitea tea and run `tea login add`)")
command = ["tea", "api", endpoint]
if payload is not None:
command += ["-d", "@-"]
print(f"Running: {' '.join(command)}")
result = subprocess.run(
command,
input=json.dumps(payload) if payload is not None else None,
stdout=subprocess.PIPE,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"tea api request failed: {endpoint}")
return json.loads(result.stdout)
def _fetch_label_ids(self, repo):
labels = self._tea_api(f"repos/{repo}/labels?limit=100")
return {label["name"]: label["id"] for label in labels}
def _format_branch_name(self, issue_repo, issue_number, issue_title):
formatted_title = re.sub(r"-+", "-", re.sub(r"[^a-z0-9\-]", "", re.sub(r"\s+", "-", issue_title.lower().strip())))
if issue_repo == self._origin_repo():
return f"issue-{issue_number}/{formatted_title}"
issue_owner = issue_repo.split("/")[0]
return f"{issue_owner}-issue-{issue_number}/{formatted_title}"
def _origin_repo(self):
result = subprocess.run(
["git", "-C", self._project_root(), "remote", "get-url", "origin"],
stdout=subprocess.PIPE,
text=True,
)
match = re.search(r"[:/]([^/:]+/[^/]+?)(?:\.git)?$", result.stdout.strip())
return match[1] if match else None
+16
View File
@@ -0,0 +1,16 @@
import shutil
# Mixed into DevCommand (bin/dev), which provides _exec_in_root.
class RenderDocCommand:
def renderdoc(self, *args, **kwargs):
qrenderdoc = self._find_qrenderdoc()
self._exec_in_root(qrenderdoc, replace=True)
def _find_qrenderdoc(self):
qrenderdoc = shutil.which("qrenderdoc")
if qrenderdoc is None:
raise RuntimeError(
"qrenderdoc not found on PATH (install RenderDoc)"
)
return qrenderdoc
+92
View File
@@ -0,0 +1,92 @@
import os
import shutil
import struct
import subprocess
# Mixed into DevCommand (bin/dev), which provides _project_root.
class ShadersCommand:
SHADER_EXTENSIONS = (".vert", ".frag", ".comp", ".geom", ".tesc", ".tese")
def shaders(self, *args, **kwargs):
shaders_dir = os.path.join(self._project_root(), "engine", "src", "shaders")
if not os.path.isdir(shaders_dir):
raise ValueError(f"Shaders directory not found: {shaders_dir}")
glslc = self._find_glslc()
shader_paths = sorted(
os.path.join(root, filename)
for root, _dirs, files in os.walk(shaders_dir)
for filename in files
if filename.endswith(self.SHADER_EXTENSIONS)
)
if not shader_paths:
print(f"No shaders found in {shaders_dir}")
return
for shader_path in shader_paths:
self._generate_shader_header(glslc, shader_path)
def _find_glslc(self):
glslc = shutil.which("glslc")
if glslc is None:
raise RuntimeError(
"glslc not found on PATH (install the Vulkan SDK)"
)
return glslc
def _generate_shader_header(self, glslc, shader_path):
relative_path = os.path.relpath(shader_path, self._project_root())
print(f"Compiling shader: {relative_path}")
result = subprocess.run(
[glslc, shader_path, "-o", "-"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode != 0:
raise RuntimeError(
f"failed to compile {shader_path}: {result.stderr.decode()}"
)
spirv = result.stdout
if len(spirv) % 4 != 0:
raise RuntimeError(
f"invalid SPIR-V output for {shader_path} (size not a multiple of 4)"
)
word_count = len(spirv) // 4
words = struct.unpack(f"<{word_count}I", spirv)
filename = os.path.basename(shader_path)
variable_name = f"{filename.replace('.', '_')}_spv"
header_path = shader_path + ".spv.h"
body_lines = [
" " + ", ".join(f"0x{word:08x}" for word in words[i:i + 4]) + ","
for i in range(0, len(words), 4)
]
header = "\n".join([
"#pragma once",
"",
"#include <array>",
"#include <cstdint>",
"",
"namespace sophia::shaders {",
"",
f"inline constexpr std::array<uint32_t, {word_count}> {variable_name} = {{",
*body_lines,
"};",
"",
"} // namespace sophia::shaders",
"",
])
with open(header_path, "w") as f:
f.write(header)
print(f"Wrote {os.path.relpath(header_path, self._project_root())} "
f"({word_count * 4} bytes)")
+85
View File
@@ -0,0 +1,85 @@
import os
import re
import shlex
import time
# Mixed into DevCommand (bin/dev), which provides _project_root and
# _run_in_root.
class TracyCommand:
##
# Builds a Tracy tool from the third_party/tracy submodule, then runs it.
# Building from the submodule keeps the tool's version in sync with the
# client compiled into the engine.
# dev tracy [--fresh] [args...] Opens the profiler (viewer).
# dev tracy --save [name] [--fresh] [args...]
# Captures a trace headlessly to
# traces/<name>.tracy (default
# name: a timestamp) until the app
# exits or Ctrl+C.
# The profiler uses the X11 backend unless WAYLAND_DISPLAY is set. Tools
# are configured on first use (or --fresh, or when the backend changes),
# then built incrementally. Other arguments are passed through to the tool
# (e.g. a .tracy file to open, or `-s 10` to capture for 10 seconds).
def tracy(self, *args, **kwargs):
fresh, save, name, args = self._parse_tracy_args(args)
if save:
binary = self._build_tracy_tool("capture", "tracy-capture", {}, fresh)
traces_dir = os.path.join(self._project_root(), "traces")
os.makedirs(traces_dir, exist_ok=True)
name = name or time.strftime("%Y-%m-%d-%H%M%S")
if not name.endswith(".tracy"):
name += ".tracy"
args = ["-o", os.path.join(traces_dir, name), *args]
else:
legacy = "OFF" if os.environ.get("WAYLAND_DISPLAY") else "ON"
binary = self._build_tracy_tool("profiler", "tracy-profiler", {"LEGACY": legacy}, fresh)
print(f"Running: {shlex.join([binary, *args])}")
os.execv(binary, [binary, *args])
def _parse_tracy_args(self, args):
fresh = False
save = False
name = None
rest = []
args = list(args)
while args:
arg = args.pop(0)
if arg == "--fresh":
fresh = True
elif arg == "--save":
save = True
if args and not args[0].startswith("-"):
name = args.pop(0)
else:
rest.append(arg)
return fresh, save, name, rest
# Configures third_party/tracy/<tool> into <tool>/build when it has no
# build yet, --fresh is given, or a cached define differs from `defines`.
def _build_tracy_tool(self, tool, binary_name, defines, fresh):
source_dir = os.path.join("third_party", "tracy", tool)
build_dir = os.path.join(source_dir, "build")
cache_path = os.path.join(self._project_root(), build_dir, "CMakeCache.txt")
stale = not os.path.isfile(cache_path)
if not stale:
with open(cache_path) as f:
cache = f.read()
stale = any(
re.search(rf"^{key}:BOOL={value}$", cache, re.MULTILINE) is None
for key, value in defines.items()
)
if fresh or stale:
configure = ["cmake", "-B", build_dir, "-S", source_dir, "-DCMAKE_BUILD_TYPE=Release"]
configure += [f"-D{key}={value}" for key, value in defines.items()]
if fresh:
configure.append("--fresh")
self._run_in_root(configure)
self._run_in_root(["cmake", "--build", build_dir, "--parallel"])
return os.path.join(self._project_root(), build_dir, binary_name)
+67 -17
View File
@@ -1,36 +1,56 @@
# Glossary # Glossary
## AssetRegistry ## ResourceRegistry
An AssetRegistry owns GPU-backed assets on the caller's behalf and hands out A ResourceRegistry owns GPU-backed resources on the caller's behalf and hands out
lightweight Handles instead of pointers or ownership. lightweight Handles instead of pointers or ownership. It is engine-internal:
frontend code never sees it, and reaches it through a ResourceRegistryRef.
Internally it's a collection of per-type pools, each responsible for one Internally it's a collection of per-type pools, each a `Pool<T, Handle>`
asset type: (a generation-checked slot pool). A type that needs extra behaviour wraps its
pool in a thin class instead:
``` ```
AssetRegistry ResourceRegistry
├── MeshPool → MeshHandle ├── meshPool Pool<Mesh, MeshHandle>
├── TexturePool → TextureHandle (planned) ├── renderTargetPool RenderTargetPool → RenderTargetHandle
└── MaterialPool → MaterialHandle (planned) │ (wraps Pool; waits for the GPU on release, applies resizes)
├── texturePool (planned)
└── materialPool (planned)
``` ```
Engine code (`Application`, `ImGuiRenderer`, `SceneRenderer`) uses the pools
directly, e.g. `resources.renderTargetPool.get(handle)`.
A Handle is a small (index, generation) pair. The index locates the slot in A Handle is a small (index, generation) pair. The index locates the slot in
the pool; the generation is bumped when that slot is released, so a Handle the pool; the generation is bumped when that slot is released, so a Handle
kept around after release() is detected as stale instead of resolving to kept around after release() is detected as stale instead of resolving to
someone else's asset (or freed memory). someone else's resource (or freed memory).
Think: "Where does the engine keep every GPU resource, and how does it look one
up by handle?"
## ResourceRegistryRef
A ResourceRegistryRef is the frontend's non-owning, cheap-to-copy view of the
ResourceRegistry, returned by `Application::resources()`. It exposes only what
frontend code needs and hands out no pools, so no Vulkan-facing types leak
through the public API:
```
create<T>(...) load<T>(path) release<T>(handle)
requestResize(target, w, h) getExtent(target) isAlive(handle)
```
Typical lifecycle: Typical lifecycle:
``` ```
load<Mesh>(path) / loadMesh(...) → MeshHandle load<Mesh>(path) / create<Mesh>(...) → MeshHandle
│
get<Mesh>(handle) → const Mesh&
│ │
release<Mesh>(handle) release<Mesh>(handle)
``` ```
Think: "Where do I get or release a GPU asset by handle, without owning its Think: "How do I create or release a GPU resource by handle, without owning its
lifetime directly?" lifetime directly?"
## Buffer ## Buffer
@@ -256,12 +276,14 @@ This is primarily a UI concept, rather than a GPU concept.
A PanelRegistry owns Panels on the caller's behalf and hands out lightweight A PanelRegistry owns Panels on the caller's behalf and hands out lightweight
PanelHandles instead of pointers or ownership. PanelHandles instead of pointers or ownership.
Internally it's a generation-checked slot pool, the same shape AssetRegistry's Internally it wraps a `Pool<Panel, PanelHandle>` (the same generation-checked
per-type pools use: slot pool ResourceRegistry uses, in `sophia/util/pool.hpp`) and adds the
Panel-specific parts: lookup by type or name, and the default dock layout:
``` ```
PanelRegistry PanelRegistry
└── Slot { Panel, generation } → PanelHandle └── Pool<Panel, PanelHandle>
└── Slot { Panel, generation } → PanelHandle
``` ```
Typical lifecycle: Typical lifecycle:
@@ -279,6 +301,34 @@ Each frame, update(frameInfo) calls onUpdate on every visible Panel it holds.
Think: "Where do I register or look up a UI panel by handle, without owning Think: "Where do I register or look up a UI panel by handle, without owning
its lifetime directly?" its lifetime directly?"
## SceneRegistry
A SceneRegistry owns Scenes on the caller's behalf and hands out lightweight
SceneHandles instead of pointers or ownership. Like PanelRegistry, it is owned
by Application and reached from frontend code directly (`scenes()`), rather than
through ResourceRegistryRef: scenes are CPU-side entity data, not GPU resources.
Internally it wraps a `Pool<Scene, SceneHandle>`:
```
SceneRegistry
└── Pool<Scene, SceneHandle>
└── Slot { Scene, generation } → SceneHandle
```
Typical lifecycle:
```
create() → SceneHandle
│
get(handle) → Scene&
│
release(handle)
```
Think: "Where do I create or look up a Scene by handle, without owning its
lifetime directly?"
## Sprite ## Sprite
A Sprite is a 2D renderable representation of an image, usually a rectangular region of a texture. A Sprite is a 2D renderable representation of an image, usually a rectangular region of a texture.
+27
View File
@@ -0,0 +1,27 @@
set(NAME editor)
set(ENGINE_NAME ${PROJECT_NAME})
file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS *.cpp src/*.cpp)
add_library(${NAME}_deps INTERFACE)
target_link_libraries(${NAME}_deps INTERFACE
${ENGINE_NAME}
imguizmo
)
target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_23)
set_property(TARGET ${PROJECT_NAME} PROPERTY VS_DEBUGGER_WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}/build")
add_executable(editor ${SOURCES})
target_link_libraries(editor PRIVATE ${NAME}_deps)
target_include_directories(editor PRIVATE
"${CMAKE_SOURCE_DIR}/engine/include"
"${CMAKE_SOURCE_DIR}/external/imgui"
)
target_compile_definitions(editor PRIVATE
EDITOR_SHADER_DIR="${CMAKE_CURRENT_SOURCE_DIR}/shaders"
)
+99
View File
@@ -0,0 +1,99 @@
#include "editor_camera.hpp"
#include <algorithm>
#include <cmath>
namespace {
constexpr float ORBIT_RADIANS_PER_PIXEL = 0.005f;
const float MAX_PITCH = glm::radians(89.f);
// Each wheel step moves this fraction of the way towards the pivot.
constexpr float ZOOM_STEP = 0.1f;
constexpr float MIN_DISTANCE = 0.05f;
constexpr float MIN_ORTHO_HEIGHT = 0.01f;
// Fly speed per unit of distance from the pivot, so it suits any scale.
constexpr float FLY_SPEED = 1.5f;
} // namespace
void EditorCamera::orbit(glm::vec2 pixels) {
if (mode != Mode::Scene3D) {
return;
}
yaw -= pixels.x * ORBIT_RADIANS_PER_PIXEL;
pitch = std::clamp(pitch - pixels.y * ORBIT_RADIANS_PER_PIXEL, -MAX_PITCH,
MAX_PITCH);
}
void EditorCamera::pan(glm::vec2 pixels, float viewportHeight) {
if (viewportHeight <= 0.f) {
return;
}
// World units one pixel covers at the pivot's depth.
float unitsPerPixel =
mode == Mode::Scene3D
? 2.f * distance * std::tan(fov / 2.f) / viewportHeight
: orthoHeight / viewportHeight;
// Screen y runs down, world y up. Dragging moves the scene with the mouse,
// so the camera moves the other way.
glm::vec3 offset{-pixels.x * unitsPerPixel, pixels.y * unitsPerPixel, 0.f};
pivot += mode == Mode::Scene3D ? getRotation() * offset : offset;
}
void EditorCamera::zoom(float steps) {
float scale = std::pow(1.f - ZOOM_STEP, steps);
if (mode == Mode::Scene3D) {
distance = std::max(distance * scale, MIN_DISTANCE);
} else {
orthoHeight = std::max(orthoHeight * scale, MIN_ORTHO_HEIGHT);
}
}
void EditorCamera::fly(glm::vec3 direction, float deltaTime) {
if (mode != Mode::Scene3D || direction == glm::vec3{0.f}) {
return;
}
float speed = FLY_SPEED * std::max(distance, 1.f);
pivot += getRotation() * glm::normalize(direction) * speed * deltaTime;
}
void EditorCamera::frame(glm::vec3 target, float radius) {
pivot = target;
radius = std::max(radius, 0.1f);
// Far enough that the sphere fits in the vertical field of view.
distance = radius / std::sin(fov / 2.f);
orthoHeight = radius * 2.5f;
}
sophia::TransformComponent EditorCamera::getTransform() const {
sophia::TransformComponent transform;
if (mode == Mode::Scene3D) {
transform.rotation = getRotation();
transform.position =
pivot + transform.rotation * glm::vec3{0.f, 0.f, distance};
} else {
transform.position = pivot;
}
return transform;
}
sophia::CameraComponent EditorCamera::getCamera() const {
sophia::CameraComponent camera;
if (mode == Mode::Scene3D) {
camera.projection = sophia::CameraComponent::Projection::Perspective;
camera.perspective.fov = fov;
// Room to zoom far out without the scene clipping.
camera.perspective.farPlane = 1000.f;
} else {
camera.projection = sophia::CameraComponent::Projection::Orthographic;
camera.ortho.height = orthoHeight;
}
return camera;
}
glm::quat EditorCamera::getRotation() const {
return glm::angleAxis(yaw, glm::vec3{0.f, 1.f, 0.f}) *
glm::angleAxis(pitch, glm::vec3{1.f, 0.f, 0.f});
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <sophia/components.hpp>
#include <sophia/math.hpp>
/**
* The Scene panel's camera. Not part of any scene the editor edits, and never
* saved with one.
*
* In 3D it orbits a pivot point at some distance; in 2D it looks straight
* down -Z at the pivot through an orthographic projection, like a 2D game
* camera.
*/
class EditorCamera {
public:
enum class Mode { Scene3D, Scene2D };
Mode mode = Mode::Scene3D;
// Turns around the pivot by a mouse movement in pixels. 3D only.
void orbit(glm::vec2 pixels);
// Moves the pivot so the scene follows a mouse movement in pixels across a
// viewport `viewportHeight` pixels tall.
void pan(glm::vec2 pixels, float viewportHeight);
// Moves in (positive) or out by mouse wheel steps.
void zoom(float steps);
// Moves the pivot along the camera's axes: x right, y up, z backwards.
// `direction` needn't be normalized. 3D only.
void fly(glm::vec3 direction, float deltaTime);
// Centers on `target`, backing off enough to see a sphere of `radius`.
void frame(glm::vec3 target, float radius);
sophia::TransformComponent getTransform() const;
sophia::CameraComponent getCamera() const;
private:
glm::quat getRotation() const;
glm::vec3 pivot{0.f, 0.f, 0.f};
// 3D: looking at the pivot from in front, a little to the right and above.
float yaw = glm::radians(30.f);
float pitch = glm::radians(-25.f);
float distance = 8.f;
float fov = glm::radians(60.f);
// 2D: world units visible from the bottom of the view to the top.
float orthoHeight = 10.f;
};
+89
View File
@@ -0,0 +1,89 @@
#pragma once
#include <sophia/entity.hpp>
#include <sophia/handles.hpp>
#include <sophia/scene.hpp>
#include <sophia/scene_registry.hpp>
#include <sophia/scene_view_registry.hpp>
#include <string>
#include <vector>
/**
* What the editor is working on, shared by its panels. Panels read it every
* frame instead of holding on to a scene, so switching scenes is one call.
*/
class EditorContext {
public:
// A scene the editor has open, and can switch to.
struct OpenScene {
sophia::SceneHandle handle;
std::string name;
};
explicit EditorContext(sophia::SceneRegistry& scenes) : scenes{scenes} {}
// Creates an open scene with a "Main Camera", without switching to it.
sophia::SceneHandle createScene(std::string name) {
sophia::SceneHandle handle = scenes.create();
scenes.get(handle).createCamera("Main Camera");
openScenes.push_back({handle, std::move(name)});
return handle;
}
const std::vector<OpenScene>& getOpenScenes() const { return openScenes; }
// Null if `handle` isn't an open scene.
const std::string* getSceneName(sophia::SceneHandle handle) const {
for (const OpenScene& open : openScenes) {
if (open.handle == handle) {
return &open.name;
}
}
return nullptr;
}
void renameScene(sophia::SceneHandle handle, std::string name) {
for (OpenScene& open : openScenes) {
if (open.handle == handle) {
open.name = std::move(name);
}
}
}
bool hasScene() const { return scenes.isAlive(activeScene); }
sophia::SceneHandle getSceneHandle() const { return activeScene; }
sophia::Scene& getScene() { return scenes.get(activeScene); }
// Also clears the selection, which belonged to the previous scene.
void setScene(sophia::SceneHandle handle) {
activeScene = handle;
selected = {};
}
// Null when nothing is selected. Always an entity of the active scene.
sophia::Entity selected;
private:
sophia::SceneRegistry& scenes;
sophia::SceneHandle activeScene = sophia::INVALID_HANDLE;
// Kept open even when not active: with no saving yet, closing one would
// lose it.
std::vector<OpenScene> openScenes;
};
// Points `view` at the context's active scene, and renders it only while
// there is one and its panel is `shown`. Call every frame from the panel that
// owns the view.
inline void syncSceneView(EditorContext& context,
sophia::SceneViewRegistry& sceneViews,
sophia::SceneViewHandle view, bool shown) {
bool enabled = shown && context.hasScene();
const sophia::SceneView& current = sceneViews.peek(view);
// get() re-sorts the render queue, so only use it when something changed.
if (current.enabled != enabled ||
(context.hasScene() && current.scene != context.getSceneHandle())) {
sophia::SceneView& updated = sceneViews.get(view);
updated.enabled = enabled;
updated.scene = context.getSceneHandle();
}
}
+153
View File
@@ -0,0 +1,153 @@
#include <sophia/application.hpp>
#include <sophia/components.hpp>
#include <sophia/entity.hpp>
#include <sophia/primitives.hpp>
#include <vector>
#include "editor_context.hpp"
#include "panels/asset_browser_panel.hpp"
#include "panels/game_panel.hpp"
#include "panels/inspector_panel.hpp"
#include "panels/scene_hierarchy_panel.hpp"
#include "panels/scene_panel.hpp"
#include "utilities/logger.hpp"
class Editor : public sophia::Application {
public:
Editor() : Application{{.width = 750, .height = 1000, .name = "Editor"}} {}
void onCreate() override {
log("Editor created");
debugUI().show();
textures.emplace_back("Checker", createCheckerTexture());
cubeMesh = createShadedCube();
sophia::SceneHandle sceneHandle = context.createScene("Demo");
createDemoScene(scenes().get(sceneHandle));
context.setScene(sceneHandle);
// The editor's own scene, never edited or rendered. It holds the camera
// the Scene panel looks through, which a SceneView may take from any
// scene.
sophia::SceneHandle editorSceneHandle = scenes().create();
sophia::Entity editorCamera =
scenes().get(editorSceneHandle).createCamera("Editor Camera");
// Views start without a scene; their panels point them at the active
// scene each frame.
sophia::RenderTargetHandle sceneTarget =
resources().create<sophia::RenderTarget>();
sophia::SceneViewHandle sceneView = sceneViews().create(
{.target = sceneTarget, .camera = editorCamera, .enabled = false});
// No camera: the Game panel shows the scene through its active one.
sophia::RenderTargetHandle gameTarget =
resources().create<sophia::RenderTarget>();
sophia::SceneViewHandle gameView =
sceneViews().create({.target = gameTarget, .enabled = false});
panels().emplace<SceneHierarchyPanel>(context, cubeMesh);
panels().emplace<InspectorPanel>(context, textures);
panels().emplace<AssetBrowserPanel>();
viewPanels.push_back(
{panels().emplace<ScenePanel>(ui(), resources(), context, sceneViews(),
sceneView, sceneTarget, editorCamera),
sceneView});
viewPanels.push_back(
{panels().emplace<GamePanel>(ui(), context, sceneViews(), gameView,
gameTarget),
gameView});
panels().setDefaultLayout(
{.left = {"Scene Hierarchy"},
.right = {"Inspector", "Frame Stats", "PanelRegistry"},
.bottom = {"Asset Browser", "Console"},
.center = {"Scene", "Game"}});
}
void onUpdate(const sophia::FrameInfo& frameInfo) override {
// A closed panel's onUpdate() doesn't run, so it can't stop its view
// rendering itself.
for (const ViewPanel& viewPanel : viewPanels) {
if (!panels().get(viewPanel.panel).isVisible() &&
sceneViews().peek(viewPanel.view).enabled) {
sceneViews().get(viewPanel.view).enabled = false;
}
}
}
private:
// Adds to a new scene, which already has a camera.
void createDemoScene(sophia::Scene& scene) {
sophia::Entity checker = scene.createEntity("Checker");
checker.getComponent<sophia::TransformComponent>().position = {-1.5f, 0.f,
0.f};
checker.addComponent<sophia::SpriteComponent>(sophia::SpriteComponent{
.texture = textures[0].second, .size = {2.f, 2.f}});
sophia::Entity red = scene.createEntity("Red");
red.getComponent<sophia::TransformComponent>().position = {1.f, -0.5f, 0.f};
red.addComponent<sophia::SpriteComponent>(sophia::SpriteComponent{
.color = {0.9f, 0.25f, 0.25f, 1.f}, .size = {2.f, 2.f}});
// Drawn over "Red" because of its higher sort order.
sophia::Entity blue = scene.createEntity("Blue (translucent)");
blue.getComponent<sophia::TransformComponent>().position = {1.75f, 0.25f,
0.f};
blue.addComponent<sophia::SpriteComponent>(
sophia::SpriteComponent{.color = {0.25f, 0.45f, 0.95f, 0.6f},
.size = {2.f, 2.f},
.sortOrder = 1});
sophia::Entity cube = scene.createEntity("Cube");
auto& cubeTransform = cube.getComponent<sophia::TransformComponent>();
cubeTransform.position = {0.f, 2.5f, 0.f};
cubeTransform.rotation =
glm::quat{glm::radians(glm::vec3{30.f, 45.f, 0.f})};
cube.addComponent<sophia::MeshComponent>(sophia::MeshComponent{
.handle = cubeMesh, .color = {0.4f, 0.8f, 0.5f, 1.f}});
}
// Meshes are unlit, so a plain cube is a flat silhouette. Bake a fixed
// light into its vertex colors so its faces read in 3D until there's real
// lighting; MeshComponent::color still tints it.
sophia::MeshHandle createShadedCube() {
const glm::vec3 lightDirection = glm::normalize(glm::vec3{0.4f, 1.f, 0.7f});
sophia::MeshData data = sophia::primitives::cube();
for (sophia::MeshVertex& vertex : data.vertices) {
float light = glm::dot(vertex.normal, lightDirection) * 0.5f + 0.5f;
vertex.color = glm::vec4{glm::vec3{0.35f + 0.65f * light}, 1.f};
}
return resources().create<sophia::Mesh>(data);
}
sophia::TextureHandle createCheckerTexture() {
constexpr sophia::u32 SIZE = 8;
std::vector<sophia::u8> pixels(SIZE * SIZE * 4);
for (sophia::u32 y = 0; y < SIZE; y++) {
for (sophia::u32 x = 0; x < SIZE; x++) {
sophia::u8 value = ((x + y) % 2 == 0) ? 230 : 60;
sophia::u8* pixel = &pixels[(y * SIZE + x) * 4];
pixel[0] = pixel[1] = pixel[2] = value;
pixel[3] = 255;
}
}
return resources().create<sophia::Texture>(SIZE, SIZE, std::move(pixels));
}
// A panel showing a scene view, which should only render while the panel
// is on screen.
struct ViewPanel {
sophia::PanelHandle panel;
sophia::SceneViewHandle view;
};
EditorContext context{scenes()};
std::vector<ViewPanel> viewPanels;
InspectorPanel::TextureList textures;
sophia::MeshHandle cubeMesh;
};
SOPHIA_MAIN(Editor)
+23
View File
@@ -0,0 +1,23 @@
#include "asset_browser_panel.hpp"
#include <imgui.h>
void AssetBrowserPanel::onUpdate(const sophia::FrameInfo&) {
ImGui::BeginChild("Folders", ImVec2(150, 0), true);
for (int i = 0; i < static_cast<int>(folders.size()); i++) {
if (ImGui::Selectable(folders[static_cast<size_t>(i)].name.c_str(),
selectedFolder == i)) {
selectedFolder = i;
}
}
ImGui::EndChild();
ImGui::SameLine();
ImGui::BeginChild("Files", ImVec2(0, 0), true);
const Folder& folder = folders[static_cast<size_t>(selectedFolder)];
for (const std::string& file : folder.files) {
ImGui::Selectable(file.c_str());
}
ImGui::EndChild();
}
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <sophia/frame_info.hpp>
#include <sophia/ui/panel.hpp>
#include <string>
#include <vector>
class AssetBrowserPanel : public sophia::Panel {
public:
AssetBrowserPanel() : Panel{{.name = "Asset Browser"}} {}
void onUpdate(const sophia::FrameInfo& frameInfo) override;
private:
struct Folder {
std::string name;
std::vector<std::string> files;
};
std::vector<Folder> folders = {
{"Models", {"player_capsule.fbx", "ground_plane.fbx", "enemy_grunt.fbx"}},
{"Textures",
{"brick_diffuse.png", "grass_albedo.png", "player_diffuse.png"}},
{"Materials", {"player_mat.mat", "ground_mat.mat"}},
{"Scripts", {"PlayerController.lua", "EnemyAI.lua"}},
{"Scenes", {"MainMenu.scene", "Level_01.scene"}},
};
int selectedFolder = 0;
};
+20
View File
@@ -0,0 +1,20 @@
#include "game_panel.hpp"
#include <imgui.h>
void GamePanel::onUpdate(const sophia::FrameInfo&) {
syncSceneView(context, sceneViews, view, isShown());
if (!context.hasScene()) {
ImGui::TextDisabled("No scene open");
return;
}
const ImVec2 origin = ImGui::GetCursorScreenPos();
ui.image(target);
if (!context.getScene().getActiveCamera()) {
ImGui::SetCursorScreenPos(ImVec2(origin.x + 8.0f, origin.y + 8.0f));
ImGui::TextDisabled("The scene has no active camera");
}
}
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#include <sophia/frame_info.hpp>
#include <sophia/handles.hpp>
#include <sophia/scene_view_registry.hpp>
#include <sophia/ui/panel.hpp>
#include <sophia/ui/ui.hpp>
#include "../editor_context.hpp"
/**
* Shows the active scene through its own active camera: what the game will
* see.
*/
class GamePanel : public sophia::Panel {
public:
// Renders into `target` through `view`, a SceneView with no camera of its
// own that this panel keeps pointed at the active scene.
GamePanel(sophia::UI& ui, EditorContext& context,
sophia::SceneViewRegistry& sceneViews, sophia::SceneViewHandle view,
sophia::RenderTargetHandle target)
: Panel{{.name = "Game",
.windowFlags = ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoScrollWithMouse}},
ui{ui},
context{context},
sceneViews{sceneViews},
view{view},
target{target} {}
void onUpdate(const sophia::FrameInfo& frameInfo) override;
private:
sophia::UI& ui;
EditorContext& context;
sophia::SceneViewRegistry& sceneViews;
sophia::SceneViewHandle view;
sophia::RenderTargetHandle target;
};
+164
View File
@@ -0,0 +1,164 @@
#include "inspector_panel.hpp"
#include <imgui.h>
#include <cstdio>
#include <sophia/components.hpp>
void InspectorPanel::onUpdate(const sophia::FrameInfo&) {
selected = context.selected;
if (!selected) {
ImGui::TextDisabled("No entity selected");
return;
}
drawTag();
ImGui::Separator();
drawTransform();
drawCamera();
drawSprite();
drawMesh();
ImGui::Separator();
drawAddComponent();
}
void InspectorPanel::drawTag() {
auto& tag = selected.getComponent<sophia::TagComponent>();
char nameBuf[128];
std::snprintf(nameBuf, sizeof(nameBuf), "%s", tag.tag.c_str());
if (ImGui::InputText("Name", nameBuf, sizeof(nameBuf))) {
tag.tag = nameBuf;
}
}
void InspectorPanel::drawTransform() {
if (!selected.hasComponent<sophia::TransformComponent>() ||
!ImGui::CollapsingHeader("Transform", ImGuiTreeNodeFlags_DefaultOpen)) {
return;
}
auto& transform = selected.getComponent<sophia::TransformComponent>();
ImGui::DragFloat3("Position", &transform.position.x, 0.1f);
// Euler angles in degrees, recomputed from the quaternion every frame.
// Near 90 degrees of pitch the angles can jump between equivalent values
// while dragging.
// TODO: keep each entity's edited angles, or replace with gizmo rotation.
glm::vec3 degrees = glm::degrees(glm::eulerAngles(transform.rotation));
if (ImGui::DragFloat3("Rotation", &degrees.x, 1.0f)) {
transform.rotation = glm::quat{glm::radians(degrees)};
}
ImGui::DragFloat3("Scale", &transform.scale.x, 0.1f);
}
void InspectorPanel::drawCamera() {
if (!selected.hasComponent<sophia::CameraComponent>() ||
!ImGui::CollapsingHeader("Camera", ImGuiTreeNodeFlags_DefaultOpen)) {
return;
}
auto& camera = selected.getComponent<sophia::CameraComponent>();
int projection = static_cast<int>(camera.projection);
if (ImGui::Combo("Projection", &projection, "Orthographic\0Perspective\0")) {
camera.projection =
static_cast<sophia::CameraComponent::Projection>(projection);
}
if (camera.projection == sophia::CameraComponent::Projection::Orthographic) {
ImGui::DragFloat("Height", &camera.ortho.height, 0.1f, 0.01f, 10000.f);
} else {
float fov = glm::degrees(camera.perspective.fov);
if (ImGui::DragFloat("FOV", &fov, 0.5f, 1.f, 179.f)) {
camera.perspective.fov = glm::radians(fov);
}
}
}
void InspectorPanel::drawSprite() {
if (!selected.hasComponent<sophia::SpriteComponent>()) {
return;
}
bool open = true;
if (!ImGui::CollapsingHeader("Sprite", &open,
ImGuiTreeNodeFlags_DefaultOpen)) {
if (!open) {
selected.removeComponent<sophia::SpriteComponent>();
}
return;
}
auto& sprite = selected.getComponent<sophia::SpriteComponent>();
const char* current = "None";
for (const auto& [name, handle] : textures) {
if (handle == sprite.texture) {
current = name.c_str();
}
}
if (ImGui::BeginCombo("Texture", current)) {
if (ImGui::Selectable("None", !sprite.texture.isValid())) {
sprite.texture = sophia::INVALID_HANDLE;
}
for (const auto& [name, handle] : textures) {
if (ImGui::Selectable(name.c_str(), handle == sprite.texture)) {
sprite.texture = handle;
}
}
ImGui::EndCombo();
}
ImGui::ColorEdit4("Color", &sprite.color.x);
ImGui::DragFloat2("Size", &sprite.size.x, 0.05f);
ImGui::SliderFloat2("Pivot", &sprite.pivot.x, 0.f, 1.f);
ImGui::DragFloat4("UV Rect", &sprite.uvRect.x, 0.01f);
ImGui::DragInt("Sort Order", &sprite.sortOrder);
if (!open) {
selected.removeComponent<sophia::SpriteComponent>();
}
}
void InspectorPanel::drawMesh() {
if (!selected.hasComponent<sophia::MeshComponent>()) {
return;
}
bool open = true;
if (!ImGui::CollapsingHeader("Mesh", &open, ImGuiTreeNodeFlags_DefaultOpen)) {
if (!open) {
selected.removeComponent<sophia::MeshComponent>();
}
return;
}
auto& mesh = selected.getComponent<sophia::MeshComponent>();
// TODO: pick the mesh once assets have names.
ImGui::Text("Mesh: %u", static_cast<sophia::u32>(mesh.handle.index));
ImGui::ColorEdit3("Color", &mesh.color.x);
if (!open) {
selected.removeComponent<sophia::MeshComponent>();
}
}
void InspectorPanel::drawAddComponent() {
if (ImGui::Button("Add Component")) {
ImGui::OpenPopup("AddComponent");
}
if (ImGui::BeginPopup("AddComponent")) {
if (!selected.hasComponent<sophia::SpriteComponent>() &&
ImGui::MenuItem("Sprite")) {
selected.addComponent<sophia::SpriteComponent>();
}
if (!selected.hasComponent<sophia::CameraComponent>() &&
ImGui::MenuItem("Camera")) {
selected.addComponent<sophia::CameraComponent>();
}
ImGui::EndPopup();
}
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <sophia/entity.hpp>
#include <sophia/frame_info.hpp>
#include <sophia/handles.hpp>
#include <sophia/ui/panel.hpp>
#include <string>
#include <utility>
#include <vector>
#include "../editor_context.hpp"
class InspectorPanel : public sophia::Panel {
public:
// Stand-in for an asset browser: the textures a sprite can pick from.
using TextureList =
std::vector<std::pair<std::string, sophia::TextureHandle>>;
InspectorPanel(EditorContext& context, const TextureList& textures)
: Panel{{.name = "Inspector"}}, context{context}, textures{textures} {}
void onUpdate(const sophia::FrameInfo& frameInfo) override;
private:
void drawTag();
void drawTransform();
void drawCamera();
void drawSprite();
void drawMesh();
void drawAddComponent();
EditorContext& context;
// The selected entity, for the duration of onUpdate().
sophia::Entity selected;
const TextureList& textures;
};
+134
View File
@@ -0,0 +1,134 @@
#include "scene_hierarchy_panel.hpp"
#include <imgui.h>
#include <cstdio>
#include <sophia/components.hpp>
#include <string>
void SceneHierarchyPanel::onUpdate(const sophia::FrameInfo&) {
drawSceneSelector();
ImGui::Separator();
if (!context.hasScene()) {
ImGui::TextDisabled("No scene open");
return;
}
sophia::Scene& scene = context.getScene();
sophia::Entity& selected = context.selected;
if (ImGui::Button("+ Sprite")) {
selected = scene.createEntity("Sprite");
selected.addComponent<sophia::SpriteComponent>();
}
ImGui::SameLine();
if (ImGui::Button("+ Cube")) {
selected = scene.createEntity("Cube");
selected.addComponent<sophia::MeshComponent>(
sophia::MeshComponent{.handle = cubeMesh});
}
ImGui::Separator();
// Flat for now: there is no parent/child component yet.
scene.forEachEntity([&selected](sophia::Entity entity) {
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_Leaf |
ImGuiTreeNodeFlags_NoTreePushOnOpen |
ImGuiTreeNodeFlags_SpanAvailWidth;
if (entity == selected) {
flags |= ImGuiTreeNodeFlags_Selected;
}
const std::string& name = entity.getComponent<sophia::TagComponent>().tag;
ImGui::PushID(static_cast<int>(static_cast<sophia::u32>(entity)));
ImGui::TreeNodeEx(name.c_str(), flags);
if (ImGui::IsItemClicked()) {
selected = entity;
}
ImGui::PopID();
});
}
namespace {
// See SceneHierarchyPanel::renameFocusFrames.
constexpr int RENAME_FOCUS_FRAMES = 2;
} // namespace
void SceneHierarchyPanel::drawSceneSelector() {
// Leave room for the button: its label, its padding, and the gap to it.
constexpr const char* ADD_SCENE_LABEL = "Add Scene";
const ImGuiStyle& style = ImGui::GetStyle();
ImGui::SetNextItemWidth(-(ImGui::CalcTextSize(ADD_SCENE_LABEL).x +
style.FramePadding.x * 2.f + style.ItemSpacing.x));
const std::string* current = context.getSceneName(context.getSceneHandle());
bool startRename = false;
if (renamingScene && current) {
drawSceneRename();
} else {
renamingScene = false;
if (ImGui::BeginCombo("##scene", current ? current->c_str() : "No scene")) {
for (const EditorContext::OpenScene& open : context.getOpenScenes()) {
ImGui::PushID(static_cast<int>(open.handle.index));
bool isCurrent = open.handle == context.getSceneHandle();
if (ImGui::Selectable(open.name.c_str(), isCurrent) && !isCurrent) {
context.setScene(open.handle);
}
ImGui::PopID();
}
ImGui::EndCombo();
}
if (current && ImGui::BeginPopupContextItem("##sceneMenu")) {
startRename = ImGui::MenuItem("Rename", "F2");
ImGui::EndPopup();
}
startRename = startRename || (current && ImGui::IsWindowFocused() &&
!ImGui::GetIO().WantTextInput &&
ImGui::IsKeyPressed(ImGuiKey_F2));
}
ImGui::SameLine();
if (ImGui::Button(ADD_SCENE_LABEL)) {
std::string name = "Untitled " + std::to_string(++this->untitledSceneCount);
context.setScene(context.createScene(std::move(name)));
startRename = false;
}
if (startRename) {
renamingScene = true;
renameFocusFrames = RENAME_FOCUS_FRAMES;
std::snprintf(renameBuffer.data(), renameBuffer.size(), "%s",
current->c_str());
}
}
void SceneHierarchyPanel::drawSceneRename() {
if (renameFocusFrames == RENAME_FOCUS_FRAMES) {
ImGui::SetKeyboardFocusHere();
}
ImGui::InputText("##sceneName", renameBuffer.data(), renameBuffer.size(),
ImGuiInputTextFlags_AutoSelectAll);
bool active = ImGui::IsItemActive();
if (active) {
renameFocusFrames = 0;
} else if (renameFocusFrames > 0) {
renameFocusFrames--;
return;
}
// Escape reverts the text before deactivating, so committing whatever is
// left when the field closes covers Enter, clicking away and Escape alike.
if (!active) {
std::string name = renameBuffer.data();
// Keep the old name rather than leave the scene nameless.
if (name.find_first_not_of(' ') != std::string::npos) {
context.renameScene(context.getSceneHandle(), std::move(name));
}
renamingScene = false;
}
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <array>
#include <sophia/frame_info.hpp>
#include <sophia/handles.hpp>
#include <sophia/ui/panel.hpp>
#include "../editor_context.hpp"
class SceneHierarchyPanel : public sophia::Panel {
public:
// `cubeMesh` is what "+ Cube" gives new entities.
SceneHierarchyPanel(EditorContext& context, sophia::MeshHandle cubeMesh)
: Panel{{.name = "Scene Hierarchy", .locked = true}},
context{context},
cubeMesh{cubeMesh} {}
void onUpdate(const sophia::FrameInfo& frameInfo) override;
private:
// Switches between open scenes, renames the active one, and creates new
// ones.
void drawSceneSelector();
// Replaces the selector with a text field editing the active scene's name.
void drawSceneRename();
EditorContext& context;
sophia::MeshHandle cubeMesh;
// For naming new scenes "Untitled 1", "Untitled 2", ...
int untitledSceneCount = 0;
// Set while the scene selector is a text field for renaming.
bool renamingScene = false;
// Frames the text field has left to become active after renaming starts;
// focus can take a frame to land. If it never does, renaming ends.
int renameFocusFrames = 0;
std::array<char, 128> renameBuffer{};
};
+603
View File
@@ -0,0 +1,603 @@
#include "scene_panel.hpp"
#include <imgui.h>
// After imgui.h, which it relies on.
#include <ImGuizmo.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <glm/gtc/type_ptr.hpp>
#include <limits>
#include <optional>
#include <sophia/aabb.hpp>
#include <sophia/components.hpp>
#include <tuple>
#include <utility>
#include <vector>
namespace {
// Corners of the unit quad a SpriteComponent's model matrix maps to world.
constexpr std::array<glm::vec2, 4> UNIT_QUAD{
glm::vec2{0.f, 0.f}, glm::vec2{1.f, 0.f}, glm::vec2{1.f, 1.f},
glm::vec2{0.f, 1.f}};
const ImU32 SELECTION_COLOR = IM_COL32(255, 170, 40, 255);
// Holding Ctrl snaps the gizmo to these steps.
constexpr float TRANSLATE_SNAP = 0.5f;
constexpr float ROTATE_SNAP_DEGREES = 15.f;
constexpr float SCALE_SNAP = 0.1f;
// Writes a matrix built from a translation, rotation and scale (no shear)
// back into `transform`. A mirroring matrix comes back with a negative x
// scale.
void decompose(const glm::mat4& matrix, sophia::TransformComponent& transform) {
transform.position = glm::vec3{matrix[3]};
glm::vec3 scale{glm::length(glm::vec3{matrix[0]}),
glm::length(glm::vec3{matrix[1]}),
glm::length(glm::vec3{matrix[2]})};
if (glm::determinant(glm::mat3{matrix}) < 0.f) {
scale.x = -scale.x;
}
transform.scale = scale;
// A zero scale loses the axis, and with it the rotation; keep the old one.
if (scale.x == 0.f || scale.y == 0.f || scale.z == 0.f) {
return;
}
glm::mat3 rotation{glm::vec3{matrix[0]} / scale.x,
glm::vec3{matrix[1]} / scale.y,
glm::vec3{matrix[2]} / scale.z};
transform.rotation = glm::normalize(glm::quat_cast(rotation));
}
// How far along a ray (from `origin`, along the normalized `direction`) it
// enters `bounds` placed by `model`, or null if it misses. A ray starting
// inside hits at 0.
std::optional<float> intersectBox(const glm::vec3& origin,
const glm::vec3& direction,
const glm::mat4& model,
const sophia::AABB& bounds) {
if (glm::determinant(model) == 0.f) {
return std::nullopt;
}
// Test in the box's space, where it's axis-aligned. The direction isn't
// renormalized, so distances along it are still world distances.
glm::mat4 inverse = glm::inverse(model);
glm::vec3 localOrigin = glm::vec3{inverse * glm::vec4{origin, 1.f}};
glm::vec3 localDirection = glm::vec3{inverse * glm::vec4{direction, 0.f}};
float enter = 0.f;
float exit = std::numeric_limits<float>::infinity();
for (int axis = 0; axis < 3; axis++) {
if (std::abs(localDirection[axis]) < 1e-8f) {
// Parallel to this pair of faces: hits only if already between them.
if (localOrigin[axis] < bounds.min[axis] ||
localOrigin[axis] > bounds.max[axis]) {
return std::nullopt;
}
continue;
}
float toMin = (bounds.min[axis] - localOrigin[axis]) / localDirection[axis];
float toMax = (bounds.max[axis] - localOrigin[axis]) / localDirection[axis];
if (toMin > toMax) {
std::swap(toMin, toMax);
}
enter = std::max(enter, toMin);
exit = std::min(exit, toMax);
if (enter > exit) {
return std::nullopt;
}
}
return enter;
}
// How far along a ray it hits the unit quad (0, 0)-(1, 1) in the XY plane,
// placed by `model`, or null if it misses.
std::optional<float> intersectQuad(const glm::vec3& origin,
const glm::vec3& direction,
const glm::mat4& model) {
if (glm::determinant(model) == 0.f) {
return std::nullopt;
}
glm::mat4 inverse = glm::inverse(model);
glm::vec3 localOrigin = glm::vec3{inverse * glm::vec4{origin, 1.f}};
glm::vec3 localDirection = glm::vec3{inverse * glm::vec4{direction, 0.f}};
// Edge on to the quad.
if (std::abs(localDirection.z) < 1e-8f) {
return std::nullopt;
}
float distance = -localOrigin.z / localDirection.z;
if (distance < 0.f) {
return std::nullopt;
}
glm::vec3 hit = localOrigin + distance * localDirection;
if (hit.x < 0.f || hit.x > 1.f || hit.y < 0.f || hit.y > 1.f) {
return std::nullopt;
}
return distance;
}
} // namespace
ImVec2 ScenePanel::ViewportTransform::clipToScreen(
const glm::vec4& clip) const {
glm::vec2 ndc = glm::vec2{clip} / clip.w;
// Vulkan NDC has +y pointing down, same as the screen.
return {origin.x + (ndc.x * 0.5f + 0.5f) * size.x,
origin.y + (ndc.y * 0.5f + 0.5f) * size.y};
}
std::optional<ImVec2> ScenePanel::ViewportTransform::worldToScreen(
const glm::vec3& world) const {
glm::vec4 clip = viewProjection * glm::vec4{world, 1.f};
if (clip.w <= 0.f) {
return std::nullopt;
}
return clipToScreen(clip);
}
glm::vec2 ScenePanel::ViewportTransform::screenToWorld(
const ImVec2& screen) const {
glm::vec2 ndc{(screen.x - origin.x) / size.x * 2.f - 1.f,
(screen.y - origin.y) / size.y * 2.f - 1.f};
glm::vec4 world = inverseViewProjection * glm::vec4{ndc, 0.f, 1.f};
return glm::vec2{world} / world.w;
}
ScenePanel::Ray ScenePanel::ViewportTransform::screenToRay(
const ImVec2& screen) const {
glm::vec2 ndc{(screen.x - origin.x) / size.x * 2.f - 1.f,
(screen.y - origin.y) / size.y * 2.f - 1.f};
// Depth runs 0 at the near plane to 1 at the far one.
glm::vec4 nearPoint = inverseViewProjection * glm::vec4{ndc, 0.f, 1.f};
glm::vec4 farPoint = inverseViewProjection * glm::vec4{ndc, 1.f, 1.f};
glm::vec3 from = glm::vec3{nearPoint} / nearPoint.w;
glm::vec3 to = glm::vec3{farPoint} / farPoint.w;
return {from, glm::normalize(to - from)};
}
void ScenePanel::ViewportTransform::drawLine(ImDrawList* drawList,
const glm::vec3& a,
const glm::vec3& b, ImU32 color,
float thickness) const {
// Just in front of the camera. Points behind it project mirrored, so the
// part of the line there is cut off rather than drawn.
constexpr float MIN_W = 1e-4f;
glm::vec4 clipA = viewProjection * glm::vec4{a, 1.f};
glm::vec4 clipB = viewProjection * glm::vec4{b, 1.f};
if (clipA.w < MIN_W && clipB.w < MIN_W) {
return;
}
if (clipA.w < MIN_W) {
clipA = glm::mix(clipA, clipB, (MIN_W - clipA.w) / (clipB.w - clipA.w));
} else if (clipB.w < MIN_W) {
clipB = glm::mix(clipB, clipA, (MIN_W - clipB.w) / (clipA.w - clipB.w));
}
drawList->AddLine(clipToScreen(clipA), clipToScreen(clipB), color, thickness);
}
void ScenePanel::onUpdate(const sophia::FrameInfo& frameInfo) {
syncSceneView(context, sceneViews, view, isShown());
drawToolbar();
ImGui::Separator();
const ImVec2 origin = ImGui::GetCursorScreenPos();
const ImVec2 size = ImGui::GetContentRegionAvail();
if (size.x < 1.0f || size.y < 1.0f) {
return;
}
if (!context.hasScene()) {
ImGui::TextDisabled("No scene open");
return;
}
ui.image(target);
// Rolls over ImGuizmo's "mouse is over a handle" state, so IsOver() below
// reports last frame's.
ImGuizmo::BeginFrame();
// A handle can't be grabbed while any ImGui item is hovered, so while the
// mouse is over one, leave out the viewport button, and keep the window
// still so dragging the handle doesn't drag it. Unless a camera drag is
// already under way: dropping the button would end it.
bool overGizmo =
(ImGuizmo::IsOver() || ImGuizmo::IsUsing()) && !viewportActive;
getConfig().windowFlags =
overGizmo ? WINDOW_FLAGS | ImGuiWindowFlags_NoMove : WINDOW_FLAGS;
// The image doesn't take mouse input, so without this, dragging in it
// would drag the window instead.
ImGui::SetCursorScreenPos(origin);
if (overGizmo) {
ImGui::Dummy(size);
} else {
ImGui::InvisibleButton("##viewport", size,
ImGuiButtonFlags_MouseButtonLeft |
ImGuiButtonFlags_MouseButtonRight |
ImGuiButtonFlags_MouseButtonMiddle);
}
viewportActive = !overGizmo && ImGui::IsItemActive();
handleCameraInput(frameInfo, size);
handleGizmoHotkeys();
// Drive the view's camera from the editor camera. The scene view renders
// after the panels update, so this frame already uses it.
camera.getComponent<sophia::TransformComponent>() =
editorCamera.getTransform();
camera.getComponent<sophia::CameraComponent>() = editorCamera.getCamera();
ImDrawList* drawList = ImGui::GetWindowDrawList();
drawList->PushClipRect(origin, ImVec2(origin.x + size.x, origin.y + size.y),
true);
// Screen-space, so it only lines up with anything in 2D.
// TODO: a world-space grid on the XZ plane for 3D, once there's debug
// drawing in scene views.
if (showGrid && editorCamera.mode == EditorCamera::Mode::Scene2D) {
drawGrid(origin, size);
}
// Use the panel's size rather than the target's: the target is resized a
// frame late, and the mouse is over the panel.
float aspect = size.x / size.y;
glm::mat4 view = sophia::CameraComponent::getView(
camera.getComponent<sophia::TransformComponent>());
glm::mat4 viewProjection =
camera.getComponent<sophia::CameraComponent>().getProjection(aspect) *
view;
ViewportTransform viewport{origin, size, viewProjection,
glm::inverse(viewProjection)};
handleSelectionInput(viewport);
drawSelection(viewport);
ImGuizmo::SetDrawlist();
ImGuizmo::SetRect(origin.x, origin.y, size.x, size.y);
drawGizmo(view, aspect);
drawList->PopClipRect();
ImGui::SetCursorScreenPos(ImVec2(origin.x + 8.0f, origin.y + 8.0f));
ImGui::TextDisabled("%.0f x %.0f", size.x, size.y);
}
void ScenePanel::drawToolbar() {
bool is2D = editorCamera.mode == EditorCamera::Mode::Scene2D;
if (ImGui::Checkbox("2D", &is2D)) {
editorCamera.mode =
is2D ? EditorCamera::Mode::Scene2D : EditorCamera::Mode::Scene3D;
dragging = false;
}
ImGui::SameLine();
ImGui::BeginDisabled(!is2D);
ImGui::Checkbox("Grid", &showGrid);
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::TextDisabled("|");
ImGui::SameLine();
const struct {
const char* label;
GizmoOperation operation;
} operations[] = {{"Move (W)", GizmoOperation::Translate},
{"Rotate (E)", GizmoOperation::Rotate},
{"Scale (R)", GizmoOperation::Scale}};
for (const auto& entry : operations) {
if (ImGui::RadioButton(entry.label, gizmoOperation == entry.operation)) {
gizmoOperation = entry.operation;
}
ImGui::SameLine();
}
if (ImGui::Button(gizmoLocal ? "Local" : "World")) {
gizmoLocal = !gizmoLocal;
}
}
void ScenePanel::handleGizmoHotkeys() {
const ImGuiIO& io = ImGui::GetIO();
// W/E/Q fly the camera while the right button is held.
if (!ImGui::IsWindowFocused() || io.WantTextInput ||
ImGui::IsMouseDown(ImGuiMouseButton_Right)) {
return;
}
if (ImGui::IsKeyPressed(ImGuiKey_W, false)) {
gizmoOperation = GizmoOperation::Translate;
} else if (ImGui::IsKeyPressed(ImGuiKey_E, false)) {
gizmoOperation = GizmoOperation::Rotate;
} else if (ImGui::IsKeyPressed(ImGuiKey_R, false)) {
gizmoOperation = GizmoOperation::Scale;
}
}
void ScenePanel::drawGizmo(const glm::mat4& view, float aspect) {
sophia::Entity selected = context.selected;
if (!selected) {
return;
}
bool is2D = editorCamera.mode == EditorCamera::Mode::Scene2D;
// In 2D, only the axes that keep things in the XY plane.
ImGuizmo::OPERATION operation{};
float snapStep = 0.f;
switch (gizmoOperation) {
case GizmoOperation::Translate:
operation = is2D ? ImGuizmo::TRANSLATE_X | ImGuizmo::TRANSLATE_Y
: ImGuizmo::TRANSLATE;
snapStep = TRANSLATE_SNAP;
break;
case GizmoOperation::Rotate:
operation = is2D ? ImGuizmo::ROTATE_Z : ImGuizmo::ROTATE;
snapStep = ROTATE_SNAP_DEGREES;
break;
case GizmoOperation::Scale:
operation =
is2D ? ImGuizmo::SCALE_X | ImGuizmo::SCALE_Y : ImGuizmo::SCALE;
snapStep = SCALE_SNAP;
break;
}
std::array<float, 3> snap{snapStep, snapStep, snapStep};
bool snapping = ImGui::GetIO().KeyCtrl;
// ImGuizmo maps clip space to the screen the OpenGL way, with +y up, so it
// needs the projection without the Y flip CameraComponent adds for Vulkan.
glm::mat4 projection =
camera.getComponent<sophia::CameraComponent>().getProjection(aspect);
projection[1][1] *= -1.f;
auto& transform = selected.getComponent<sophia::TransformComponent>();
glm::mat4 model = transform.mat4();
ImGuizmo::SetOrthographic(is2D);
// Per entity, so a drag in progress doesn't carry over to a new selection.
ImGuizmo::PushID(static_cast<int>(static_cast<sophia::u32>(selected)));
bool changed = ImGuizmo::Manipulate(
glm::value_ptr(view), glm::value_ptr(projection), operation,
gizmoLocal ? ImGuizmo::LOCAL : ImGuizmo::WORLD, glm::value_ptr(model),
nullptr, snapping ? snap.data() : nullptr);
ImGuizmo::PopID();
if (changed) {
decompose(model, transform);
}
}
void ScenePanel::drawGrid(ImVec2 origin, ImVec2 size) {
constexpr float GRID_STEP = 40.0f;
const ImU32 gridColor = IM_COL32(255, 255, 255, 18);
const ImVec2 end(origin.x + size.x, origin.y + size.y);
ImDrawList* drawList = ImGui::GetWindowDrawList();
for (float x = GRID_STEP; x < size.x; x += GRID_STEP) {
drawList->AddLine(ImVec2(origin.x + x, origin.y),
ImVec2(origin.x + x, end.y), gridColor);
}
for (float y = GRID_STEP; y < size.y; y += GRID_STEP) {
drawList->AddLine(ImVec2(origin.x, origin.y + y),
ImVec2(end.x, origin.y + y), gridColor);
}
}
void ScenePanel::handleCameraInput(const sophia::FrameInfo& frameInfo,
ImVec2 size) {
const ImGuiIO& io = ImGui::GetIO();
bool is3D = editorCamera.mode == EditorCamera::Mode::Scene3D;
glm::vec2 mouseDelta{io.MouseDelta.x, io.MouseDelta.y};
// The button stays active while any of its mouse buttons is held, even
// once the mouse leaves the panel.
if (ImGui::IsItemActive()) {
bool rightDown = ImGui::IsMouseDown(ImGuiMouseButton_Right);
bool middleDown = ImGui::IsMouseDown(ImGuiMouseButton_Middle);
if (is3D && rightDown) {
editorCamera.orbit(mouseDelta);
glm::vec3 direction{0.f};
if (ImGui::IsKeyDown(ImGuiKey_D)) direction.x += 1.f;
if (ImGui::IsKeyDown(ImGuiKey_A)) direction.x -= 1.f;
if (ImGui::IsKeyDown(ImGuiKey_E)) direction.y += 1.f;
if (ImGui::IsKeyDown(ImGuiKey_Q)) direction.y -= 1.f;
if (ImGui::IsKeyDown(ImGuiKey_S)) direction.z += 1.f;
if (ImGui::IsKeyDown(ImGuiKey_W)) direction.z -= 1.f;
editorCamera.fly(direction, static_cast<float>(frameInfo.deltaTime));
} else if (middleDown || (!is3D && rightDown)) {
editorCamera.pan(mouseDelta, size.y);
}
}
if (ImGui::IsItemHovered() && io.MouseWheel != 0.f) {
editorCamera.zoom(io.MouseWheel);
}
if (ImGui::IsWindowFocused() && !io.WantTextInput &&
ImGui::IsKeyPressed(ImGuiKey_F, false)) {
frameSelection();
}
}
void ScenePanel::handleSelectionInput(const ViewportTransform& viewport) {
const ImGuiIO& io = ImGui::GetIO();
if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) {
context.selected = pick(viewport.screenToRay(io.MousePos));
// Dragging the selection around is 2D only; in 3D, use the gizmo.
dragging =
context.selected && editorCamera.mode == EditorCamera::Mode::Scene2D;
}
if (!ImGui::IsItemActive() || !ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
dragging = false;
return;
}
if (dragging && (io.MouseDelta.x != 0.f || io.MouseDelta.y != 0.f)) {
ImVec2 previous(io.MousePos.x - io.MouseDelta.x,
io.MousePos.y - io.MouseDelta.y);
glm::vec2 delta =
viewport.screenToWorld(io.MousePos) - viewport.screenToWorld(previous);
auto& transform =
context.selected.getComponent<sophia::TransformComponent>();
transform.position.x += delta.x;
transform.position.y += delta.y;
}
}
void ScenePanel::drawSelection(const ViewportTransform& viewport) {
sophia::Entity selected = context.selected;
if (!selected) {
return;
}
ImDrawList* drawList = ImGui::GetWindowDrawList();
const auto& transform = selected.getComponent<sophia::TransformComponent>();
if (selected.hasComponent<sophia::SpriteComponent>()) {
glm::mat4 model =
selected.getComponent<sophia::SpriteComponent>().getModel(transform);
for (size_t i = 0; i < UNIT_QUAD.size(); i++) {
const glm::vec2& from = UNIT_QUAD[i];
const glm::vec2& to = UNIT_QUAD[(i + 1) % UNIT_QUAD.size()];
viewport.drawLine(drawList, glm::vec3{model * glm::vec4{from, 0.f, 1.f}},
glm::vec3{model * glm::vec4{to, 0.f, 1.f}},
SELECTION_COLOR, 2.0f);
}
}
if (std::optional<sophia::AABB> bounds = getMeshBounds(selected)) {
glm::mat4 model = transform.mat4();
std::array<glm::vec3, 8> corners = bounds->getCorners();
for (glm::vec3& corner : corners) {
corner = glm::vec3{model * glm::vec4{corner, 1.f}};
}
// Corners whose indices differ in one bit share an edge.
for (int i = 0; i < 8; i++) {
for (int bit : {1, 2, 4}) {
if ((i & bit) == 0) {
viewport.drawLine(drawList, corners[i], corners[i | bit],
SELECTION_COLOR, 2.0f);
}
}
}
}
// Mark the transform's position (the sprite's pivot), which is also all
// there is to show for entities without a sprite or mesh, such as the
// camera.
if (std::optional<ImVec2> center =
viewport.worldToScreen(transform.position)) {
drawList->AddCircle(*center, 4.0f, SELECTION_COLOR, 0, 2.0f);
}
}
void ScenePanel::frameSelection() {
sophia::Entity selected = context.selected;
if (!selected) {
return;
}
const auto& transform = selected.getComponent<sophia::TransformComponent>();
// The selection's corners in world space, from its mesh or sprite.
std::vector<glm::vec3> points;
if (std::optional<sophia::AABB> bounds = getMeshBounds(selected)) {
glm::mat4 model = transform.mat4();
for (const glm::vec3& corner : bounds->getCorners()) {
points.push_back(glm::vec3{model * glm::vec4{corner, 1.f}});
}
}
if (selected.hasComponent<sophia::SpriteComponent>()) {
glm::mat4 model =
selected.getComponent<sophia::SpriteComponent>().getModel(transform);
for (const glm::vec2& corner : UNIT_QUAD) {
points.push_back(glm::vec3{model * glm::vec4{corner, 0.f, 1.f}});
}
}
if (points.empty()) {
// Nothing with a size: frame a unit around its position.
editorCamera.frame(transform.position, 0.5f);
return;
}
glm::vec3 min = points.front();
glm::vec3 max = points.front();
for (const glm::vec3& point : points) {
min = glm::min(min, point);
max = glm::max(max, point);
}
editorCamera.frame((min + max) * 0.5f, glm::length(max - min) * 0.5f);
}
std::optional<sophia::AABB> ScenePanel::getMeshBounds(sophia::Entity entity) {
if (!entity.hasComponent<sophia::MeshComponent>()) {
return std::nullopt;
}
sophia::MeshHandle mesh = entity.getComponent<sophia::MeshComponent>().handle;
if (!resources.isAlive(mesh)) {
return std::nullopt;
}
return resources.getBounds(mesh);
}
sophia::Entity ScenePanel::pick(const Ray& ray) {
sophia::Scene& scene = context.getScene();
// Meshes are opaque and depth tested, so the nearest one hides anything
// behind it.
sophia::Entity nearestMesh;
float meshDistance = std::numeric_limits<float>::infinity();
scene.forEachEntity([&](sophia::Entity entity) {
std::optional<sophia::AABB> bounds = getMeshBounds(entity);
if (!bounds) {
return;
}
glm::mat4 model = entity.getComponent<sophia::TransformComponent>().mat4();
std::optional<float> distance =
intersectBox(ray.origin, ray.direction, model, *bounds);
if (distance && *distance < meshDistance) {
nearestMesh = entity;
meshDistance = *distance;
}
});
// Sprites in front of that mesh draw over it, in the renderer's order:
// higher sortOrder on top, then nearer.
sophia::Entity topSprite;
std::optional<std::tuple<sophia::s32, float>> topOrder;
scene.forEachEntity([&](sophia::Entity entity) {
if (!entity.hasComponent<sophia::SpriteComponent>()) {
return;
}
const auto& sprite = entity.getComponent<sophia::SpriteComponent>();
glm::mat4 model =
sprite.getModel(entity.getComponent<sophia::TransformComponent>());
std::optional<float> distance =
intersectQuad(ray.origin, ray.direction, model);
if (!distance || *distance > meshDistance) {
return;
}
// Negated distance, so nearer compares greater.
std::tuple order{sprite.sortOrder, -*distance};
if (!topOrder || order > *topOrder) {
topSprite = entity;
topOrder = order;
}
});
return topSprite ? topSprite : nearestMesh;
}
+125
View File
@@ -0,0 +1,125 @@
#pragma once
#include <optional>
#include <sophia/aabb.hpp>
#include <sophia/entity.hpp>
#include <sophia/frame_info.hpp>
#include <sophia/handles.hpp>
#include <sophia/math.hpp>
#include <sophia/resource_registry_ref.hpp>
#include <sophia/scene_view_registry.hpp>
#include <sophia/ui/panel.hpp>
#include <sophia/ui/ui.hpp>
#include "../editor_camera.hpp"
#include "../editor_context.hpp"
/**
* Shows the active scene through the editor's own camera.
*
* 3D: right-drag orbits, middle-drag pans, the wheel zooms, right-drag with
* WASD/QE flies. 2D: right- or middle-drag pans, the wheel zooms. F frames
* the selection in both.
*
* The selection gets a transform gizmo: W moves, E rotates, R scales, and
* holding Ctrl snaps. Clicking selects the mesh or sprite under the mouse;
* in 2D, dragging it also moves it.
*/
class ScenePanel : public sophia::Panel {
public:
// Renders into `target` through `view`, a SceneView this panel keeps
// pointed at the active scene. `camera` is the entity the view looks
// through; it belongs to a scene of the editor's own, and this panel
// drives it from its EditorCamera.
ScenePanel(sophia::UI& ui, sophia::ResourceRegistryRef resources,
EditorContext& context, sophia::SceneViewRegistry& sceneViews,
sophia::SceneViewHandle view, sophia::RenderTargetHandle target,
sophia::Entity camera)
: Panel{{.name = "Scene", .windowFlags = WINDOW_FLAGS}},
ui{ui},
resources{resources},
context{context},
sceneViews{sceneViews},
view{view},
target{target},
camera{camera} {}
void onUpdate(const sophia::FrameInfo& frameInfo) override;
private:
static constexpr ImGuiWindowFlags WINDOW_FLAGS =
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse;
enum class GizmoOperation { Translate, Rotate, Scale };
// A half-line in world space. `direction` is normalized, so distances
// along it are in world units.
struct Ray {
glm::vec3 origin;
glm::vec3 direction;
};
// Where the scene image is on screen this frame, and the camera looking
// through it.
struct ViewportTransform {
ImVec2 origin;
ImVec2 size;
glm::mat4 viewProjection;
glm::mat4 inverseViewProjection;
// Null for points behind the camera.
std::optional<ImVec2> worldToScreen(const glm::vec3& world) const;
// Where `screen` is on the camera's near plane. Only the x and y, which
// is all that's needed in 2D.
glm::vec2 screenToWorld(const ImVec2& screen) const;
// From the near plane through `screen`, into the scene.
Ray screenToRay(const ImVec2& screen) const;
// Draws a world-space line, cut off where it passes behind the camera.
void drawLine(ImDrawList* drawList, const glm::vec3& a, const glm::vec3& b,
ImU32 color, float thickness) const;
private:
ImVec2 clipToScreen(const glm::vec4& clip) const;
};
void drawToolbar();
void drawGrid(ImVec2 origin, ImVec2 size);
void handleCameraInput(const sophia::FrameInfo& frameInfo, ImVec2 size);
void handleSelectionInput(const ViewportTransform& viewport);
void handleGizmoHotkeys();
// Draws the transform gizmo on the selection, and applies any change made
// with it. `view` is the editor camera's, and `aspect` the viewport's
// width over its height.
void drawGizmo(const glm::mat4& view, float aspect);
void drawSelection(const ViewportTransform& viewport);
void frameSelection();
// The bounds of the entity's mesh in the mesh's own space, if it has one.
std::optional<sophia::AABB> getMeshBounds(sophia::Entity entity);
// The entity drawn in front along `ray`, or a null Entity. Only meshes and
// sprites can be hit.
sophia::Entity pick(const Ray& ray);
sophia::UI& ui;
sophia::ResourceRegistryRef resources;
EditorContext& context;
sophia::SceneViewRegistry& sceneViews;
sophia::SceneViewHandle view;
sophia::RenderTargetHandle target;
sophia::Entity camera;
EditorCamera editorCamera;
GizmoOperation gizmoOperation = GizmoOperation::Translate;
// Whether the gizmo works along the entity's own axes or the world's.
bool gizmoLocal = true;
// Whether the viewport button was active last frame, i.e. a camera drag or
// click is under way, which the gizmo mustn't interrupt.
bool viewportActive = false;
bool showGrid = true;
// Set while a drag that started on the selected entity is moving it.
bool dragging = false;
};
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <format>
#include <sophia/util/logger.hpp>
#include <utility>
template <typename... Args>
void log(std::format_string<Args...> fmt, Args&&... args) {
if constexpr (sophia::log::enabled) {
sophia::log::print("\033[1;38;2;170;90;255m[editor]\033[0m {}",
std::format(fmt, std::forward<Args>(args)...));
}
}
+12
View File
@@ -17,10 +17,22 @@ target_include_directories(${ENGINE_NAME} PUBLIC
"${CMAKE_CURRENT_SOURCE_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}/include"
) )
target_compile_definitions(${ENGINE_NAME} PUBLIC
GLM_FORCE_RADIANS
GLM_FORCE_DEPTH_ZERO_TO_ONE
)
target_link_libraries(${ENGINE_NAME} PUBLIC target_link_libraries(${ENGINE_NAME} PUBLIC
vulkan vulkan
vma
shaderc
glfw glfw
glm glm
imgui imgui
entt entt
Tracy::TracyClient
)
target_link_libraries(${ENGINE_NAME} PRIVATE
stb_image
) )
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <array>
#include <sophia/math.hpp>
namespace sophia {
/** @brief Axis-Aligned Bounding Box
*
* An axis-aligned box, from its smallest corner to its largest.
*/
struct AABB {
vec3 min{0.f, 0.f, 0.f};
vec3 max{0.f, 0.f, 0.f};
vec3 getCenter() const { return (min + max) * 0.5f; }
vec3 getSize() const { return max - min; }
// The 8 corners. Bit 0 of the index picks x from max, bit 1 y, bit 2 z.
std::array<vec3, 8> getCorners() const {
std::array<vec3, 8> corners;
for (int i = 0; i < 8; i++) {
corners[i] = {(i & 1) ? max.x : min.x, (i & 2) ? max.y : min.y,
(i & 4) ? max.z : min.z};
}
return corners;
}
};
} // namespace sophia
+15 -9
View File
@@ -3,20 +3,20 @@
#include <cstdlib> #include <cstdlib>
#include <iostream> #include <iostream>
#include <memory> #include <memory>
#include <sophia/asset_registry.hpp>
#include <sophia/camera_2d.hpp>
#include <sophia/canvas.hpp> #include <sophia/canvas.hpp>
#include <sophia/console/console_command_registry.hpp>
#include <sophia/frame_info.hpp> #include <sophia/frame_info.hpp>
#include <sophia/math.hpp>
#include <sophia/resource_registry_ref.hpp>
#include <sophia/scene_registry.hpp>
#include <sophia/scene_view_registry.hpp>
#include <sophia/types.hpp> #include <sophia/types.hpp>
#include <sophia/ui/debug_ui.hpp> #include <sophia/ui/debug_ui.hpp>
#include <sophia/ui/panel_registry.hpp> #include <sophia/ui/panel_registry.hpp>
#include <sophia/ui/ui.hpp>
#include <string> #include <string>
#include <vector> #include <vector>
#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/glm.hpp>
namespace sophia { namespace sophia {
class Application { class Application {
@@ -43,15 +43,19 @@ class Application {
virtual void onRender() {} virtual void onRender() {}
virtual void onDestroy() {} virtual void onDestroy() {}
AssetRegistry& assets() { return this->assetRegistry; } ResourceRegistryRef resources();
PanelRegistry& panels() { return this->panelRegistry; } PanelRegistry& panels() { return this->panelRegistry; }
SceneRegistry& scenes() { return this->sceneRegistry; }
SceneViewRegistry& sceneViews() { return this->sceneViewRegistry; }
UI& ui() { return this->_ui; }
DebugUI& debugUI() { return this->_debugUI; } DebugUI& debugUI() { return this->_debugUI; }
Canvas canvas; Canvas canvas;
Camera2D camera;
std::vector<std::string> args; std::vector<std::string> args;
ConsoleCommandRegistry consoleCommandRegistry;
void drawMesh(MeshHandle handle, const glm::mat4& transform); void drawMesh(MeshHandle handle, const glm::mat4& transform);
private: private:
@@ -63,8 +67,10 @@ class Application {
class Impl; class Impl;
std::unique_ptr<Impl> impl; std::unique_ptr<Impl> impl;
AssetRegistry assetRegistry;
PanelRegistry panelRegistry; PanelRegistry panelRegistry;
SceneRegistry sceneRegistry;
SceneViewRegistry sceneViewRegistry;
UI _ui;
DebugUI _debugUI; DebugUI _debugUI;
}; };
-64
View File
@@ -1,64 +0,0 @@
#pragma once
#include <memory>
#include <sophia/handles.hpp>
#include <string>
#include <vector>
#include "sophia/canvas.hpp"
#include "sophia/types.hpp"
namespace sophia {
class Device;
class Mesh;
template <typename T>
struct AssetTraits;
template <>
struct AssetTraits<Mesh> {
using Handle = MeshHandle;
};
class AssetRegistry {
public:
~AssetRegistry();
AssetRegistry(const AssetRegistry&) = delete;
AssetRegistry& operator=(const AssetRegistry&) = delete;
template <typename T>
typename AssetTraits<T>::Handle load(const std::string& path);
/**
* TEMPORARY: Remove once load<Mesh> can read real model files from disk.
*/
MeshHandle loadMesh(std::vector<CanvasVertex> vertices,
std::vector<u32> indices);
template <typename T>
void release(typename AssetTraits<T>::Handle handle);
private:
friend class Application;
explicit AssetRegistry(Device& device);
template <typename T>
const T& get(typename AssetTraits<T>::Handle handle) const;
class Impl;
std::unique_ptr<Impl> impl;
};
template <>
MeshHandle AssetRegistry::load<Mesh>(const std::string& path);
template <>
void AssetRegistry::release<Mesh>(MeshHandle handle);
template <>
const Mesh& AssetRegistry::get<Mesh>(MeshHandle handle) const;
} // namespace sophia
-30
View File
@@ -1,30 +0,0 @@
#pragma once
#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
namespace sophia {
class Camera2D {
public:
glm::vec2 position{0.f, 0.f};
void setProjection(float left, float right, float bottom, float top) {
projection = glm::ortho(left, right, bottom, top);
}
const glm::mat4& getProjection() const { return projection; }
glm::mat4 getView() const {
return glm::translate(glm::mat4(1.f), glm::vec3(-position, 0.f));
}
glm::mat4 getViewProjection() const { return projection * getView(); }
private:
glm::mat4 projection{1.f};
};
} // namespace sophia
+1 -4
View File
@@ -2,12 +2,9 @@
#include <memory> #include <memory>
#include <sophia/frame_info.hpp> #include <sophia/frame_info.hpp>
#include <sophia/math.hpp>
#include <vector> #include <vector>
#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/glm.hpp>
namespace sophia { namespace sophia {
class Application; class Application;
+113 -7
View File
@@ -1,18 +1,17 @@
#pragma once #pragma once
#include <sophia/asset_registry.hpp>
#include <string>
#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp> #include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/quaternion.hpp> #include <iostream>
#include <ostream>
#include <sophia/handles.hpp>
#include <sophia/math.hpp>
#include <string>
namespace sophia { namespace sophia {
struct TagComponent { struct TagComponent {
std::string tag; // entt::hashed_string ? std::string tag; // entt::hashed_string ?
uint32_t mask = 0;
}; };
struct TransformComponent { struct TransformComponent {
@@ -28,10 +27,117 @@ struct TransformComponent {
glm::mat3 normalMatrix() const { glm::mat3 normalMatrix() const {
return glm::mat3{glm::transpose(glm::inverse(mat4()))}; return glm::mat3{glm::transpose(glm::inverse(mat4()))};
} }
friend std::ostream& operator<<(std::ostream& os,
const TransformComponent& transform) {
os << "TransformComponent {\n"
<< " Position: (" << transform.position.x << ", "
<< transform.position.y << ", " << transform.position.z << ")\n"
<< " Rotation (w, x, y, z): (" << transform.rotation.w << ", "
<< transform.rotation.x << ", " << transform.rotation.y << ", "
<< transform.rotation.z << ")\n"
<< " Scale: (" << transform.scale.x << ", " << transform.scale.y << ", "
<< transform.scale.z << ")\n"
<< "}";
return os;
}
}; };
/**
* Draws a mesh, placed by the entity's TransformComponent. Opaque and unlit
* for now.
*/
struct MeshComponent { struct MeshComponent {
MeshHandle handle; MeshHandle handle;
// Multiplied with the mesh's vertex colors. Alpha is ignored. Stands in for
// a material until there are materials.
glm::vec4 color{1.f, 1.f, 1.f, 1.f};
};
/**
* A textured quad in the XY plane, placed by the entity's TransformComponent.
* Only rotation around Z is meaningful.
*/
struct SpriteComponent {
// Invalid draws a solid quad in `color`.
TextureHandle texture = INVALID_HANDLE;
glm::vec4 color{1.f, 1.f, 1.f, 1.f};
// Size in world units, before the transform's scale.
glm::vec2 size{1.f, 1.f};
// Point on the sprite, from (0, 0) at its bottom-left to (1, 1) at its
// top-right, that sits at the transform's position and that it rotates
// around.
glm::vec2 pivot{0.5f, 0.5f};
// Region of the texture to draw, as normalized (u0, v0, u1, v1) with
// (0, 0) at the texture's top-left.
glm::vec4 uvRect{0.f, 0.f, 1.f, 1.f};
// Higher draws on top, whatever the depth. Sprites with the same sortOrder
// draw back to front from the camera; the transform's position.z is depth
// like any other axis, not a layer.
s32 sortOrder = 0;
// Maps the unit quad (0, 0)-(1, 1) to world space: to the sprite's
// rectangle around its pivot, then by `transform`.
glm::mat4 getModel(const TransformComponent& transform) const {
return transform.mat4() *
glm::translate(glm::mat4{1.f}, glm::vec3{-pivot * size, 0.f}) *
glm::scale(glm::mat4{1.f}, glm::vec3{size, 1.f});
}
};
struct CameraComponent {
struct Orthographic {
float height = 10.f;
float nearPlane = -1000.f;
float farPlane = 1000.f;
};
struct Perspective {
float fov = glm::radians(60.f);
float nearPlane = 0.1f;
float farPlane = 100.f;
};
enum class Projection { Orthographic, Perspective };
Projection projection = Projection::Orthographic;
Orthographic ortho;
Perspective perspective;
// View -> clip space. World +Y is up on screen: Vulkan's clip space has +Y
// pointing down, so the projection flips it.
glm::mat4 getProjection(float aspect) const {
glm::mat4 result;
if (projection == Projection::Perspective) {
result = glm::perspective(perspective.fov, aspect, perspective.nearPlane,
perspective.farPlane);
} else {
float halfHeight = ortho.height / 2.f;
float halfWidth = halfHeight * aspect;
result = glm::ortho(-halfWidth, halfWidth, -halfHeight, halfHeight,
ortho.nearPlane, ortho.farPlane);
}
result[1][1] *= -1.f;
return result;
}
// World -> view space for this camera placed by `transform`. Ignores the
// transform's scale; zoom is the projection's job. The camera looks down
// view-space -Z.
static glm::mat4 getView(const TransformComponent& transform) {
glm::mat4 world = glm::translate(glm::mat4{1.f}, transform.position) *
glm::mat4_cast(transform.rotation);
return glm::inverse(world);
}
// World -> clip space for this camera placed by `transform`.
glm::mat4 getViewProjection(const TransformComponent& transform,
float aspect) const {
return getProjection(aspect) * getView(transform);
}
}; };
} // namespace sophia } // namespace sophia
@@ -0,0 +1,35 @@
#pragma once
#include <functional>
#include <map>
#include <string>
#include <vector>
namespace sophia {
class ConsoleCommandRegistry {
public:
using Handler = std::function<std::string(const std::vector<std::string>&)>;
struct CommandInfo {
std::string name;
std::string help;
};
void registerCommand(std::string name, std::string help, Handler handler);
bool dispatch(const std::string& name, const std::vector<std::string>& args,
std::string& outResult) const;
std::vector<CommandInfo> list() const;
private:
struct Command {
std::string help;
Handler handler;
};
std::map<std::string, Command> commands;
};
} // namespace sophia
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <entt/entt.hpp>
#include <sophia/scene.hpp>
#include <sophia/types.hpp>
#include <sophia/util/assert.hpp>
namespace sophia {
class Entity {
public:
Entity(const Entity&) = default;
Entity& operator=(const Entity&) = default;
Entity() = default;
Entity(entt::entity handle, Scene* scene);
template <typename T, typename... Args>
T& addComponent(Args&&... args) {
SOPHIA_ASSERT(!hasComponent<T>(), "Entity already has component!");
T& component =
scene->registry.emplace<T>(handle, std::forward<Args>(args)...);
// scene->OnComponentAdded<T>(*this, component);
return component;
}
template <typename T>
T& getComponent() {
SOPHIA_ASSERT(hasComponent<T>(), "Entity does not have component!");
return scene->registry.get<T>(handle);
}
template <typename T>
bool hasComponent() {
return scene->registry.all_of<T>(handle);
}
template <typename T>
void removeComponent() {
SOPHIA_ASSERT(hasComponent<T>(), "Entity does not have component!");
scene->registry.remove<T>(handle);
}
operator bool() const { return handle != entt::null; }
operator entt::entity() const { return handle; }
operator u32() const { return (u32)handle; }
// UUID GetUUID() { return GetComponent<IDComponent>().ID; }
// const std::string& GetName() { return GetComponent<TagComponent>().Tag; }
bool operator==(const Entity& other) const {
return handle == other.handle && scene == other.scene;
}
bool operator!=(const Entity& other) const { return !(*this == other); }
private:
entt::entity handle{entt::null};
Scene* scene = nullptr;
};
template <typename Fn>
void Scene::forEachEntity(Fn&& fn) {
for (auto [handle] : registry.storage<entt::entity>().each()) {
fn(Entity{handle, this});
}
}
} // namespace sophia
+11 -8
View File
@@ -2,12 +2,11 @@
#include <any> #include <any>
#include <functional> #include <functional>
#include <string>
#include <unordered_map>
#include <sophia/types.hpp> #include <sophia/types.hpp>
#include <sophia/util/delegate.hpp> #include <sophia/util/delegate.hpp>
#include <sophia/util/logger.hpp> #include <sophia/util/logger.hpp>
#include <string>
#include <unordered_map>
#define BIT(x) (1 << (x)) #define BIT(x) (1 << (x))
@@ -84,7 +83,7 @@ class EventSystem {
this->eventDelegates[eventType] = this->eventDelegates[eventType] =
std::make_unique<std::any>(EventDelegate<E>()); std::make_unique<std::any>(EventDelegate<E>());
log::trace("EventSystem creating new event type: ", eventType); log::trace("EventSystem creating new event type: {}", eventType);
} }
EventDelegate<E>& delegate = EventDelegate<E>& delegate =
@@ -103,7 +102,7 @@ class EventSystem {
this->eventDelegates[eventType] = this->eventDelegates[eventType] =
std::make_unique<std::any>(EventDelegate<E>()); std::make_unique<std::any>(EventDelegate<E>());
log::trace("EventSystem creating new event type: ", eventType); log::trace("EventSystem creating new event type: {}", eventType);
} }
EventDelegate<E>& delegate = EventDelegate<E>& delegate =
@@ -116,19 +115,23 @@ class EventSystem {
static_assert(std::is_base_of<Event, E>::value, "E must derive from Event"); static_assert(std::is_base_of<Event, E>::value, "E must derive from Event");
EventType eventType = GET_EVENT_TYPE(E); EventType eventType = GET_EVENT_TYPE(E);
if (!this->eventDelegates.count(eventType)) { return; } if (!this->eventDelegates.count(eventType)) {
return;
}
try { try {
EventDelegate<E>& delegate = EventDelegate<E>& delegate =
std::any_cast<EventDelegate<E>&>(*this->eventDelegates.at(eventType)); std::any_cast<EventDelegate<E>&>(*this->eventDelegates.at(eventType));
delegate.invoke(event); delegate.invoke(event);
} catch (const std::out_of_range& error) { } catch (const std::out_of_range& error) {
log::error("event dispatch failed: ", event.toString()); log::error("event dispatch failed: {}", event.toString());
} }
} }
private: private:
EventSystem() { log::info("EventSystem Initialized: ", this); } EventSystem() {
log::info("EventSystem Initialized: {}", static_cast<const void*>(this));
}
~EventSystem() { log::info("EventSystem Destroyed"); } ~EventSystem() { log::info("EventSystem Destroyed"); }
std::unordered_map<EventType, std::unique_ptr<std::any>> eventDelegates; std::unordered_map<EventType, std::unique_ptr<std::any>> eventDelegates;
+1 -6
View File
@@ -1,12 +1,8 @@
#pragma once #pragma once
#include <sophia/camera_2d.hpp> #include <sophia/math.hpp>
#include <sophia/types.hpp> #include <sophia/types.hpp>
#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/glm.hpp>
namespace sophia { namespace sophia {
struct FrameInfo { struct FrameInfo {
@@ -14,7 +10,6 @@ struct FrameInfo {
double elapsedTime; double elapsedTime;
double deltaTime; double deltaTime;
glm::vec2 currentFramebufferExtent; glm::vec2 currentFramebufferExtent;
const Camera2D& camera;
}; };
} // namespace sophia } // namespace sophia
+22 -5
View File
@@ -6,7 +6,7 @@
namespace sophia { namespace sophia {
template <typename Tag> template <typename T>
struct Handle { struct Handle {
static constexpr u32 INVALID_INDEX = (1u << 20) - 1; static constexpr u32 INVALID_INDEX = (1u << 20) - 1;
@@ -24,10 +24,27 @@ struct Handle {
[[nodiscard]] auto operator<=>(const Handle&) const = default; [[nodiscard]] auto operator<=>(const Handle&) const = default;
}; };
struct MeshTag {}; struct InvalidHandle {
struct PanelTag {}; template <typename T>
[[nodiscard]] constexpr operator Handle<T>() const {
return Handle<T>::invalid();
}
};
using MeshHandle = Handle<MeshTag>; inline constexpr InvalidHandle INVALID_HANDLE{};
using PanelHandle = Handle<PanelTag>;
class Mesh;
class Panel;
class RenderTarget;
class Scene;
struct SceneView;
class Texture;
using MeshHandle = Handle<Mesh>;
using PanelHandle = Handle<Panel>;
using RenderTargetHandle = Handle<RenderTarget>;
using SceneHandle = Handle<Scene>;
using SceneViewHandle = Handle<SceneView>;
using TextureHandle = Handle<Texture>;
} // namespace sophia } // namespace sophia
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <glm/glm.hpp>
#include <glm/gtc/quaternion.hpp>
namespace sophia {
using vec2 = glm::vec2;
using vec3 = glm::vec3;
using vec4 = glm::vec4;
using ivec2 = glm::ivec2;
using uvec2 = glm::uvec2;
using mat3 = glm::mat3;
using mat4 = glm::mat4;
using quat = glm::quat;
} // namespace sophia
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <sophia/math.hpp>
#include <sophia/types.hpp>
#include <vector>
namespace sophia {
// Layout must match the per-vertex inputs in mesh.vert.
struct MeshVertex {
vec3 position{0.f, 0.f, 0.f};
vec3 normal{0.f, 0.f, 1.f};
// Texture coordinates, with (0, 0) at the texture's top-left.
vec2 uv{0.f, 0.f};
vec4 color{1.f, 1.f, 1.f, 1.f};
};
/**
* Geometry on the CPU, uploaded with `resources().create<Mesh>(data)`.
* Triangles wind counter-clockwise when seen from the front; back faces are
* culled. See primitives.hpp for ready-made shapes.
*/
struct MeshData {
std::vector<MeshVertex> vertices;
// Empty draws the vertices in order, three per triangle.
std::vector<u32> indices;
};
} // namespace sophia
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <sophia/math.hpp>
#include <sophia/mesh_data.hpp>
/**
* Ready-made shapes, centered on the origin. They return MeshData rather than
* a mesh so they can be adjusted before uploading:
*
* MeshHandle cube = resources().create<Mesh>(primitives::cube());
*/
namespace sophia::primitives {
// An axis-aligned cube `size` across. Each face has its own four vertices so
// it gets its own normal and UVs.
MeshData cube(float size = 1.f, vec4 color = vec4{1.f});
} // namespace sophia::primitives
@@ -0,0 +1,74 @@
#pragma once
#include <sophia/aabb.hpp>
#include <sophia/handles.hpp>
#include <sophia/math.hpp>
#include <sophia/mesh_data.hpp>
#include <sophia/types.hpp>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
namespace sophia {
class ResourceRegistry;
class ResourceRegistryRef {
public:
template <typename T>
Handle<T> load(const std::string& path);
template <typename T, typename... Args>
Handle<T> create(Args&&... args) {
return createResource(std::type_identity<T>{}, std::forward<Args>(args)...);
}
void requestResize(RenderTargetHandle handle, u32 width, u32 height);
glm::vec2 getExtent(RenderTargetHandle handle) const;
glm::vec2 getExtent(TextureHandle handle) const;
// In the mesh's own space.
AABB getBounds(MeshHandle handle) const;
bool isAlive(MeshHandle handle) const;
bool isAlive(RenderTargetHandle handle) const;
bool isAlive(TextureHandle handle) const;
template <typename T>
void release(Handle<T> handle);
private:
friend class Application;
explicit ResourceRegistryRef(ResourceRegistry& registry)
: registry{&registry} {}
MeshHandle createResource(std::type_identity<Mesh>, const MeshData& data);
RenderTargetHandle createResource(std::type_identity<RenderTarget>,
u32 width = 1, u32 height = 1);
TextureHandle createResource(std::type_identity<Texture>, u32 width,
u32 height, std::vector<u8> pixels);
ResourceRegistry* registry;
};
template <>
MeshHandle ResourceRegistryRef::load<Mesh>(const std::string& path);
template <>
TextureHandle ResourceRegistryRef::load<Texture>(const std::string& path);
template <>
void ResourceRegistryRef::release<Mesh>(MeshHandle handle);
template <>
void ResourceRegistryRef::release<RenderTarget>(RenderTargetHandle handle);
template <>
void ResourceRegistryRef::release<Texture>(TextureHandle handle);
} // namespace sophia
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <entt/entt.hpp>
#include <string>
namespace sophia {
class Entity;
class SceneRenderer; // !TEMP remove this
class Scene {
public:
Scene();
~Scene();
Entity createEntity(const std::string& name = {});
void destroyEntity(Entity entity);
Entity createCamera(const std::string& name = "Camera");
/** Calls `fn(Entity)` for every live entity. Defined in entity.hpp. */
template <typename Fn>
void forEachEntity(Fn&& fn);
Entity getActiveCamera();
void setActiveCamera(Entity camera);
bool isVisible() const { return this->visible; }
void setVisible(bool visible) { this->visible = visible; }
bool isActive() const { return this->active; }
void setActive(bool active) { this->active = active; }
private:
friend class Entity;
friend class SceneRenderer; // !TEMP remove this
entt::registry registry;
entt::entity activeCamera = entt::null;
bool visible = true;
bool active = true;
};
} // namespace sophia
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <sophia/handles.hpp>
#include <sophia/scene.hpp>
#include <sophia/util/pool.hpp>
namespace sophia {
class SceneRegistry {
public:
SceneRegistry(const SceneRegistry&) = delete;
SceneRegistry& operator=(const SceneRegistry&) = delete;
SceneRegistry() = default;
~SceneRegistry() = default;
SceneHandle create() { return pool.create(); }
const Scene& get(SceneHandle handle) const { return pool.get(handle); }
Scene& get(SceneHandle handle) { return pool.get(handle); }
const Scene& peek(SceneHandle handle) const { return pool.get(handle); }
void release(SceneHandle handle) { pool.release(handle); }
bool isAlive(SceneHandle handle) const { return pool.isAlive(handle); }
u32 size() const { return pool.size(); }
private:
Pool<Scene> pool{"Scene"};
};
} // namespace sophia
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <sophia/entity.hpp>
#include <sophia/handles.hpp>
#include <sophia/math.hpp>
#include <sophia/scene.hpp>
namespace sophia {
enum class ClearFlags : u8 {
None = 0,
Color = 1 << 0,
Depth = 1 << 1,
Stencil = 1 << 2,
All = Color | Depth | Stencil
};
struct SceneView {
SceneHandle scene = INVALID_HANDLE;
RenderTargetHandle target =
INVALID_HANDLE; // null implies render to swapchain
// Null implies the scene's active camera. May belong to another scene,
// e.g. an editor camera that isn't part of the scene being shown.
Entity camera;
// TODO(#76): not used yet; every view renders to its whole target.
vec4 viewport{0.0f, 0.0f, 1.0f, 1.0f};
vec4 clearColor{0.f, 0.f, 0.f, 1.f};
ClearFlags clearFlags = ClearFlags::All;
u32 priority = 0; // Lower priority renders first
u32 layerMask = 0xFFFFFFFF;
bool enabled = true; // Disabled views are kept but not rendered
};
} // namespace sophia
@@ -0,0 +1,90 @@
#pragma once
#include <algorithm>
#include <sophia/handles.hpp>
#include <sophia/scene_view.hpp>
#include <sophia/util/pool.hpp>
#include <utility>
#include <vector>
namespace sophia {
/**
* Every live SceneView. The application renders all enabled views each frame,
* in ascending priority order, before the UI.
*/
class SceneViewRegistry {
public:
SceneViewRegistry(const SceneViewRegistry&) = delete;
SceneViewRegistry& operator=(const SceneViewRegistry&) = delete;
SceneViewRegistry() = default;
~SceneViewRegistry() = default;
SceneViewHandle create(const SceneView& view = {}) {
isRenderQueueDirty = true;
return pool.create(view);
}
const SceneView& get(SceneViewHandle handle) const {
return pool.get(handle);
}
SceneView& get(SceneViewHandle handle) {
isRenderQueueDirty = true;
return pool.get(handle);
}
const SceneView& peek(SceneViewHandle handle) const {
return pool.get(handle);
}
void release(SceneViewHandle handle) {
isRenderQueueDirty = true;
pool.release(handle);
}
bool isAlive(SceneViewHandle handle) const { return pool.isAlive(handle); }
u32 size() const { return pool.size(); }
template <typename Fn>
void forEach(Fn&& fn) {
isRenderQueueDirty = true;
pool.forEach(std::forward<Fn>(fn));
}
template <typename Fn>
void forEach(Fn&& fn) const {
pool.forEach(std::forward<Fn>(fn));
}
const std::vector<SceneViewHandle>& GetRenderQueue() const {
if (isRenderQueueDirty) {
rebuildRenderQueue();
}
return renderQueue;
}
private:
void rebuildRenderQueue() const {
renderQueue.clear();
pool.forEachWithHandle([&](SceneViewHandle handle, const SceneView& view) {
if (view.enabled) {
renderQueue.push_back(handle);
}
});
std::ranges::stable_sort(renderQueue, {}, [this](SceneViewHandle handle) {
return pool.get(handle).priority;
});
isRenderQueueDirty = false;
}
Pool<SceneView> pool{"SceneView"};
mutable std::vector<SceneViewHandle> renderQueue;
mutable bool isRenderQueueDirty = true;
};
} // namespace sophia
+5 -4
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <sophia/console/console_command_registry.hpp>
#include <sophia/handles.hpp> #include <sophia/handles.hpp>
#include <sophia/ui/panel_registry.hpp> #include <sophia/ui/panel_registry.hpp>
@@ -10,15 +11,15 @@ class DebugUI {
DebugUI(const DebugUI&) = delete; DebugUI(const DebugUI&) = delete;
DebugUI& operator=(const DebugUI&) = delete; DebugUI& operator=(const DebugUI&) = delete;
explicit DebugUI(PanelRegistry& registry); DebugUI(PanelRegistry& registry, ConsoleCommandRegistry& commands);
~DebugUI() = default; ~DebugUI() = default;
void show(); void show();
void hide(); void hide();
void showPanelRegistry(bool show = true); void showPanelRegistry(bool show = false);
void showFrameStats(bool show = true); void showFrameStats(bool show = false);
void showConsole(bool show = true); void showConsole(bool show = false);
private: private:
PanelRegistry& registry; PanelRegistry& registry;
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <string>
#include <vector>
namespace sophia {
struct DockLayout {
float leftWidth = 0.2f;
float rightWidth = 0.25f;
float bottomHeight = 0.25f;
std::vector<std::string> left;
std::vector<std::string> right;
std::vector<std::string> bottom;
std::vector<std::string> center;
bool empty() const {
return left.empty() && right.empty() && bottom.empty() && center.empty();
}
};
} // namespace sophia
+35 -5
View File
@@ -1,5 +1,9 @@
#pragma once #pragma once
#include <imgui.h>
#include <glm/glm.hpp>
#include <optional>
#include <sophia/frame_info.hpp> #include <sophia/frame_info.hpp>
#include <string> #include <string>
#include <utility> #include <utility>
@@ -8,28 +12,54 @@ namespace sophia {
class Panel { class Panel {
public: public:
struct Config {
const std::string name;
ImGuiWindowFlags windowFlags = ImGuiWindowFlags_None;
ImGuiDockNodeFlags dockNodeFlags = ImGuiDockNodeFlags_None;
bool locked = false;
bool closable = true;
glm::vec2 size{0.0f, 0.0f};
std::optional<glm::vec2> position;
ImGuiWindowFlags getWindowFlags() const;
ImGuiDockNodeFlags getDockNodeFlags() const;
bool hasCloseButton() const;
ImGuiCond getPlacementCond() const;
};
Panel(const Panel&) = delete; Panel(const Panel&) = delete;
Panel& operator=(const Panel&) = delete; Panel& operator=(const Panel&) = delete;
Panel(Panel&&) noexcept = default; Panel(Panel&&) noexcept = default;
Panel& operator=(Panel&&) noexcept = default; Panel& operator=(Panel&&) = delete;
explicit Panel(std::string name = {}) : name{std::move(name)} {} explicit Panel(Config config) : config{std::move(config)} {}
virtual ~Panel() = default; virtual ~Panel() = default;
virtual void onUpdate(const FrameInfo& frameInfo) = 0; void draw(const FrameInfo& frameInfo);
const std::string& getName() const { return name; } Config& getConfig() { return config; }
const Config& getConfig() const { return config; }
const std::string& getName() const { return config.name; }
bool isLocked() const { return config.locked; }
bool isVisible() const { return this->visible; } bool isVisible() const { return this->visible; }
bool isShown() const { return this->visible && this->shown; }
void setVisible(bool visible) { this->visible = visible; } void setVisible(bool visible) { this->visible = visible; }
void toggleVisible() { this->visible = !this->visible; } void toggleVisible() { this->visible = !this->visible; }
protected: protected:
virtual void onUpdate(const FrameInfo& frameInfo) = 0;
bool visible = true; bool visible = true;
bool shown = false;
private: private:
std::string name; Config config;
}; };
} // namespace sophia } // namespace sophia
+29 -36
View File
@@ -3,8 +3,10 @@
#include <memory> #include <memory>
#include <sophia/frame_info.hpp> #include <sophia/frame_info.hpp>
#include <sophia/handles.hpp> #include <sophia/handles.hpp>
#include <sophia/ui/dock_layout.hpp>
#include <sophia/ui/panel.hpp> #include <sophia/ui/panel.hpp>
#include <sophia/util/assert.hpp> #include <sophia/util/assert.hpp>
#include <sophia/util/pool.hpp>
#include <string_view> #include <string_view>
#include <typeinfo> #include <typeinfo>
#include <utility> #include <utility>
@@ -20,23 +22,23 @@ class PanelRegistry {
PanelRegistry() = default; PanelRegistry() = default;
~PanelRegistry() = default; ~PanelRegistry() = default;
PanelHandle add(std::unique_ptr<Panel> panel); PanelHandle add(std::unique_ptr<Panel> panel) {
return pool.add(std::move(panel));
}
template <typename T, typename... Args> template <typename T, typename... Args>
PanelHandle emplace(Args&&... args) { PanelHandle emplace(Args&&... args) {
return add(std::make_unique<T>(std::forward<Args>(args)...)); return add(std::make_unique<T>(std::forward<Args>(args)...));
} }
const Panel& get(PanelHandle handle) const; const Panel& get(PanelHandle handle) const { return pool.get(handle); }
Panel& get(PanelHandle handle) { Panel& get(PanelHandle handle) { return pool.get(handle); }
return const_cast<Panel&>(std::as_const(*this).get(handle));
}
template <typename T> template <typename T>
const T& get(PanelHandle handle) const { const T& get(PanelHandle handle) const {
const Panel& panel = get(handle); const Panel& panel = get(handle);
SOPHIA_ASSERT(dynamic_cast<const T*>(&panel) != nullptr, SOPHIA_ASSERT(dynamic_cast<const T*>(&panel) != nullptr,
"PanelHandle does not refer to a", typeid(T).name()); "PanelHandle does not refer to a {}", typeid(T).name());
return static_cast<const T&>(panel); return static_cast<const T&>(panel);
} }
@@ -45,19 +47,17 @@ class PanelRegistry {
return const_cast<T&>(std::as_const(*this).template get<T>(handle)); return const_cast<T&>(std::as_const(*this).template get<T>(handle));
} }
void remove(PanelHandle handle); void remove(PanelHandle handle) { pool.release(handle); }
bool isValid(PanelHandle handle) const; bool isValid(PanelHandle handle) const { return pool.isAlive(handle); }
template <typename T> template <typename T>
const T* find() const { const T* find() const {
for (const Slot& slot : slots) { const T* result = nullptr;
if (slot.panel) { pool.findIf([&result](const Panel& panel) {
if (const T* panel = dynamic_cast<const T*>(slot.panel.get())) { result = dynamic_cast<const T*>(&panel);
return panel; return result != nullptr;
} });
} return result;
}
return nullptr;
} }
template <typename T> template <typename T>
@@ -66,42 +66,35 @@ class PanelRegistry {
} }
const Panel* find(std::string_view name) const { const Panel* find(std::string_view name) const {
for (const Slot& slot : slots) { return pool.findIf(
if (slot.panel && slot.panel->getName() == name) { [name](const Panel& panel) { return panel.getName() == name; });
return slot.panel.get();
}
}
return nullptr;
} }
Panel* find(std::string_view name) { Panel* find(std::string_view name) {
return const_cast<Panel*>(std::as_const(*this).find(name)); return const_cast<Panel*>(std::as_const(*this).find(name));
} }
u32 size() const; u32 size() const { return pool.size(); }
u32 visibleCount() const; u32 visibleCount() const;
std::vector<std::string_view> names() const { std::vector<std::string_view> names() const {
std::vector<std::string_view> result; std::vector<std::string_view> result;
result.reserve(slots.size()); result.reserve(pool.size());
for (const Slot& slot : slots) { pool.forEach(
if (slot.panel) { [&result](const Panel& panel) { result.push_back(panel.getName()); });
result.push_back(slot.panel->getName());
}
}
return result; return result;
} }
void update(const FrameInfo& frameInfo); void update(const FrameInfo& frameInfo);
private: void setDefaultLayout(DockLayout layout) {
struct Slot { defaultLayout = std::move(layout);
std::unique_ptr<Panel> panel; }
u32 generation = 0; const DockLayout& getDefaultLayout() const { return defaultLayout; }
};
std::vector<Slot> slots; private:
std::vector<u32> freeList; Pool<Panel> pool{"Panel"};
DockLayout defaultLayout;
}; };
} // namespace sophia } // namespace sophia
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <imgui.h>
#include <sophia/handles.hpp>
#include <sophia/resource_registry_ref.hpp>
namespace sophia {
class ImGuiRenderer;
class UI {
public:
UI(const UI&) = delete;
UI& operator=(const UI&) = delete;
/**
* Draws the target filling the space left in the current window, and keeps
* the target sized to match. The target catches up on the next frame, so the
* image may be a frame behind while the window is being resized.
*/
void image(RenderTargetHandle handle);
/**
* Texture to hand to ImGui::Image() that shows the target's contents.
* Changes when the target is resized, so fetch it each frame rather than
* caching it.
*/
ImTextureID getTextureId(RenderTargetHandle handle);
private:
friend class Application;
explicit UI(ResourceRegistryRef resources) : resources{resources} {}
ResourceRegistryRef resources;
ImGuiRenderer* renderer = nullptr;
};
} // namespace sophia
+12 -8
View File
@@ -1,7 +1,9 @@
/** /**
* Usage: * Usage:
* SOPHIA_ASSERT(pipelineLayout != nullptr); * SOPHIA_ASSERT(pipelineLayout != nullptr);
* SOPHIA_ASSERT(index < count, "index", index, "out of bounds:", count); * SOPHIA_ASSERT(index < count, "index {} out of bounds: {}", index, count);
*
* The optional message is a std::format string, checked at compile time.
* *
* Compiled out entirely in release (NDEBUG) builds, matching <cassert>'s * Compiled out entirely in release (NDEBUG) builds, matching <cassert>'s
* assert() semantics: don't rely on side effects inside the condition. * assert() semantics: don't rely on side effects inside the condition.
@@ -10,16 +12,18 @@
#pragma once #pragma once
#include <cstdlib> #include <cstdlib>
#include <format>
#include <sophia/util/logger.hpp> #include <sophia/util/logger.hpp>
#ifndef NDEBUG #ifndef NDEBUG
#define SOPHIA_ASSERT(condition, ...) \ #define SOPHIA_ASSERT(condition, ...) \
do { \ do { \
if (!(condition)) { \ if (!(condition)) { \
::sophia::log::fatal("Assertion failed:", #condition, "at", __FILE__, \ ::sophia::log::fatal( \
":", __LINE__ __VA_OPT__(, ) __VA_ARGS__); \ "Assertion failed: {} at {}:{}" __VA_OPT__(" - {}"), #condition, \
std::abort(); \ __FILE__, __LINE__ __VA_OPT__(, ::std::format(__VA_ARGS__))); \
} \ std::abort(); \
} \
} while (false) } while (false)
#else #else
#define SOPHIA_ASSERT(condition, ...) ((void)0) #define SOPHIA_ASSERT(condition, ...) ((void)0)
+8 -5
View File
@@ -2,13 +2,12 @@
#include <functional> #include <functional>
#include <memory> #include <memory>
#include <sophia/util/logger.hpp>
#include <stdexcept> #include <stdexcept>
#include <string> #include <string>
#include <typeinfo> #include <typeinfo>
#include <vector> #include <vector>
#include <sophia/util/logger.hpp>
#define DEFAULT_DELEGATE_FUNCTOR_ALLOC 5 #define DEFAULT_DELEGATE_FUNCTOR_ALLOC 5
namespace sophia { namespace sophia {
@@ -39,7 +38,9 @@ class expired_weak_object : public std::runtime_error {
template <class O, typename... A> template <class O, typename... A>
std::function<void(A...)> createWeakFunction(void (O::*method)(A...), std::function<void(A...)> createWeakFunction(void (O::*method)(A...),
std::shared_ptr<O> obj) { std::shared_ptr<O> obj) {
if (!obj) { throw std::invalid_argument("method and obj must not be null"); } if (!obj) {
throw std::invalid_argument("method and obj must not be null");
}
std::weak_ptr<O> weakObj = obj; std::weak_ptr<O> weakObj = obj;
@@ -82,7 +83,7 @@ class Delegate {
try { try {
this->functors[i](std::forward<A>(args)...); this->functors[i](std::forward<A>(args)...);
} catch (const expired_weak_object& e) { } catch (const expired_weak_object& e) {
log::error("delegate invoke error: ", e.what()); log::error("delegate invoke error: {}", e.what());
deadFunctorIndexs.push_back(i); deadFunctorIndexs.push_back(i);
} }
} }
@@ -97,7 +98,9 @@ class Delegate {
void operator=(Functor c) { void operator=(Functor c) {
clear(); clear();
if (c != nullptr) { add(c); } if (c != nullptr) {
add(c);
}
} }
void operator+=(Functor c) { add(c); } void operator+=(Functor c) { add(c); }
+94 -66
View File
@@ -1,23 +1,36 @@
/** /**
* Lots of work still needs to be done in here. * std::format-style logging. Format strings are checked at compile time.
* *
* Usage: * Usage:
* using namespace sophia; * using namespace sophia;
* log::fatal("fatal message") * log::fatal("failed to load {}", path)
* log::info("Infomation message") * log::info("loaded {} meshes in {:.2f}ms", count, ms)
* log::debug("value: {}", x) // prefixed with file:line
* log::print("no prefix, just the message")
*
* Everything is compiled out in release (NDEBUG) builds.
* *
* TODO Impl some form of log error handling * TODO Impl some form of log error handling
*/ */
#pragma once #pragma once
#include <iostream> #include <cstdio>
#include <format>
#include <print>
#include <source_location>
#include <string_view>
#include <type_traits>
#include <utility> #include <utility>
#include <sophia/types.hpp>
namespace sophia::log { namespace sophia::log {
#ifndef NDEBUG
inline constexpr bool enabled = true;
#else
inline constexpr bool enabled = false;
#endif // !NDEBUG
enum class LEVEL { enum class LEVEL {
NONE, NONE,
FATAL, FATAL,
@@ -29,98 +42,113 @@ enum class LEVEL {
TRACE, TRACE,
}; };
// Helper print functions namespace detail {
#define LOG_PRINT_HELPER(MSG) std::cout << MSG
#define LOG_PREFIX_FATAL LOG_PRINT_HELPER("\033[1;91m[FATAL]\033[0m ")
#define LOG_PREFIX_ERROR LOG_PRINT_HELPER("\033[1;31m[ERROR]\033[0m ")
#define LOG_PREFIX_WARNING LOG_PRINT_HELPER("\033[1;33m[WARNING]\033[0m ")
#define LOG_PREFIX_INFO LOG_PRINT_HELPER("\033[32m[INFO]\033[0m ")
#define LOG_PREFIX_VERBOSE LOG_PRINT_HELPER("\033[36m[VERBOSE]\033[0m ")
#define LOG_PREFIX_DEBUG LOG_PRINT_HELPER("\033[1;34m[DEBUG]\033[0m ")
#define LOG_PREFIX_TRACE LOG_PRINT_HELPER("\033[1;90m[TRACE]\033[0m ")
template <typename... Args> constexpr std::string_view prefix(LEVEL level) {
void log(LEVEL level = LEVEL::NONE, Args... args) {
#ifndef NDEBUG
switch (level) { switch (level) {
case LEVEL::FATAL: case LEVEL::FATAL:
LOG_PREFIX_FATAL; return "\033[1;91m[FATAL]\033[0m ";
break;
case LEVEL::ERROR: case LEVEL::ERROR:
LOG_PREFIX_ERROR; return "\033[1;31m[ERROR]\033[0m ";
break;
case LEVEL::WARNING: case LEVEL::WARNING:
LOG_PREFIX_WARNING; return "\033[1;33m[WARNING]\033[0m ";
break;
case LEVEL::INFO: case LEVEL::INFO:
LOG_PREFIX_INFO; return "\033[32m[INFO]\033[0m ";
break;
case LEVEL::VERBOSE: case LEVEL::VERBOSE:
LOG_PREFIX_VERBOSE; return "\033[36m[VERBOSE]\033[0m ";
break; case LEVEL::DEBUGGING:
// case LEVEL::DEBUGGING: return "\033[1;34m[DEBUG]\033[0m ";
// LOG_PREFIX_DEBUG;
// break;
case LEVEL::TRACE: case LEVEL::TRACE:
LOG_PREFIX_TRACE; return "\033[1;90m[TRACE]\033[0m ";
break;
default: default:
std::cout << "\033[1;90m[LOG]\033[0m "; return "\033[1;90m[LOG]\033[0m ";
break;
} }
}
((std::cout << ' ' << std::forward<Args>(args)), ...); inline void write(std::string_view prefix, std::string_view message) {
std::cout << std::endl; std::println(stdout, "{}{}", prefix, message);
#else std::fflush(stdout);
(void)level; }
(void)std::initializer_list<int>{((void)args, 0)...};
#endif // !NDEBUG // Captures the caller's location alongside the format string, since a
// defaulted std::source_location parameter can't follow a parameter pack.
template <typename... Args>
struct FormatWithLocation {
std::format_string<Args...> fmt;
std::source_location location;
template <typename S>
requires std::is_convertible_v<const S&, std::string_view>
consteval FormatWithLocation(
const S& fmt,
std::source_location location = std::source_location::current())
: fmt(fmt), location(location) {}
};
} // namespace detail
template <typename... Args>
void log(LEVEL level, std::format_string<Args...> fmt, Args&&... args) {
if constexpr (enabled) {
detail::write(detail::prefix(level),
std::format(fmt, std::forward<Args>(args)...));
}
} }
template <typename... Args> template <typename... Args>
void fatal(Args... args) { void fatal(std::format_string<Args...> fmt, Args&&... args) {
log(LEVEL::FATAL, args...); log(LEVEL::FATAL, fmt, std::forward<Args>(args)...);
} }
template <typename... Args> template <typename... Args>
void error(Args... args) { void error(std::format_string<Args...> fmt, Args&&... args) {
log(LEVEL::ERROR, args...); log(LEVEL::ERROR, fmt, std::forward<Args>(args)...);
} }
template <typename... Args> template <typename... Args>
void warning(Args... args) { void warning(std::format_string<Args...> fmt, Args&&... args) {
log(LEVEL::WARNING, args...); log(LEVEL::WARNING, fmt, std::forward<Args>(args)...);
} }
template <typename... Args> template <typename... Args>
void info(Args... args) { void info(std::format_string<Args...> fmt, Args&&... args) {
log(LEVEL::INFO, args...); log(LEVEL::INFO, fmt, std::forward<Args>(args)...);
} }
template <typename... Args> template <typename... Args>
void verbose(Args... args) { void verbose(std::format_string<Args...> fmt, Args&&... args) {
log(LEVEL::VERBOSE, args...); log(LEVEL::VERBOSE, fmt, std::forward<Args>(args)...);
} }
template <typename... Args> template <typename... Args>
void debug(const char* file, u16 line, Args... args) { void debug(detail::FormatWithLocation<std::type_identity_t<Args>...> fmt,
#ifndef NDEBUG Args&&... args) {
LOG_PREFIX_DEBUG; if constexpr (enabled) {
std::cout << file << "::" << line; detail::write(
((std::cout << ' ' << std::forward<Args>(args)), ...); detail::prefix(LEVEL::DEBUGGING),
std::cout << std::endl; std::format("{}:{} {}", fmt.location.file_name(), fmt.location.line(),
#else std::format(fmt.fmt, std::forward<Args>(args)...)));
(void)file; }
(void)line;
(void)std::initializer_list<int>{((void)args, 0)...};
#endif // !NDEBUG
} }
// trick
#define debug(...) debug(__FILE__, __LINE__, __VA_ARGS__)
template <typename... Args> template <typename... Args>
void trace(Args... args) { void trace(std::format_string<Args...> fmt, Args&&... args) {
log(LEVEL::TRACE, args...); log(LEVEL::TRACE, fmt, std::forward<Args>(args)...);
}
// Like the functions above, but with no "[LEVEL]" prefix. With no arguments,
// an empty line.
inline void print() {
if constexpr (enabled) {
detail::write("", "");
}
}
template <typename... Args>
void print(std::format_string<Args...> fmt, Args&&... args) {
if constexpr (enabled) {
detail::write("", std::format(fmt, std::forward<Args>(args)...));
}
} }
} // namespace sophia::log } // namespace sophia::log
+128
View File
@@ -0,0 +1,128 @@
#pragma once
#include <memory>
#include <sophia/handles.hpp>
#include <sophia/types.hpp>
#include <sophia/util/assert.hpp>
#include <utility>
#include <vector>
namespace sophia {
template <typename T>
class Pool {
public:
explicit Pool(const char* name) : name{name} {}
Pool(const Pool&) = delete;
Pool& operator=(const Pool&) = delete;
template <typename... Args>
Handle<T> create(Args&&... args) {
return add(std::make_unique<T>(std::forward<Args>(args)...));
}
Handle<T> add(std::unique_ptr<T> item) {
if (!freeList.empty()) {
u32 index = freeList.back();
freeList.pop_back();
slots[index].item = std::move(item);
return Handle<T>{index, slots[index].generation};
}
slots.push_back(Slot{std::move(item), 0});
return Handle<T>{static_cast<u32>(slots.size() - 1), 0};
}
T& get(Handle<T> handle) {
return const_cast<T&>(std::as_const(*this).get(handle));
}
const T& get(Handle<T> handle) const { return *validate(handle).item; }
void release(Handle<T> handle) {
validate(handle);
Slot& slot = slots[handle.index];
slot.item.reset();
slot.generation = (slot.generation + 1) & GENERATION_MASK;
freeList.push_back(handle.index);
}
bool isAlive(Handle<T> handle) const {
return handle.isValid() && handle.index < slots.size() &&
slots[handle.index].generation == handle.generation &&
slots[handle.index].item != nullptr;
}
u32 size() const { return static_cast<u32>(slots.size() - freeList.size()); }
template <typename Fn>
void forEach(Fn&& fn) {
for (Slot& slot : slots) {
if (slot.item) {
fn(*slot.item);
}
}
}
template <typename Fn>
void forEach(Fn&& fn) const {
for (const Slot& slot : slots) {
if (slot.item) {
fn(*slot.item);
}
}
}
/** Calls `fn(handle, item)` for every live item. */
template <typename Fn>
void forEachWithHandle(Fn&& fn) const {
for (u32 index = 0; index < slots.size(); index++) {
const Slot& slot = slots[index];
if (slot.item) {
fn(Handle<T>{index, slot.generation}, *slot.item);
}
}
}
template <typename Predicate>
T* findIf(Predicate&& predicate) {
return const_cast<T*>(
std::as_const(*this).findIf(std::forward<Predicate>(predicate)));
}
template <typename Predicate>
const T* findIf(Predicate&& predicate) const {
for (const Slot& slot : slots) {
if (slot.item && predicate(*slot.item)) {
return slot.item.get();
}
}
return nullptr;
}
private:
static constexpr u32 GENERATION_MASK = (1u << 12) - 1;
struct Slot {
std::unique_ptr<T> item;
u32 generation = 0;
};
const Slot& validate(Handle<T> handle) const {
SOPHIA_ASSERT(handle.isValid(), "invalid handle: {}", name);
SOPHIA_ASSERT(handle.index < slots.size(), "{} handle index out of range: {}", name, static_cast<u32>(handle.index));
const Slot& slot = slots[handle.index];
SOPHIA_ASSERT(slot.generation == handle.generation, "stale {} handle (generation mismatch): {}", name, static_cast<u32>(handle.index));
SOPHIA_ASSERT(slot.item != nullptr, "{} handle refers to a released item: {}", name, static_cast<u32>(handle.index));
return slot;
}
const char* name;
std::vector<Slot> slots;
std::vector<u32> freeList;
};
} // namespace sophia
+209 -85
View File
@@ -1,46 +1,48 @@
#include <imgui.h>
#include <algorithm>
#include <array> #include <array>
#include <chrono> #include <chrono>
#include <cstddef> #include <cstddef>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <map>
#include <optional>
#include <set>
#include <sophia/application.hpp> #include <sophia/application.hpp>
#include <sophia/util/logger.hpp> #include <sophia/util/logger.hpp>
#include <string> #include <string>
#include <tracy/Tracy.hpp>
#include <utility> #include <utility>
#include <vector> #include <vector>
#include <vulkan/vulkan.hpp> #include <vulkan/vulkan.hpp>
#define GLM_FORCE_RADIANS #include "renderer/imgui_renderer.hpp"
#define GLM_FORCE_DEPTH_ZERO_TO_ONE #include "renderer/model_render_system.hpp"
#include <imgui.h> #include "renderer/render_context.hpp"
#include "renderer/render_graph.hpp"
#include <glm/glm.hpp> #include "renderer/scene_renderer.hpp"
#include "resources/mesh.hpp"
#include "buffer.hpp" #include "resources/render_target.hpp"
#include "device.hpp" #include "resources/resource_registry.hpp"
#include "imgui_renderer.hpp" #include "vulkan/buffer.hpp"
#include "mesh.hpp" #include "vulkan/device.hpp"
#include "pipelines/graphics_pipeline.hpp" #include "vulkan/pipelines/graphics_pipeline.hpp"
#include "render_context.hpp" #include "vulkan/shader.hpp"
#include "shader.hpp"
#include "systems/model_render_system.hpp"
#include "window.hpp" #include "window.hpp"
namespace sophia { namespace sophia {
namespace { namespace {
struct Vertex {
glm::vec2 position;
glm::vec3 color;
};
struct PushConstants {
glm::mat4 transform;
glm::vec4 color;
glm::vec4 data;
};
std::vector<std::string> commandLineArgs; std::vector<std::string> commandLineArgs;
constexpr ClearColor CLEAR_COLOR = {0.01f, 0.01f, 0.02f, 1.0f};
bool hasFlag(ClearFlags flags, ClearFlags flag) {
return (static_cast<u8>(flags) & static_cast<u8>(flag)) != 0;
}
} // namespace } // namespace
class Application::Impl { class Application::Impl {
@@ -50,15 +52,41 @@ class Application::Impl {
config{config}, config{config},
window{config.width, config.height, config.name}, window{config.width, config.height, config.name},
device{&window}, device{&window},
renderContext{window, device} {} renderContext{window, device},
resourceRegistry{device, renderContext.getImageFormat()} {}
void run() { void run() {
ModelRenderSystem modelRenderSystem{device, renderContext}; ModelRenderSystem modelRenderSystem{device, renderContext};
SceneRenderer sceneRenderer{this->application.sceneRegistry,
this->resourceRegistry, device,
renderContext.getImageFormat()};
ImGuiRenderer imguiRenderer{window, device, renderContext.getImageFormat(), ImGuiRenderer imguiRenderer{window, device, renderContext.getImageFormat(),
renderContext.getImageCount()}; renderContext.getImageCount(),
this->resourceRegistry};
this->application._ui.renderer = &imguiRenderer;
this->application.consoleCommandRegistry.registerCommand(
"reset_layout", "Restore the default panel layout",
[&imguiRenderer](const std::vector<std::string>&) {
imguiRenderer.resetLayout();
return std::string{"Layout reset"};
});
this->application.onCreate(); this->application.onCreate();
auto& meshPool = this->resourceRegistry.meshPool;
auto& renderTargetPool = this->resourceRegistry.renderTargetPool;
PanelRegistry& panelRegistry = this->application.panelRegistry;
SceneRegistry& sceneRegistry = this->application.sceneRegistry;
SceneViewRegistry& sceneViewRegistry = this->application.sceneViewRegistry;
imguiRenderer.setDefaultLayout(panelRegistry.getDefaultLayout());
// Views already warned about, so each warns once.
std::set<SceneViewHandle> viewsWithoutCamera;
std::set<SceneViewHandle> viewsOverwritingOthers;
auto startTime = std::chrono::high_resolution_clock::now(); auto startTime = std::chrono::high_resolution_clock::now();
auto currentTime = startTime; auto currentTime = startTime;
u32 frameIndex = 0; u32 frameIndex = 0;
@@ -74,17 +102,27 @@ class Application::Impl {
double elapsedTime = double elapsedTime =
std::chrono::duration<double>(currentTime - startTime).count(); std::chrono::duration<double>(currentTime - startTime).count();
// Resizing waits for the GPU, so it has to happen before any of this
// frame's work (or ImGui's references to the targets) is recorded.
renderTargetPool.applyPendingResizes();
vk::Extent2D extent = renderContext.getExtent(); vk::Extent2D extent = renderContext.getExtent();
FrameInfo frameInfo{frameIndex, elapsedTime, deltaTime, FrameInfo frameInfo{frameIndex, elapsedTime, deltaTime,
glm::vec2(static_cast<float>(extent.width), glm::vec2(static_cast<float>(extent.width),
static_cast<float>(extent.height)), static_cast<float>(extent.height))};
this->application.camera}; {
this->application.onUpdate(frameInfo); ZoneScopedN("Application::onUpdate");
this->application.camera.setProjection( this->application.onUpdate(frameInfo);
-frameInfo.currentFramebufferExtent.x / 2.f, }
frameInfo.currentFramebufferExtent.x / 2.f,
-frameInfo.currentFramebufferExtent.y / 2.f, // Canvas draws in window pixels, origin at the center.
frameInfo.currentFramebufferExtent.y / 2.f); // TODO: replace with Debug/Gizmos drawn into scene views through their
// cameras.
glm::mat4 canvasProjection =
glm::ortho(-frameInfo.currentFramebufferExtent.x / 2.f,
frameInfo.currentFramebufferExtent.x / 2.f,
-frameInfo.currentFramebufferExtent.y / 2.f,
frameInfo.currentFramebufferExtent.y / 2.f);
vk::CommandBuffer commandBuffer = renderContext.beginFrame(); vk::CommandBuffer commandBuffer = renderContext.beginFrame();
if (!commandBuffer) { if (!commandBuffer) {
@@ -92,41 +130,13 @@ class Application::Impl {
} }
imguiRenderer.beginFrame(); imguiRenderer.beginFrame();
sceneRenderer.beginFrame(renderContext.getFrameIndex());
this->application.panelRegistry.update(frameInfo); panelRegistry.update(frameInfo);
this->application.onRender(); {
ZoneScopedN("Application::onRender");
renderContext.transitionCurrentImage( this->application.onRender();
commandBuffer, vk::ImageLayout::eColorAttachmentOptimal); }
vk::ClearValue clearValue{};
clearValue.color =
vk::ClearColorValue{std::array<float, 4>{0.01f, 0.01f, 0.02f, 1.0f}};
vk::RenderingAttachmentInfo colorAttachment{};
colorAttachment.imageView = renderContext.getCurrentImageView();
colorAttachment.imageLayout = vk::ImageLayout::eColorAttachmentOptimal;
colorAttachment.loadOp = vk::AttachmentLoadOp::eClear;
colorAttachment.storeOp = vk::AttachmentStoreOp::eStore;
colorAttachment.clearValue = clearValue;
vk::RenderingInfo renderingInfo{};
renderingInfo.renderArea = vk::Rect2D{{0, 0}, extent};
renderingInfo.layerCount = 1;
renderingInfo.colorAttachmentCount = 1;
renderingInfo.pColorAttachments = &colorAttachment;
commandBuffer.beginRendering(renderingInfo);
vk::Viewport viewport{0.0f,
0.0f,
static_cast<float>(extent.width),
static_cast<float>(extent.height),
0.0f,
1.0f};
vk::Rect2D scissor{{0, 0}, extent};
commandBuffer.setViewport(0, viewport);
commandBuffer.setScissor(0, scissor);
std::vector<ModelRenderSystem::MeshInstance> instances; std::vector<ModelRenderSystem::MeshInstance> instances;
instances.reserve(this->meshes.size() + this->drawCalls.size()); instances.reserve(this->meshes.size() + this->drawCalls.size());
@@ -135,18 +145,124 @@ class Application::Impl {
} }
instances.insert(instances.end(), this->drawCalls.begin(), instances.insert(instances.end(), this->drawCalls.begin(),
this->drawCalls.end()); this->drawCalls.end());
modelRenderSystem.render(commandBuffer, instances, frameInfo);
this->drawCalls.clear(); this->drawCalls.clear();
imguiRenderer.endFrame(commandBuffer); RenderGraph graph{renderContext.getTracyContext()};
ImageRef backbuffer =
graph.importImage(renderContext.getCurrentImage(), Access::Present);
commandBuffer.endRendering(); struct ViewTarget {
ImageRef color;
ImageRef depth;
};
std::map<RenderTargetHandle, ViewTarget> viewTargets;
// Views without a target render straight into the backbuffer.
std::optional<ViewTarget> swapchainTarget;
for (SceneViewHandle handle : sceneViewRegistry.GetRenderQueue()) {
const SceneView& view = sceneViewRegistry.peek(handle);
bool toSwapchain = !view.target.isValid();
if (!sceneRegistry.isAlive(view.scene) ||
!sceneRegistry.get(view.scene).isVisible() ||
(!toSwapchain && !renderTargetPool.isAlive(view.target))) {
continue;
}
renderContext.transitionCurrentImage(commandBuffer, ViewTarget target;
vk::ImageLayout::ePresentSrcKHR); vk::Extent2D targetExtent;
// Whether this is the first view this frame to render to its target.
bool firstOnTarget = false;
if (toSwapchain) {
firstOnTarget = !swapchainTarget;
if (!swapchainTarget) {
swapchainTarget = {
backbuffer,
graph.importImage(renderContext.getCurrentDepthImage(),
Access::DepthWrite)};
}
target = *swapchainTarget;
targetExtent = renderContext.getExtent();
} else {
RenderTarget& renderTarget = renderTargetPool.get(view.target);
auto [it, inserted] = viewTargets.try_emplace(view.target);
firstOnTarget = inserted;
if (inserted) {
it->second = {
graph.importImage(renderTarget.getImage(), Access::SampledRead),
graph.importImage(renderTarget.getDepthImage(),
Access::DepthWrite)};
}
target = it->second;
targetExtent = renderTarget.getExtent();
}
if (!firstOnTarget && hasFlag(view.clearFlags, ClearFlags::Color) &&
viewsOverwritingOthers.insert(handle).second) {
log::warning(
"scene view clears its target after another view already "
"rendered to it this frame, hiding that view (give it clearFlags "
"without Color, or render it first with a lower priority)");
}
const vec4& color = view.clearColor;
graph.addPass(
"scene view " + std::to_string(handle.index),
[&](RenderGraph::PassBuilder& pass) {
pass.writeColor(target.color,
hasFlag(view.clearFlags, ClearFlags::Color)
? vk::AttachmentLoadOp::eClear
: vk::AttachmentLoadOp::eLoad,
{color.r, color.g, color.b, color.a});
pass.writeDepth(target.depth,
hasFlag(view.clearFlags, ClearFlags::Depth)
? vk::AttachmentLoadOp::eClear
: vk::AttachmentLoadOp::eLoad);
},
[&sceneRenderer, &sceneRegistry, &sceneViewRegistry,
&viewsWithoutCamera, handle,
targetExtent](vk::CommandBuffer commandBuffer) {
ZoneScopedN("scene view pass");
const SceneView& view = sceneViewRegistry.peek(handle);
Entity camera =
view.camera ? view.camera
: sceneRegistry.get(view.scene).getActiveCamera();
// No camera to look through: the target is just cleared.
if (!camera) {
if (viewsWithoutCamera.insert(handle).second) {
log::warning(
"scene view has no camera and its scene has no active "
"camera; it will only be cleared (see "
"Scene::createCamera)");
}
return;
}
sceneRenderer.render(commandBuffer, view, camera, targetExtent);
});
}
graph.addPass(
"main",
[&](RenderGraph::PassBuilder& pass) {
// Keep what scene views drew into the backbuffer.
pass.writeColor(backbuffer,
swapchainTarget ? vk::AttachmentLoadOp::eLoad
: vk::AttachmentLoadOp::eClear,
CLEAR_COLOR);
for (const auto& [handle, target] : viewTargets) {
pass.sample(target.color);
}
},
[&](vk::CommandBuffer commandBuffer) {
ZoneScopedN("main pass");
modelRenderSystem.render(commandBuffer, instances,
canvasProjection);
imguiRenderer.endFrame(commandBuffer);
});
graph.execute(commandBuffer);
renderContext.endFrame(); renderContext.endFrame();
FrameMark;
frameIndex++; frameIndex++;
} }
@@ -155,21 +271,24 @@ class Application::Impl {
device.waitIdle(); device.waitIdle();
this->application._ui.renderer = nullptr;
auto endTime = std::chrono::high_resolution_clock::now(); auto endTime = std::chrono::high_resolution_clock::now();
double totalRuntime = double totalRuntime =
std::chrono::duration<double>(endTime - startTime).count(); std::chrono::duration<double>(endTime - startTime).count();
log::info("Application ran for", totalRuntime, "s"); log::info("Application ran for {:.2f}s", totalRuntime);
} }
void pushMesh(std::vector<CanvasVertex> vertices, std::vector<u32> indices) { void pushMesh(std::vector<CanvasVertex> vertices, std::vector<u32> indices) {
Mesh::Builder builder{}; MeshData data;
builder.vertices.reserve(vertices.size()); data.vertices.reserve(vertices.size());
for (const CanvasVertex& vertex : vertices) { for (const CanvasVertex& vertex : vertices) {
builder.vertices.push_back({vertex.position, vertex.color}); data.vertices.push_back(
{.position = {vertex.position, 0.f}, .color = {vertex.color, 1.f}});
} }
builder.indices = std::move(indices); data.indices = std::move(indices);
this->meshes.push_back(std::make_unique<Mesh>(this->device, builder)); this->meshes.push_back(std::make_unique<Mesh>(this->device, data));
} }
void clearMeshes() { void clearMeshes() {
@@ -184,7 +303,7 @@ class Application::Impl {
this->drawCalls.push_back({&mesh, transform}); this->drawCalls.push_back({&mesh, transform});
} }
Device& getDevice() { return device; } ResourceRegistry& getResourceRegistry() { return resourceRegistry; }
private: private:
Application& application; Application& application;
@@ -192,6 +311,7 @@ class Application::Impl {
Window window; Window window;
Device device; Device device;
RenderContext renderContext; RenderContext renderContext;
ResourceRegistry resourceRegistry;
std::vector<std::unique_ptr<Mesh>> meshes; std::vector<std::unique_ptr<Mesh>> meshes;
std::vector<ModelRenderSystem::MeshInstance> drawCalls; std::vector<ModelRenderSystem::MeshInstance> drawCalls;
@@ -205,13 +325,17 @@ Application::Application(const Config& config)
: impl{std::make_unique<Impl>(*this, config)}, : impl{std::make_unique<Impl>(*this, config)},
canvas{this}, canvas{this},
args{std::move(commandLineArgs)}, args{std::move(commandLineArgs)},
assetRegistry{impl->getDevice()}, _ui{ResourceRegistryRef{impl->getResourceRegistry()}},
_debugUI{this->panelRegistry} {} _debugUI{this->panelRegistry, this->consoleCommandRegistry} {}
Application::~Application() = default; Application::~Application() = default;
void Application::run() { impl->run(); } void Application::run() { impl->run(); }
ResourceRegistryRef Application::resources() {
return ResourceRegistryRef{impl->getResourceRegistry()};
}
void Application::pushMesh(std::vector<CanvasVertex> vertices, void Application::pushMesh(std::vector<CanvasVertex> vertices,
std::vector<u32> indices) { std::vector<u32> indices) {
impl->pushMesh(std::move(vertices), std::move(indices)); impl->pushMesh(std::move(vertices), std::move(indices));
@@ -220,7 +344,7 @@ void Application::pushMesh(std::vector<CanvasVertex> vertices,
void Application::clearMeshes() { impl->clearMeshes(); } void Application::clearMeshes() { impl->clearMeshes(); }
void Application::drawMesh(MeshHandle handle, const glm::mat4& transform) { void Application::drawMesh(MeshHandle handle, const glm::mat4& transform) {
impl->drawMesh(assetRegistry.get<Mesh>(handle), transform); impl->drawMesh(impl->getResourceRegistry().meshPool.get(handle), transform);
} }
} // namespace sophia } // namespace sophia
-60
View File
@@ -1,60 +0,0 @@
#include <sophia/asset_registry.hpp>
#include <sophia/util/assert.hpp>
#include "mesh.hpp"
#include "pools/mesh_pool.hpp"
namespace sophia {
class AssetRegistry::Impl {
public:
explicit Impl(Device& device) : meshPool{device} {}
MeshPool meshPool;
// TexturePool texturePool;
// MaterialPool materialPool;
};
AssetRegistry::AssetRegistry(Device& device)
: impl{std::make_unique<Impl>(device)} {}
AssetRegistry::~AssetRegistry() = default;
template <>
MeshHandle AssetRegistry::load<Mesh>(const std::string& path) {
(void)path;
SOPHIA_ASSERT(false,
"Mesh::load(path) is not implemented yet - no model file "
"format is supported; build one via Mesh::Builder for now");
return MeshHandle{};
}
template <typename T>
typename AssetTraits<T>::Handle load(const std::string&) {
static_assert(sizeof(T) == 0,
"load<T> is not implemented for this asset type");
}
template <>
void AssetRegistry::release<Mesh>(MeshHandle handle) {
impl->meshPool.release(handle);
}
template <>
const Mesh& AssetRegistry::get<Mesh>(MeshHandle handle) const {
return impl->meshPool.get(handle);
}
MeshHandle AssetRegistry::loadMesh(std::vector<CanvasVertex> vertices,
std::vector<u32> indices) {
Mesh::Builder builder{};
builder.vertices.reserve(vertices.size());
for (const CanvasVertex& vertex : vertices) {
builder.vertices.push_back({vertex.position, vertex.color});
}
builder.indices = std::move(indices);
return impl->meshPool.create(builder);
}
} // namespace sophia
-58
View File
@@ -1,58 +0,0 @@
#include "assets/pools/mesh_pool.hpp"
#include <sophia/util/assert.hpp>
namespace sophia {
namespace {
constexpr u32 GENERATION_MASK = (1u << 12) - 1;
} // namespace
MeshPool::MeshPool(Device& device) : device{device} {}
MeshPool::~MeshPool() = default;
MeshHandle MeshPool::create(const Mesh::Builder& builder) {
auto mesh = std::make_unique<Mesh>(device, builder);
if (!freeList.empty()) {
u32 index = freeList.back();
freeList.pop_back();
slots[index].mesh = std::move(mesh);
return MeshHandle{index, slots[index].generation};
}
slots.push_back(Slot{std::move(mesh), 0});
return MeshHandle{static_cast<u32>(slots.size() - 1), 0};
}
const Mesh& MeshPool::get(MeshHandle handle) const {
SOPHIA_ASSERT(handle.isValid(), "invalid MeshHandle");
SOPHIA_ASSERT(handle.index < slots.size(),
"MeshHandle index out of range:", handle.index);
const Slot& slot = slots[handle.index];
SOPHIA_ASSERT(slot.generation == handle.generation,
"stale MeshHandle (generation mismatch):", handle.index);
SOPHIA_ASSERT(slot.mesh != nullptr,
"MeshHandle refers to a released mesh:", handle.index);
return *slot.mesh;
}
void MeshPool::release(MeshHandle handle) {
SOPHIA_ASSERT(handle.isValid(), "invalid MeshHandle");
SOPHIA_ASSERT(handle.index < slots.size(),
"MeshHandle index out of range:", handle.index);
Slot& slot = slots[handle.index];
SOPHIA_ASSERT(slot.generation == handle.generation,
"stale MeshHandle (generation mismatch):", handle.index);
SOPHIA_ASSERT(slot.mesh != nullptr,
"MeshHandle already released:", handle.index);
slot.mesh.reset();
slot.generation = (slot.generation + 1) & GENERATION_MASK;
freeList.push_back(handle.index);
}
} // namespace sophia
-35
View File
@@ -1,35 +0,0 @@
#pragma once
#include <memory>
#include <vector>
#include "device.hpp"
#include "mesh.hpp"
namespace sophia {
class MeshPool {
public:
explicit MeshPool(Device& device);
~MeshPool();
MeshPool(const MeshPool&) = delete;
MeshPool& operator=(const MeshPool&) = delete;
MeshHandle create(const Mesh::Builder& builder);
const Mesh& get(MeshHandle handle) const;
void release(MeshHandle handle);
private:
struct Slot {
std::unique_ptr<Mesh> mesh;
u32 generation = 0;
};
Device& device;
std::vector<Slot> slots;
std::vector<u32> freeList;
};
} // namespace sophia
@@ -0,0 +1,31 @@
#include <sophia/console/console_command_registry.hpp>
namespace sophia {
void ConsoleCommandRegistry::registerCommand(std::string name, std::string help,
Handler handler) {
commands[std::move(name)] = Command{std::move(help), std::move(handler)};
}
bool ConsoleCommandRegistry::dispatch(const std::string& name,
const std::vector<std::string>& args,
std::string& outResult) const {
auto it = commands.find(name);
if (it == commands.end()) {
return false;
}
outResult = it->second.handler(args);
return true;
}
std::vector<ConsoleCommandRegistry::CommandInfo> ConsoleCommandRegistry::list()
const {
std::vector<CommandInfo> result;
result.reserve(commands.size());
for (const auto& [name, command] : commands) {
result.push_back(CommandInfo{name, command.help});
}
return result;
}
} // namespace sophia
-85
View File
@@ -1,85 +0,0 @@
#include "imgui_renderer.hpp"
#include <imgui.h>
#include <imgui_impl_glfw.h>
#include <imgui_impl_vulkan.h>
namespace sophia {
ImGuiRenderer::ImGuiRenderer(Window& window, Device& device,
vk::Format colorAttachmentFormat,
u32 minImageCount) {
// 1. Construct ImGui context
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGui_ImplGlfw_InitForVulkan(window.getGLFWwindow(), true);
ImGui_ImplVulkan_InitInfo initInfo = {};
device.populateImGuiInitInfo(initInfo);
initInfo.DescriptorPoolSize =
IMGUI_IMPL_VULKAN_MINIMUM_SAMPLED_IMAGE_POOL_SIZE + 5;
initInfo.MinImageCount = minImageCount;
initInfo.ImageCount = minImageCount;
VkFormat rawColorAttachmentFormat =
static_cast<VkFormat>(colorAttachmentFormat);
initInfo.UseDynamicRendering = true;
initInfo.PipelineInfoMain.PipelineRenderingCreateInfo.sType =
VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO_KHR;
initInfo.PipelineInfoMain.PipelineRenderingCreateInfo.colorAttachmentCount =
1;
initInfo.PipelineInfoMain.PipelineRenderingCreateInfo
.pColorAttachmentFormats = &rawColorAttachmentFormat;
ImGui_ImplVulkan_Init(&initInfo);
// 2. Enable dark theme
ImGui::StyleColorsDark();
// 3. Enable docking / keyboard
ImGuiIO& io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
/**
* When viewports are enabled we tweak WindowRounding/WindowBg so
* platform windows can look identical to regular ones.
*/
ImGuiStyle& style = ImGui::GetStyle();
if (ImGui::GetIO().ConfigFlags & ImGuiConfigFlags_ViewportsEnable) {
style.WindowRounding = 0.0f;
style.Colors[ImGuiCol_WindowBg].w = 1.0f;
}
}
ImGuiRenderer::~ImGuiRenderer() {
ImGui_ImplVulkan_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();
}
void ImGuiRenderer::beginFrame() {
ImGui_ImplVulkan_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
ImGui::DockSpaceOverViewport(0, ImGui::GetMainViewport(),
ImGuiDockNodeFlags_PassthruCentralNode);
}
void ImGuiRenderer::endFrame(vk::CommandBuffer commandBuffer) {
ImGui::Render();
ImDrawData* mainDrawData = ImGui::GetDrawData();
ImGuiIO& io = ImGui::GetIO();
if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) {
ImGui::UpdatePlatformWindows();
ImGui::RenderPlatformWindowsDefault();
}
ImGui_ImplVulkan_RenderDrawData(mainDrawData, commandBuffer);
}
} // namespace sophia
-23
View File
@@ -1,23 +0,0 @@
#pragma once
#include <sophia/types.hpp>
#include "device.hpp"
#include "window.hpp"
namespace sophia {
class ImGuiRenderer {
public:
ImGuiRenderer(const ImGuiRenderer&) = delete;
ImGuiRenderer& operator=(const ImGuiRenderer&) = delete;
ImGuiRenderer(Window& window, Device& device,
vk::Format colorAttachmentFormat, u32 minImageCount);
~ImGuiRenderer();
void beginFrame();
void endFrame(vk::CommandBuffer commandBuffer);
};
} // namespace sophia
-70
View File
@@ -1,70 +0,0 @@
#pragma once
#include <memory>
#include <sophia/asset_registry.hpp>
#include <sophia/types.hpp>
#include <vector>
#define GLM_FORCE_RADIANS
#define GLM_FORCE_DEPTH_ZERO_TO_ONE
#include <glm/glm.hpp>
#include "buffer.hpp"
#include "device.hpp"
namespace sophia {
class Mesh {
public:
using HandleType = MeshHandle;
struct Vertex {
glm::vec2 position{};
glm::vec3 color{};
// glm::vec3 normal{};
// glm::vec2 uv{};
static std::vector<vk::VertexInputBindingDescription>
getBindingDescriptions();
static std::vector<vk::VertexInputAttributeDescription>
getAttributeDescriptions();
// bool operator==(const Vertex& other) const {
// return position == other.position && color == other.color;
// }
};
struct Builder {
std::vector<Vertex> vertices{};
std::vector<uint32_t> indices{};
// void loadModel(const std::string& filepath);
};
Mesh(Device& device, const Mesh::Builder& builder);
~Mesh();
Mesh(const Mesh&) = delete;
Mesh& operator=(const Mesh&) = delete;
// static std::unique_ptr<Mesh> createMeshFromFile(
// Device& device, const std::string& filepath);
void bind(vk::CommandBuffer commandBuffer) const;
void draw(vk::CommandBuffer commandBuffer) const;
private:
void createVertexBuffers(const std::vector<Vertex>& vertices);
void createIndexBuffers(const std::vector<u32>& indices);
Device& device;
std::unique_ptr<Buffer> vertexBuffer;
u32 vertexCount;
bool hasIndexBuffer = false;
std::unique_ptr<Buffer> indexBuffer;
u32 indexCount;
};
} // namespace sophia
+202
View File
@@ -0,0 +1,202 @@
#include "renderer/imgui_renderer.hpp"
#include <imgui.h>
#include <imgui_impl_glfw.h>
#include <imgui_impl_vulkan.h>
#include <imgui_internal.h>
#include <tracy/Tracy.hpp>
#include "resources/render_target.hpp"
namespace sophia {
namespace {
constexpr ImGuiDockNodeFlags DOCKSPACE_FLAGS =
ImGuiDockNodeFlags_PassthruCentralNode;
void dockAll(const std::vector<std::string>& windows, ImGuiID nodeId) {
for (const std::string& name : windows) {
ImGui::DockBuilderDockWindow(name.c_str(), nodeId);
}
}
} // namespace
ImGuiRenderer::ImGuiRenderer(Window& window, Device& device,
vk::Format colorAttachmentFormat,
u32 minImageCount, ResourceRegistry& resources)
: resources{resources} {
// 1. Construct ImGui context
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGui_ImplGlfw_InitForVulkan(window.getGLFWwindow(), true);
ImGui_ImplVulkan_InitInfo initInfo = {};
device.populateImGuiInitInfo(initInfo);
initInfo.DescriptorPoolSize =
IMGUI_IMPL_VULKAN_MINIMUM_SAMPLED_IMAGE_POOL_SIZE + 5;
initInfo.MinImageCount = minImageCount;
initInfo.ImageCount = minImageCount;
VkFormat rawColorAttachmentFormat =
static_cast<VkFormat>(colorAttachmentFormat);
initInfo.UseDynamicRendering = true;
initInfo.PipelineInfoMain.PipelineRenderingCreateInfo.sType =
VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO_KHR;
initInfo.PipelineInfoMain.PipelineRenderingCreateInfo.colorAttachmentCount =
1;
initInfo.PipelineInfoMain.PipelineRenderingCreateInfo
.pColorAttachmentFormats = &rawColorAttachmentFormat;
ImGui_ImplVulkan_Init(&initInfo);
// 2. Enable dark theme
ImGui::StyleColorsDark();
// 3. Enable docking / keyboard
ImGuiIO& io = ImGui::GetIO();
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
/**
* When viewports are enabled we tweak WindowRounding/WindowBg so
* platform windows can look identical to regular ones.
*/
ImGuiStyle& style = ImGui::GetStyle();
if (ImGui::GetIO().ConfigFlags & ImGuiConfigFlags_ViewportsEnable) {
style.WindowRounding = 0.0f;
style.Colors[ImGuiCol_WindowBg].w = 1.0f;
}
}
ImGuiRenderer::~ImGuiRenderer() {
for (const auto& [index, texture] : textures) {
ImGui_ImplVulkan_RemoveTexture(texture.descriptorSet);
}
ImGui_ImplVulkan_Shutdown();
ImGui_ImplGlfw_Shutdown();
ImGui::DestroyContext();
}
void ImGuiRenderer::beginFrame() {
ZoneScoped;
releaseStaleTextures();
ImGui_ImplVulkan_NewFrame();
ImGui_ImplGlfw_NewFrame();
ImGui::NewFrame();
ImGuiID dockspaceId = ImGui::DockSpaceOverViewport(
0, ImGui::GetMainViewport(), DOCKSPACE_FLAGS);
applyLayoutIfNeeded(dockspaceId, DOCKSPACE_FLAGS);
}
ImTextureID ImGuiRenderer::getTextureId(RenderTargetHandle handle) {
const RenderTarget& target = resources.renderTargetPool.get(handle);
vk::ImageView view = target.getImage().getView();
auto it = textures.find(handle.index);
if (it != textures.end()) {
if (it->second.generation == handle.generation &&
it->second.version == target.getVersion()) {
return (ImTextureID)it->second.descriptorSet;
}
ImGui_ImplVulkan_RemoveTexture(it->second.descriptorSet);
textures.erase(it);
}
// ImGui owns the sampler; it only needs the view and the layout the image is
// left in once the scene pass has finished.
VkDescriptorSet descriptorSet = ImGui_ImplVulkan_AddTexture(
static_cast<VkImageView>(view), VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
textures[handle.index] = {handle.generation, target.getVersion(),
descriptorSet};
return (ImTextureID)descriptorSet;
}
// Resizing and releasing a target both wait for the GPU before destroying its
// image, so nothing in flight can still be using a stale texture.
void ImGuiRenderer::releaseStaleTextures() {
for (auto it = textures.begin(); it != textures.end();) {
RenderTargetHandle handle{it->first, it->second.generation};
RenderTargetPool& renderTargetPool = resources.renderTargetPool;
bool stale =
!renderTargetPool.isAlive(handle) ||
renderTargetPool.get(handle).getVersion() != it->second.version;
if (stale) {
ImGui_ImplVulkan_RemoveTexture(it->second.descriptorSet);
it = textures.erase(it);
} else {
++it;
}
}
}
void ImGuiRenderer::setDefaultLayout(DockLayout layout) {
defaultLayout = std::move(layout);
seedIfUnsaved = true;
}
void ImGuiRenderer::resetLayout() { forceReset = true; }
void ImGuiRenderer::applyLayoutIfNeeded(ImGuiID dockspaceId,
ImGuiDockNodeFlags flags) {
if (defaultLayout.empty()) {
return;
}
bool apply = forceReset;
if (seedIfUnsaved) {
// A layout restored from imgui.ini shows up as a split root node.
ImGuiDockNode* root = ImGui::DockBuilderGetNode(dockspaceId);
apply = apply || root == nullptr || !root->IsSplitNode();
seedIfUnsaved = false;
}
if (!apply) {
return;
}
forceReset = false;
const ImVec2 size = ImGui::GetMainViewport()->Size;
ImGui::DockBuilderRemoveNode(dockspaceId);
ImGui::DockBuilderAddNode(dockspaceId, flags | ImGuiDockNodeFlags_DockSpace);
ImGui::DockBuilderSetNodeSize(dockspaceId, size);
ImGuiID center = dockspaceId;
const ImGuiID left = ImGui::DockBuilderSplitNode(
center, ImGuiDir_Left, defaultLayout.leftWidth, nullptr, &center);
// The right split is taken from what's left after the left split.
const float rightRatio =
defaultLayout.rightWidth / (1.0f - defaultLayout.leftWidth);
const ImGuiID right = ImGui::DockBuilderSplitNode(
center, ImGuiDir_Right, rightRatio, nullptr, &center);
const ImGuiID bottom = ImGui::DockBuilderSplitNode(
center, ImGuiDir_Down, defaultLayout.bottomHeight, nullptr, &center);
dockAll(defaultLayout.left, left);
dockAll(defaultLayout.right, right);
dockAll(defaultLayout.bottom, bottom);
dockAll(defaultLayout.center, center);
ImGui::DockBuilderFinish(dockspaceId);
}
void ImGuiRenderer::endFrame(vk::CommandBuffer commandBuffer) {
ZoneScoped;
ImGui::Render();
ImDrawData* mainDrawData = ImGui::GetDrawData();
ImGuiIO& io = ImGui::GetIO();
if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) {
ImGui::UpdatePlatformWindows();
ImGui::RenderPlatformWindowsDefault();
}
ImGui_ImplVulkan_RenderDrawData(mainDrawData, commandBuffer);
}
} // namespace sophia
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <imgui.h>
#include <sophia/handles.hpp>
#include <sophia/types.hpp>
#include <sophia/ui/dock_layout.hpp>
#include <unordered_map>
#include "resources/resource_registry.hpp"
#include "vulkan/device.hpp"
#include "window.hpp"
namespace sophia {
class ImGuiRenderer {
public:
ImGuiRenderer(const ImGuiRenderer&) = delete;
ImGuiRenderer& operator=(const ImGuiRenderer&) = delete;
ImGuiRenderer(Window& window, Device& device,
vk::Format colorAttachmentFormat, u32 minImageCount,
ResourceRegistry& resources);
~ImGuiRenderer();
void beginFrame();
void endFrame(vk::CommandBuffer commandBuffer);
ImTextureID getTextureId(RenderTargetHandle handle);
void setDefaultLayout(DockLayout layout);
void resetLayout();
private:
void applyLayoutIfNeeded(ImGuiID dockspaceId, ImGuiDockNodeFlags flags);
void releaseStaleTextures();
struct Texture {
u32 generation;
u32 version;
VkDescriptorSet descriptorSet;
};
ResourceRegistry& resources;
// Keyed by RenderTargetHandle::index.
std::unordered_map<u32, Texture> textures;
DockLayout defaultLayout;
bool seedIfUnsaved = false;
bool forceReset = false;
};
} // namespace sophia
+154
View File
@@ -0,0 +1,154 @@
#include "renderer/mesh_render_system.hpp"
#include <algorithm>
#include <sophia/util/logger.hpp>
#include <tracy/Tracy.hpp>
#include "shaders/mesh.frag.spv.h"
#include "shaders/mesh.vert.spv.h"
namespace sophia {
namespace {
// Mesh::getAttributeDescriptions() takes locations below this.
constexpr u32 FIRST_INSTANCE_LOCATION = 4;
} // namespace
MeshRenderSystem::MeshRenderSystem(Device& device, vk::Format colorFormat,
vk::Format depthFormat,
ResourceRegistry& resources)
: device{device}, resources{resources} {
createPipelineLayout();
createPipeline(colorFormat, depthFormat);
createInstanceBuffers();
}
MeshRenderSystem::~MeshRenderSystem() {
log::trace("destroyed sophia::MeshRenderSystem");
}
void MeshRenderSystem::beginFrame(u32 frameIndex) {
this->frameIndex = frameIndex;
this->instanceCursor = 0;
}
void MeshRenderSystem::render(vk::CommandBuffer commandBuffer,
const glm::mat4& viewProjection,
std::span<const Instance> instances,
std::span<const Batch> batches) {
ZoneScoped;
if (instances.empty()) {
return;
}
u32 available = MAX_INSTANCES_PER_FRAME - this->instanceCursor;
if (instances.size() > available) {
log::warning(
"MeshRenderSystem: frame instance buffer full, dropping {} meshes",
instances.size() - available);
}
Buffer& buffer = *this->instanceBuffers[this->frameIndex];
u32 base = this->instanceCursor;
u32 count = std::min(static_cast<u32>(instances.size()), available);
buffer.writeToBuffer(const_cast<Instance*>(instances.data()),
count * sizeof(Instance), base * sizeof(Instance));
this->instanceCursor += count;
this->graphicsPipeline->bind(commandBuffer);
PushConstantData push{viewProjection};
commandBuffer.pushConstants(this->pipelineLayout.get(),
vk::ShaderStageFlagBits::eVertex, 0,
sizeof(PushConstantData), &push);
// Binding 1 stays bound across batches; each mesh rebinds binding 0.
vk::Buffer instanceBuffer = buffer.getBuffer();
vk::DeviceSize offset = 0;
commandBuffer.bindVertexBuffers(1, 1, &instanceBuffer, &offset);
for (const Batch& batch : batches) {
if (batch.firstInstance >= count) {
break;
}
u32 batchCount = std::min(batch.instanceCount, count - batch.firstInstance);
const Mesh& mesh = this->resources.meshPool.get(batch.mesh);
mesh.bind(commandBuffer);
mesh.draw(commandBuffer, batchCount, base + batch.firstInstance);
}
}
void MeshRenderSystem::createPipelineLayout() {
vk::PushConstantRange pushConstantRange{};
pushConstantRange.stageFlags = vk::ShaderStageFlagBits::eVertex;
pushConstantRange.offset = 0;
pushConstantRange.size = sizeof(PushConstantData);
vk::PipelineLayoutCreateInfo pipelineLayoutInfo = {};
pipelineLayoutInfo.setLayoutCount = 0;
pipelineLayoutInfo.pushConstantRangeCount = 1;
pipelineLayoutInfo.pPushConstantRanges = &pushConstantRange;
try {
this->pipelineLayout =
this->device.get()->createPipelineLayoutUnique(pipelineLayoutInfo);
log::trace("created vk::PipelineLayout");
} catch (const vk::SystemError& err) {
log::fatal("failed to create vk::PipelineLayout");
throw std::runtime_error("failed to create vk::PipelineLayout");
}
}
void MeshRenderSystem::createPipeline(vk::Format colorFormat,
vk::Format depthFormat) {
auto vertexShader = Shader::Builder()
.fromSPIRV(shaders::mesh_vert_spv)
.setStage(ShaderStage::VERTEX)
.build();
auto fragmentShader = Shader::Builder()
.fromSPIRV(shaders::mesh_frag_spv)
.setStage(ShaderStage::FRAGMENT)
.build();
std::vector<vk::VertexInputBindingDescription> bindings =
Mesh::getBindingDescriptions();
bindings.push_back({1, sizeof(Instance), vk::VertexInputRate::eInstance});
std::vector<vk::VertexInputAttributeDescription> attributes =
Mesh::getAttributeDescriptions();
// A mat4 attribute takes four consecutive locations, one per column.
for (u32 column = 0; column < 4; column++) {
attributes.push_back({FIRST_INSTANCE_LOCATION + column, 1,
vk::Format::eR32G32B32A32Sfloat,
static_cast<u32>(offsetof(Instance, model) +
column * sizeof(glm::vec4))});
}
attributes.push_back({FIRST_INSTANCE_LOCATION + 4, 1,
vk::Format::eR32G32B32A32Sfloat,
static_cast<u32>(offsetof(Instance, color))});
this->graphicsPipeline = GraphicsPipeline::Builder(this->device)
.setVertexShader(*vertexShader)
.setFragmentShader(*fragmentShader)
.setPipelineLayout(this->pipelineLayout.get())
.setColorAttachmentFormats({colorFormat})
.setDepthAttachmentFormat(depthFormat)
.setVertexInput(bindings, attributes)
.setDepthTest(true, true)
.setCullMode(vk::CullModeFlagBits::eBack)
.build();
}
void MeshRenderSystem::createInstanceBuffers() {
for (std::unique_ptr<Buffer>& buffer : this->instanceBuffers) {
buffer = std::make_unique<Buffer>(
this->device, sizeof(Instance), MAX_INSTANCES_PER_FRAME,
vk::BufferUsageFlagBits::eVertexBuffer,
vk::MemoryPropertyFlagBits::eHostVisible |
vk::MemoryPropertyFlagBits::eHostCoherent);
buffer->map();
}
}
} // namespace sophia
@@ -0,0 +1,80 @@
#pragma once
#include <array>
#include <memory>
#include <sophia/handles.hpp>
#include <sophia/math.hpp>
#include <span>
#include "renderer/render_context.hpp"
#include "resources/resource_registry.hpp"
#include "vulkan/buffer.hpp"
#include "vulkan/device.hpp"
#include "vulkan/pipelines/graphics_pipeline.hpp"
namespace sophia {
/**
* Draws batches of opaque meshes, depth tested and back-face culled. Knows
* nothing about scenes: callers hand it instances grouped into runs that
* share a mesh.
*
* Unlit for now: each pixel is the vertex color times the instance color.
* Normals and UVs reach the vertex shader but aren't used yet.
*
* Instances are written to a per-frame host-visible buffer, so every
* render() call within one frame appends to it; call beginFrame() once per
* frame before any render().
*/
class MeshRenderSystem {
public:
// Layout must match the per-instance inputs in mesh.vert.
struct Instance {
glm::mat4 model{1.f};
glm::vec4 color{1.f};
};
// A run of consecutive instances of the same mesh.
struct Batch {
MeshHandle mesh;
u32 firstInstance;
u32 instanceCount;
};
static constexpr u32 MAX_INSTANCES_PER_FRAME = 32768;
MeshRenderSystem(const MeshRenderSystem&) = delete;
MeshRenderSystem& operator=(const MeshRenderSystem&) = delete;
MeshRenderSystem(Device& device, vk::Format colorFormat,
vk::Format depthFormat, ResourceRegistry& resources);
~MeshRenderSystem();
void beginFrame(u32 frameIndex);
void render(vk::CommandBuffer commandBuffer, const glm::mat4& viewProjection,
std::span<const Instance> instances,
std::span<const Batch> batches);
private:
struct PushConstantData {
glm::mat4 viewProjection{1.f};
};
void createPipelineLayout();
void createPipeline(vk::Format colorFormat, vk::Format depthFormat);
void createInstanceBuffers();
Device& device;
ResourceRegistry& resources;
vk::UniquePipelineLayout pipelineLayout;
std::unique_ptr<GraphicsPipeline> graphicsPipeline;
std::array<std::unique_ptr<Buffer>, RenderContext::MAX_FRAMES_IN_FLIGHT>
instanceBuffers;
u32 frameIndex = 0;
u32 instanceCursor = 0;
};
} // namespace sophia
@@ -1,6 +1,7 @@
#include "model_render_system.hpp" #include "renderer/model_render_system.hpp"
#include <sophia/util/assert.hpp> #include <sophia/util/assert.hpp>
#include <tracy/Tracy.hpp>
#include "shaders/model_render_system.frag.spv.h" #include "shaders/model_render_system.frag.spv.h"
#include "shaders/model_render_system.vert.spv.h" #include "shaders/model_render_system.vert.spv.h"
@@ -24,8 +25,8 @@ ModelRenderSystem::ModelRenderSystem(Device& device,
.setStage(ShaderStage::FRAGMENT) .setStage(ShaderStage::FRAGMENT)
.build(); .build();
auto bindingDescriptions = Mesh::Vertex::getBindingDescriptions(); auto bindingDescriptions = Mesh::getBindingDescriptions();
auto attributeDescriptions = Mesh::Vertex::getAttributeDescriptions(); auto attributeDescriptions = Mesh::getAttributeDescriptions();
this->graphicsPipeline = this->graphicsPipeline =
GraphicsPipeline::Builder(device) GraphicsPipeline::Builder(device)
@@ -43,9 +44,8 @@ ModelRenderSystem::~ModelRenderSystem() {
void ModelRenderSystem::render(vk::CommandBuffer commandBuffer, void ModelRenderSystem::render(vk::CommandBuffer commandBuffer,
std::span<const MeshInstance> instances, std::span<const MeshInstance> instances,
const FrameInfo& frameInfo) { const glm::mat4& viewProjection) {
glm::mat4 viewProjection = frameInfo.camera.getViewProjection(); ZoneScoped;
this->graphicsPipeline->bind(commandBuffer); this->graphicsPipeline->bind(commandBuffer);
for (const MeshInstance& instance : instances) { for (const MeshInstance& instance : instances) {
@@ -1,13 +1,13 @@
#pragma once #pragma once
#include <memory> #include <memory>
#include <sophia/frame_info.hpp> #include <sophia/math.hpp>
#include <span> #include <span>
#include "device.hpp" #include "renderer/render_context.hpp"
#include "mesh.hpp" #include "resources/mesh.hpp"
#include "pipelines/graphics_pipeline.hpp" #include "vulkan/device.hpp"
#include "render_context.hpp" #include "vulkan/pipelines/graphics_pipeline.hpp"
namespace sophia { namespace sophia {
@@ -32,7 +32,7 @@ class ModelRenderSystem {
void render(vk::CommandBuffer commandBuffer, void render(vk::CommandBuffer commandBuffer,
std::span<const MeshInstance> instances, std::span<const MeshInstance> instances,
const FrameInfo& frameInfo); const glm::mat4& viewProjection);
private: private:
void createPipelineLayout(); void createPipelineLayout();
@@ -1,9 +1,12 @@
#include "render_context.hpp" #include "renderer/render_context.hpp"
#include <limits> #include <limits>
#include <sophia/util/assert.hpp> #include <sophia/util/assert.hpp>
#include <sophia/util/logger.hpp> #include <sophia/util/logger.hpp>
#include <stdexcept> #include <stdexcept>
#include <tracy/Tracy.hpp>
#include "vulkan/access.hpp"
namespace sophia { namespace sophia {
@@ -11,22 +14,31 @@ RenderContext::RenderContext(Window& window, Device& device)
: window{window}, device{device} { : window{window}, device{device} {
createFrames(); createFrames();
recreateSwapchain(); recreateSwapchain();
createTracyContext();
} }
RenderContext::~RenderContext() { this->device.waitIdle(); } RenderContext::~RenderContext() {
this->device.waitIdle();
TracyVkDestroy(this->tracyContext);
}
vk::CommandBuffer RenderContext::beginFrame() { vk::CommandBuffer RenderContext::beginFrame() {
ZoneScoped;
SOPHIA_ASSERT(!this->isFrameStarted, SOPHIA_ASSERT(!this->isFrameStarted,
"Can't call beginFrame while already in progress"); "Can't call beginFrame while already in progress");
FrameData& frame = this->frames[this->currentFrameIndex]; FrameData& frame = this->frames[this->currentFrameIndex];
vk::Fence inFlightFence = frame.inFlight.get(); vk::Fence inFlightFence = frame.inFlight.get();
vk::Result fenceResult = this->device.get()->waitForFences( vk::Result fenceResult;
inFlightFence, vk::True, std::numeric_limits<u64>::max()); {
ZoneScopedN("wait for frame fence");
fenceResult = this->device.get()->waitForFences(
inFlightFence, vk::True, std::numeric_limits<u64>::max());
}
if (fenceResult != vk::Result::eSuccess) { if (fenceResult != vk::Result::eSuccess) {
log::error("failed to wait for fence: " + vk::to_string(fenceResult)); log::error("failed to wait for fence: {}", vk::to_string(fenceResult));
} }
vk::Semaphore imageAvailable = frame.imageAvailable.get(); vk::Semaphore imageAvailable = frame.imageAvailable.get();
@@ -50,7 +62,7 @@ vk::CommandBuffer RenderContext::beginFrame() {
imageInFlight, vk::True, std::numeric_limits<u64>::max()); imageInFlight, vk::True, std::numeric_limits<u64>::max());
if (fenceResult != vk::Result::eSuccess) { if (fenceResult != vk::Result::eSuccess) {
log::error("failed to wait for fence: " + vk::to_string(fenceResult)); log::error("failed to wait for fence: {}", vk::to_string(fenceResult));
} }
} }
@@ -67,33 +79,42 @@ vk::CommandBuffer RenderContext::beginFrame() {
throw std::runtime_error("failed to begin recording command buffer"); throw std::runtime_error("failed to begin recording command buffer");
} }
TracyVkCollect(this->tracyContext,
static_cast<VkCommandBuffer>(frame.commandBuffer));
return frame.commandBuffer; return frame.commandBuffer;
} }
void RenderContext::endFrame() { void RenderContext::endFrame() {
ZoneScoped;
SOPHIA_ASSERT(this->isFrameStarted, SOPHIA_ASSERT(this->isFrameStarted,
"Can't call endFrame while no frame is in progress"); "Can't call endFrame while no frame is in progress");
FrameData& frame = this->frames[this->currentFrameIndex]; FrameData& frame = this->frames[this->currentFrameIndex];
frame.commandBuffer.end(); frame.commandBuffer.end();
vk::Semaphore waitSemaphore = frame.imageAvailable.get();
vk::Semaphore signalSemaphore = vk::Semaphore signalSemaphore =
this->renderFinishedSemaphores.at(this->currentImageIndex).get(); this->renderFinishedSemaphores.at(this->currentImageIndex).get();
vk::PipelineStageFlags waitStage =
vk::PipelineStageFlagBits::eColorAttachmentOutput;
vk::CommandBuffer commandBuffer = frame.commandBuffer;
vk::SubmitInfo submitInfo{}; vk::SemaphoreSubmitInfo waitInfo{};
submitInfo.waitSemaphoreCount = 1; waitInfo.semaphore = frame.imageAvailable.get();
submitInfo.pWaitSemaphores = &waitSemaphore; waitInfo.stageMask = SWAPCHAIN_ACQUIRE_STAGE;
submitInfo.pWaitDstStageMask = &waitStage;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &signalSemaphore;
this->device.getGraphicsQueue().submit(submitInfo, frame.inFlight.get()); // Present has no pipeline stage to wait on, so signal once everything in
// the submission is done.
vk::SemaphoreSubmitInfo signalInfo{};
signalInfo.semaphore = signalSemaphore;
signalInfo.stageMask = vk::PipelineStageFlagBits2::eAllCommands;
vk::CommandBufferSubmitInfo commandBufferInfo{};
commandBufferInfo.commandBuffer = frame.commandBuffer;
vk::SubmitInfo2 submitInfo{};
submitInfo.setWaitSemaphoreInfos(waitInfo);
submitInfo.setCommandBufferInfos(commandBufferInfo);
submitInfo.setSignalSemaphoreInfos(signalInfo);
this->device.getGraphicsQueue().submit2(submitInfo, frame.inFlight.get());
vk::Result result = this->swapchain->present( vk::Result result = this->swapchain->present(
this->device.getPresentQueue(), this->currentImageIndex, signalSemaphore); this->device.getPresentQueue(), this->currentImageIndex, signalSemaphore);
@@ -144,6 +165,27 @@ void RenderContext::recreateSwapchain() {
this->imagesInFlight.assign(this->swapchain->imageCount(), vk::Fence{}); this->imagesInFlight.assign(this->swapchain->imageCount(), vk::Fence{});
} }
void RenderContext::createTracyContext() {
#ifdef TRACY_ENABLE
// Tracy records and submits a one-off command buffer to line up GPU and CPU
// clocks, and waits for it.
vk::CommandBufferAllocateInfo allocInfo{this->device.getCommandPool(),
vk::CommandBufferLevel::ePrimary, 1};
vk::CommandBuffer commandBuffer =
this->device.get()->allocateCommandBuffers(allocInfo).front();
this->tracyContext = TracyVkContext(
static_cast<VkPhysicalDevice>(this->device.getPhysicalDevice()),
static_cast<VkDevice>(*this->device.get()),
static_cast<VkQueue>(this->device.getGraphicsQueue()),
static_cast<VkCommandBuffer>(commandBuffer));
TracyVkContextName(this->tracyContext, "Graphics", 8);
this->device.get()->freeCommandBuffers(this->device.getCommandPool(),
commandBuffer);
#endif
}
void RenderContext::createFrames() { void RenderContext::createFrames() {
vk::CommandBufferAllocateInfo allocInfo{this->device.getCommandPool(), vk::CommandBufferAllocateInfo allocInfo{this->device.getCommandPool(),
vk::CommandBufferLevel::ePrimary, vk::CommandBufferLevel::ePrimary,
@@ -6,8 +6,11 @@
#include <vector> #include <vector>
#include <vulkan/vulkan.hpp> #include <vulkan/vulkan.hpp>
#include "device.hpp" /* AFTER VULKAN */
#include "swapchain.hpp" #include <tracy/TracyVulkan.hpp>
#include "vulkan/device.hpp"
#include "vulkan/swapchain.hpp"
#include "window.hpp" #include "window.hpp"
namespace sophia { namespace sophia {
@@ -31,13 +34,12 @@ class RenderContext {
vk::CommandBuffer beginFrame(); vk::CommandBuffer beginFrame();
void endFrame(); void endFrame();
void transitionCurrentImage(vk::CommandBuffer commandBuffer, Image& getCurrentImage() const {
vk::ImageLayout newLayout) const { return this->swapchain->getImage(static_cast<int>(this->currentImageIndex));
this->swapchain->getImage(static_cast<int>(this->currentImageIndex))
.transitionLayout(commandBuffer, newLayout);
} }
vk::ImageView getCurrentImageView() const {
return this->swapchain->getImageView( Image& getCurrentDepthImage() const {
return this->swapchain->getDepthImage(
static_cast<int>(this->currentImageIndex)); static_cast<int>(this->currentImageIndex));
} }
@@ -45,6 +47,8 @@ class RenderContext {
vk::Format getImageFormat() const { vk::Format getImageFormat() const {
return this->swapchain->getImageFormat(); return this->swapchain->getImageFormat();
} }
u32 getFrameIndex() const { return this->currentFrameIndex; }
TracyVkCtx getTracyContext() const { return this->tracyContext; }
u32 getImageCount() const { u32 getImageCount() const {
return static_cast<u32>(this->swapchain->imageCount()); return static_cast<u32>(this->swapchain->imageCount());
} }
@@ -52,6 +56,7 @@ class RenderContext {
private: private:
void recreateSwapchain(); void recreateSwapchain();
void createFrames(); void createFrames();
void createTracyContext();
Window& window; Window& window;
Device& device; Device& device;
@@ -63,6 +68,8 @@ class RenderContext {
std::vector<vk::UniqueSemaphore> renderFinishedSemaphores; std::vector<vk::UniqueSemaphore> renderFinishedSemaphores;
std::vector<vk::Fence> imagesInFlight; std::vector<vk::Fence> imagesInFlight;
TracyVkCtx tracyContext = nullptr;
u32 currentFrameIndex = 0; u32 currentFrameIndex = 0;
u32 currentImageIndex = 0; u32 currentImageIndex = 0;
bool isFrameStarted = false; bool isFrameStarted = false;
+195
View File
@@ -0,0 +1,195 @@
#include "renderer/render_graph.hpp"
#include <sophia/util/assert.hpp>
#include <tracy/Tracy.hpp>
#include <utility>
#include "vulkan/debug_label.hpp"
namespace sophia {
ImageRef RenderGraph::importImage(Image& image, Access finalAccess) {
ImageRef ref{static_cast<u32>(this->images.size())};
this->images.push_back({&image, finalAccess, image.getAccessState()});
return ref;
}
void RenderGraph::addPass(std::string name, const SetupFn& setup,
ExecuteFn execute) {
Pass& pass = this->passes.emplace_back();
pass.name = std::move(name);
pass.execute = std::move(execute);
PassBuilder builder{*this, pass};
setup(builder);
}
void RenderGraph::execute(vk::CommandBuffer commandBuffer) {
ZoneScoped;
for (Pass& pass : this->passes) {
this->recordPass(commandBuffer, pass);
}
PendingBarriers pending;
for (u32 i = 0; i < this->images.size(); i++) {
this->transitionTo(ImageRef{i}, getAccessState(this->images[i].finalAccess),
pending);
}
this->flush(commandBuffer, pending);
for (ImportedImage& imported : this->images) {
imported.image->recordLayout(imported.state.layout);
}
this->passes.clear();
}
void RenderGraph::transitionTo(ImageRef ref, const AccessState& next,
PendingBarriers& pending) {
SOPHIA_ASSERT(ref.isValid() && ref.index < this->images.size(),
"ImageRef doesn't belong to this graph");
ImportedImage& imported = this->images[ref.index];
if (needsBarrier(imported.state, next)) {
pending.barriers.push_back(makeImageBarrier(
imported.image->get(), imported.image->getSubresourceRange(),
imported.state, next));
imported.state = next;
} else {
// Same-layout read after read. Nothing to record, but a later write has
// to wait on this reader too.
imported.state.stage |= next.stage;
imported.state.access |= next.access;
}
}
void RenderGraph::flush(vk::CommandBuffer commandBuffer,
PendingBarriers& pending) {
recordImageBarriers(commandBuffer, pending.barriers);
pending = PendingBarriers{};
}
void RenderGraph::recordPass(vk::CommandBuffer commandBuffer, Pass& pass) {
ZoneTransientN(cpuZone, pass.name.c_str(), true);
TracyVkZoneTransient(this->tracyContext, gpuZone,
static_cast<VkCommandBuffer>(commandBuffer),
pass.name.c_str(), this->tracyContext != nullptr);
beginDebugLabel(commandBuffer, pass.name.c_str());
PendingBarriers pending;
for (const ImageUse& use : pass.uses) {
this->transitionTo(use.image, getAccessState(use.access), pending);
}
this->flush(commandBuffer, pending);
if (pass.colorAttachments.empty() && !pass.depthAttachment) {
pass.execute(commandBuffer);
} else {
this->recordRendering(commandBuffer, pass);
}
endDebugLabel(commandBuffer);
}
void RenderGraph::recordRendering(vk::CommandBuffer commandBuffer, Pass& pass) {
std::vector<vk::RenderingAttachmentInfo> attachments;
attachments.reserve(pass.colorAttachments.size());
ImageRef sizedBy = pass.colorAttachments.empty()
? pass.depthAttachment->image
: pass.colorAttachments.front().image;
vk::Extent3D imageExtent = this->images[sizedBy.index].image->getExtent();
vk::Extent2D extent{imageExtent.width, imageExtent.height};
for (const ColorAttachment& color : pass.colorAttachments) {
const Image& image = *this->images[color.image.index].image;
SOPHIA_ASSERT(image.getExtent().width == extent.width &&
image.getExtent().height == extent.height,
"Pass {} has color attachments of different sizes",
pass.name);
vk::RenderingAttachmentInfo attachment{};
attachment.imageView = image.getView();
attachment.imageLayout = vk::ImageLayout::eColorAttachmentOptimal;
attachment.loadOp = color.loadOp;
attachment.storeOp = vk::AttachmentStoreOp::eStore;
attachment.clearValue.color = vk::ClearColorValue{color.clearColor};
attachments.push_back(attachment);
}
vk::RenderingInfo renderingInfo{};
renderingInfo.renderArea = vk::Rect2D{{0, 0}, extent};
renderingInfo.layerCount = 1;
renderingInfo.colorAttachmentCount = static_cast<u32>(attachments.size());
renderingInfo.pColorAttachments = attachments.data();
vk::RenderingAttachmentInfo depthAttachment{};
if (pass.depthAttachment) {
const Image& image = *this->images[pass.depthAttachment->image.index].image;
SOPHIA_ASSERT(image.getExtent().width == extent.width &&
image.getExtent().height == extent.height,
"Pass {} has a depth attachment of a different size",
pass.name);
depthAttachment.imageView = image.getView();
depthAttachment.imageLayout =
vk::ImageLayout::eDepthStencilAttachmentOptimal;
depthAttachment.loadOp = pass.depthAttachment->loadOp;
depthAttachment.storeOp = vk::AttachmentStoreOp::eStore;
depthAttachment.clearValue.depthStencil =
vk::ClearDepthStencilValue{pass.depthAttachment->clearDepth, 0};
renderingInfo.pDepthAttachment = &depthAttachment;
}
commandBuffer.beginRendering(renderingInfo);
vk::Viewport viewport{0.0f,
0.0f,
static_cast<float>(extent.width),
static_cast<float>(extent.height),
0.0f,
1.0f};
vk::Rect2D scissor{{0, 0}, extent};
commandBuffer.setViewport(0, viewport);
commandBuffer.setScissor(0, scissor);
pass.execute(commandBuffer);
commandBuffer.endRendering();
}
RenderGraph::PassBuilder& RenderGraph::PassBuilder::writeColor(
ImageRef image, vk::AttachmentLoadOp loadOp, ClearColor clearColor) {
this->use(image, Access::ColorWrite);
this->pass.colorAttachments.push_back({image, loadOp, clearColor});
return *this;
}
RenderGraph::PassBuilder& RenderGraph::PassBuilder::writeDepth(
ImageRef image, vk::AttachmentLoadOp loadOp, float clearDepth) {
SOPHIA_ASSERT(!this->pass.depthAttachment,
"Pass {} already has a depth attachment", this->pass.name);
this->use(image, Access::DepthWrite);
this->pass.depthAttachment = DepthAttachment{image, loadOp, clearDepth};
return *this;
}
RenderGraph::PassBuilder& RenderGraph::PassBuilder::sample(ImageRef image) {
return this->use(image, Access::SampledRead);
}
RenderGraph::PassBuilder& RenderGraph::PassBuilder::use(ImageRef image,
Access access) {
SOPHIA_ASSERT(image.isValid() && image.index < this->graph.images.size(),
"ImageRef doesn't belong to this graph");
for (const ImageUse& existing : this->pass.uses) {
SOPHIA_ASSERT(existing.image.index != image.index,
"Pass {} uses the same image twice", this->pass.name);
}
this->pass.uses.push_back({image, access});
return *this;
}
} // namespace sophia
+168
View File
@@ -0,0 +1,168 @@
#pragma once
#include <array>
#include <functional>
#include <optional>
#include <sophia/types.hpp>
#include <string>
#include <vector>
#include <vulkan/vulkan.hpp>
// After Vulkan, which it expects to already be declared.
#include <tracy/TracyVulkan.hpp>
#include "vulkan/access.hpp"
#include "vulkan/image.hpp"
namespace sophia {
/**
* Names an image within one RenderGraph. Only valid for the graph that
* returned it.
*/
struct ImageRef {
u32 index = INVALID_INDEX;
static constexpr u32 INVALID_INDEX = 0xFFFFFFFF;
bool isValid() const { return this->index != INVALID_INDEX; }
};
using ClearColor = std::array<float, 4>;
/**
* A frame's passes and the images they touch, recorded into a single command
* buffer. Passes run in the order they were added; the graph's job is to
* work out the barriers and layout transitions between them from what each
* pass says it does to each image, and to begin/end rendering for passes that
* write color or depth attachments.
*
* Built fresh every frame:
*
* RenderGraph graph;
* ImageRef backbuffer = graph.importImage(swapchainImage, Access::Present);
* graph.addPass(
* "main", [&](PassBuilder& pass) { pass.writeColor(backbuffer); },
* [&](vk::CommandBuffer cb) { draw(cb); });
* graph.execute(commandBuffer);
*
* Imported images start in whatever layout Image reports and are left in
* their final access when execute() returns, with the Image's tracked layout
* updated to match.
*/
class RenderGraph {
public:
class PassBuilder;
using SetupFn = std::function<void(PassBuilder&)>;
using ExecuteFn = std::function<void(vk::CommandBuffer)>;
explicit RenderGraph(TracyVkCtx tracyContext = nullptr)
: tracyContext{tracyContext} {}
RenderGraph(const RenderGraph&) = delete;
RenderGraph& operator=(const RenderGraph&) = delete;
/**
* Brings an image the graph doesn't own into it. It must outlive
* execute(). After the last pass it is transitioned to `finalAccess`.
*/
ImageRef importImage(Image& image, Access finalAccess);
/**
* Adds a pass. `setup` runs immediately and declares what the pass does to
* images; `execute` runs later, from execute(), with the barriers already
* recorded and (if the pass writes color or depth) rendering begun. `name`
* labels the pass's commands in debugging tools such as RenderDoc.
*/
void addPass(std::string name, const SetupFn& setup, ExecuteFn execute);
/**
* Records every pass, and the barriers between them, into `commandBuffer`.
*/
void execute(vk::CommandBuffer commandBuffer);
private:
struct ImageUse {
ImageRef image;
Access access;
};
struct ColorAttachment {
ImageRef image;
vk::AttachmentLoadOp loadOp;
ClearColor clearColor;
};
struct DepthAttachment {
ImageRef image;
vk::AttachmentLoadOp loadOp;
float clearDepth;
};
struct Pass {
std::string name;
std::vector<ImageUse> uses;
std::vector<ColorAttachment> colorAttachments;
std::optional<DepthAttachment> depthAttachment;
ExecuteFn execute;
};
struct ImportedImage {
Image* image;
Access finalAccess;
// What the image was last left in, as of the barriers recorded so far.
AccessState state;
};
struct PendingBarriers {
std::vector<vk::ImageMemoryBarrier2> barriers;
};
void transitionTo(ImageRef ref, const AccessState& next,
PendingBarriers& pending);
void flush(vk::CommandBuffer commandBuffer, PendingBarriers& pending);
void recordPass(vk::CommandBuffer commandBuffer, Pass& pass);
void recordRendering(vk::CommandBuffer commandBuffer, Pass& pass);
TracyVkCtx tracyContext;
std::vector<ImportedImage> images;
std::vector<Pass> passes;
};
class RenderGraph::PassBuilder {
public:
/**
* Renders into `image` as a color attachment. Passes with one or more of
* these get rendering begun (and viewport/scissor set to the attachment's
* extent) before their execute callback runs.
*/
PassBuilder& writeColor(
ImageRef image,
vk::AttachmentLoadOp loadOp = vk::AttachmentLoadOp::eClear,
ClearColor clearColor = {0.f, 0.f, 0.f, 1.f});
/**
* Renders with `image` as the depth attachment, which must be the same size
* as any color attachments. At most one per pass.
*/
PassBuilder& writeDepth(
ImageRef image,
vk::AttachmentLoadOp loadOp = vk::AttachmentLoadOp::eClear,
float clearDepth = 1.f);
/** Samples `image` in a shader. */
PassBuilder& sample(ImageRef image);
/** Any other use, e.g. storage or transfer access. */
PassBuilder& use(ImageRef image, Access access);
private:
friend class RenderGraph;
PassBuilder(RenderGraph& graph, Pass& pass) : graph{graph}, pass{pass} {}
RenderGraph& graph;
Pass& pass;
};
} // namespace sophia
+9
View File
@@ -0,0 +1,9 @@
#include "renderer/renderer.hpp"
namespace sophia {
Renderer::Renderer() {}
Renderer::~Renderer() {}
} // namespace sophia
+91
View File
@@ -0,0 +1,91 @@
#pragma once
#include <sophia/handles.hpp>
#include <sophia/math.hpp>
#include <sophia/types.hpp>
#include <string>
#include <vector>
#include "vulkan/buffer.hpp"
namespace sophia {
struct DrawPacket {
/* Geometry */
// BufferHandle vertexBuffer;
// BufferHandle indexBuffer;
u32 indexCount;
u32 firstIndex;
/* Shader & state */
// PipelineHandle pipelineState; // Contains shaders, blend state, depth
// settings
/* Bindings */
// DescriptorSetHandle resourceBindings; // Textures, Uniform Buffers,
// Storage Buffers
/* Transform / Constants */
u32 pushConstantData[16];
};
struct RenderView {
mat4 view;
mat4 projection;
vec2 viewport;
RenderTargetHandle target;
};
// struct ComputePacket {
// ComputePipelineHandle pipeline;
// DescriptorSetHandle
// resourceBindings; // Includes storage buffers, uniforms, textures
// // Grid dimensions
// uint32_t groupCountX = 1;
// uint32_t groupCountY = 1;
// uint32_t groupCountZ = 1;
// // Optional push constants for dynamic per-dispatch parameters
// uint8_t pushConstants[64];
// uint32_t pushConstantSize = 0;
// };
// !TODO move to render graph
enum class RenderPassType { Graphics, Compute };
// !TODO move to render graph (???)
struct RenderPass {
std::string name;
RenderPassType type;
// Declared for automatic barrier injection & resource synchronization
std::vector<Buffer> readBuffers; // BufferHandle instead?
std::vector<Buffer> writeBuffers; // BufferHandle instead?
// Commands in this pass
// std::vector<ComputePacket> computeDispatches;
std::vector<DrawPacket> opaqueDrawPackets;
std::vector<DrawPacket> transparentDrawPackets;
};
struct RenderPacket {
RenderView view;
std::vector<RenderPass> passes;
};
class Renderer {
public:
Renderer(const Renderer&) = delete;
Renderer& operator=(const Renderer&) = delete;
Renderer();
~Renderer();
// PipelineHandle createPipeline();
private:
};
} // namespace sophia
+163
View File
@@ -0,0 +1,163 @@
#include "renderer/scene_renderer.hpp"
#include <algorithm>
#include <sophia/components.hpp>
#include <sophia/util/assert.hpp>
#include <tracy/Tracy.hpp>
#include <tuple>
namespace sophia {
SceneRenderer::SceneRenderer(const SceneRegistry& sceneRegistry,
ResourceRegistry& resources, Device& device,
vk::Format colorFormat)
: sceneRegistry{sceneRegistry},
resources{resources},
meshRenderSystem{device, colorFormat,
resources.renderTargetPool.getDepthFormat(), resources},
spriteRenderSystem{device, colorFormat,
resources.renderTargetPool.getDepthFormat(),
resources} {}
void SceneRenderer::beginFrame(u32 frameIndex) {
ZoneScoped;
this->meshRenderSystem.beginFrame(frameIndex);
this->spriteRenderSystem.beginFrame(frameIndex);
}
void SceneRenderer::render(vk::CommandBuffer commandBuffer,
const SceneView& sceneView, Entity camera,
vk::Extent2D extent) {
ZoneScoped;
SOPHIA_ASSERT(camera.hasComponent<CameraComponent>(),
"camera does not have camera component!");
float aspect =
static_cast<float>(extent.width) / static_cast<float>(extent.height);
glm::mat4 view =
CameraComponent::getView(camera.getComponent<TransformComponent>());
glm::mat4 projection =
camera.getComponent<CameraComponent>().getProjection(aspect);
const Scene& scene = this->sceneRegistry.get(sceneView.scene);
glm::mat4 viewProjection = projection * view;
// Opaque first, so sprites blend over the meshes behind them.
renderMeshes(commandBuffer, scene, viewProjection);
renderSprites(commandBuffer, scene, view, viewProjection);
}
void SceneRenderer::renderMeshes(vk::CommandBuffer commandBuffer,
const Scene& scene,
const glm::mat4& viewProjection) {
ZoneScoped;
this->meshDrawItems.clear();
{
ZoneScopedN("meshes: gather");
auto meshes =
scene.registry.view<const TransformComponent, const MeshComponent>();
for (auto [entity, transform, mesh] : meshes.each()) {
if (!this->resources.meshPool.isAlive(mesh.handle)) {
continue;
}
this->meshDrawItems.push_back(
{mesh.handle, {transform.mat4(), mesh.color}});
}
}
{
ZoneScopedN("meshes: sort");
// Opaque, so order only matters for batching: one batch per mesh.
std::ranges::sort(this->meshDrawItems, {},
[](const MeshDrawItem& item) { return item.mesh; });
}
{
ZoneScopedN("meshes: batch");
this->meshInstances.clear();
this->meshBatches.clear();
for (const MeshDrawItem& item : this->meshDrawItems) {
auto index = static_cast<u32>(this->meshInstances.size());
this->meshInstances.push_back(item.instance);
if (this->meshBatches.empty() ||
this->meshBatches.back().mesh != item.mesh) {
this->meshBatches.push_back({item.mesh, index, 0});
}
this->meshBatches.back().instanceCount++;
}
}
TracyPlot("mesh instances", static_cast<int64_t>(this->meshInstances.size()));
TracyPlot("mesh batches", static_cast<int64_t>(this->meshBatches.size()));
this->meshRenderSystem.render(commandBuffer, viewProjection,
this->meshInstances, this->meshBatches);
}
void SceneRenderer::renderSprites(vk::CommandBuffer commandBuffer,
const Scene& scene, const glm::mat4& view,
const glm::mat4& viewProjection) {
ZoneScoped;
this->spriteDrawItems.clear();
{
ZoneScopedN("sprites: gather");
auto sprites =
scene.registry.view<const TransformComponent, const SpriteComponent>();
for (auto [entity, transform, sprite] : sprites.each()) {
TextureHandle texture =
this->resources.texturePool.isAlive(sprite.texture)
? sprite.texture
: this->resources.whiteTexture;
// Distance in front of the camera, which looks down view-space -Z.
float depth = -(view * glm::vec4{transform.position, 1.f}).z;
this->spriteDrawItems.push_back(
{sprite.sortOrder,
depth,
texture,
{sprite.getModel(transform), sprite.color, sprite.uvRect}});
}
}
{
ZoneScopedN("sprites: sort");
// Within a sortOrder, back to front, so nearer sprites blend over farther
// ones. Texture is the last key so that sprites whose order doesn't
// matter relative to each other still end up in as few batches as
// possible.
std::ranges::sort(
this->spriteDrawItems, {}, [](const SpriteDrawItem& item) {
return std::tuple{item.sortOrder, -item.depth, item.texture};
});
}
{
ZoneScopedN("sprites: batch");
this->spriteInstances.clear();
this->spriteBatches.clear();
for (const SpriteDrawItem& item : this->spriteDrawItems) {
auto index = static_cast<u32>(this->spriteInstances.size());
this->spriteInstances.push_back(item.instance);
if (this->spriteBatches.empty() ||
this->spriteBatches.back().texture != item.texture) {
this->spriteBatches.push_back({item.texture, index, 0});
}
this->spriteBatches.back().instanceCount++;
}
}
TracyPlot("sprite instances",
static_cast<int64_t>(this->spriteInstances.size()));
TracyPlot("sprite batches", static_cast<int64_t>(this->spriteBatches.size()));
this->spriteRenderSystem.render(commandBuffer, viewProjection,
this->spriteInstances, this->spriteBatches);
}
} // namespace sophia
+69
View File
@@ -0,0 +1,69 @@
#pragma once
#include <sophia/scene_registry.hpp>
#include <sophia/scene_view.hpp>
#include <vector>
#include "renderer/mesh_render_system.hpp"
#include "renderer/sprite_render_system.hpp"
#include "resources/resource_registry.hpp"
namespace sophia {
/**
* !TEMP to be decoupled once scene batching and rendering impl
*
* Turns a scene's components into draw calls for a SceneView.
*/
class SceneRenderer {
public:
SceneRenderer(const SceneRenderer&) = delete;
SceneRenderer& operator=(const SceneRenderer&) = delete;
SceneRenderer(const SceneRegistry& sceneRegistry, ResourceRegistry& resources,
Device& device, vk::Format colorFormat);
// Call once per frame, before any render().
void beginFrame(u32 frameIndex);
// Records `sceneView` as seen through `camera` (an entity of the view's
// scene with a CameraComponent) into `commandBuffer`. Rendering into the
// view's target, which is `extent` in size, must already have begun.
void render(vk::CommandBuffer commandBuffer, const SceneView& sceneView,
Entity camera, vk::Extent2D extent);
private:
struct MeshDrawItem {
MeshHandle mesh;
MeshRenderSystem::Instance instance;
};
struct SpriteDrawItem {
s32 sortOrder;
// Distance in front of the camera.
float depth;
TextureHandle texture;
SpriteRenderSystem::Instance instance;
};
void renderMeshes(vk::CommandBuffer commandBuffer, const Scene& scene,
const glm::mat4& viewProjection);
void renderSprites(vk::CommandBuffer commandBuffer, const Scene& scene,
const glm::mat4& view, const glm::mat4& viewProjection);
const SceneRegistry& sceneRegistry;
ResourceRegistry& resources;
MeshRenderSystem meshRenderSystem;
SpriteRenderSystem spriteRenderSystem;
// Reused across frames to avoid reallocating.
std::vector<MeshDrawItem> meshDrawItems;
std::vector<MeshRenderSystem::Instance> meshInstances;
std::vector<MeshRenderSystem::Batch> meshBatches;
std::vector<SpriteDrawItem> spriteDrawItems;
std::vector<SpriteRenderSystem::Instance> spriteInstances;
std::vector<SpriteRenderSystem::Batch> spriteBatches;
};
} // namespace sophia
@@ -0,0 +1,202 @@
#include "renderer/sprite_render_system.hpp"
#include <sophia/util/assert.hpp>
#include <sophia/util/logger.hpp>
#include <tracy/Tracy.hpp>
#include "shaders/sprite.frag.spv.h"
#include "shaders/sprite.vert.spv.h"
#include "vulkan/descriptors/descriptor_writer.hpp"
namespace sophia {
namespace {
constexpr u32 MAX_TEXTURES = 1024;
constexpr u32 VERTICES_PER_SPRITE = 6;
} // namespace
SpriteRenderSystem::SpriteRenderSystem(Device& device, vk::Format colorFormat,
vk::Format depthFormat,
ResourceRegistry& resources)
: device{device}, resources{resources} {
createDescriptors();
createPipelineLayout();
createPipeline(colorFormat, depthFormat);
createInstanceBuffers();
}
SpriteRenderSystem::~SpriteRenderSystem() {
log::trace("destroyed sophia::SpriteRenderSystem");
}
void SpriteRenderSystem::beginFrame(u32 frameIndex) {
this->frameIndex = frameIndex;
this->instanceCursor = 0;
}
void SpriteRenderSystem::render(vk::CommandBuffer commandBuffer,
const glm::mat4& viewProjection,
std::span<const Instance> instances,
std::span<const Batch> batches) {
ZoneScoped;
if (instances.empty()) {
return;
}
u32 available = MAX_INSTANCES_PER_FRAME - this->instanceCursor;
if (instances.size() > available) {
log::warning(
"SpriteRenderSystem: frame instance buffer full, dropping {} sprites",
instances.size() - available);
}
Buffer& buffer = *this->instanceBuffers[this->frameIndex];
u32 base = this->instanceCursor;
u32 count = std::min(static_cast<u32>(instances.size()), available);
buffer.writeToBuffer(const_cast<Instance*>(instances.data()),
count * sizeof(Instance), base * sizeof(Instance));
this->instanceCursor += count;
this->graphicsPipeline->bind(commandBuffer);
PushConstantData push{viewProjection};
commandBuffer.pushConstants(this->pipelineLayout.get(),
vk::ShaderStageFlagBits::eVertex, 0,
sizeof(PushConstantData), &push);
vk::Buffer vertexBuffer = buffer.getBuffer();
vk::DeviceSize offset = 0;
commandBuffer.bindVertexBuffers(0, 1, &vertexBuffer, &offset);
for (const Batch& batch : batches) {
if (batch.firstInstance >= count) {
break;
}
u32 batchCount = std::min(batch.instanceCount, count - batch.firstInstance);
vk::DescriptorSet set = getDescriptorSet(batch.texture);
commandBuffer.bindDescriptorSets(vk::PipelineBindPoint::eGraphics,
this->pipelineLayout.get(), 0, 1, &set, 0,
nullptr);
commandBuffer.draw(VERTICES_PER_SPRITE, batchCount, 0,
base + batch.firstInstance);
}
}
vk::DescriptorSet SpriteRenderSystem::getDescriptorSet(TextureHandle handle) {
auto it = this->descriptorSets.find(handle.index);
if (it != this->descriptorSets.end() &&
it->second.generation == handle.generation) {
return it->second.set;
}
const Texture& texture = this->resources.texturePool.get(handle);
vk::DescriptorImageInfo imageInfo = texture.descriptorInfo();
DescriptorWriter writer{*this->setLayout, *this->descriptorPool};
writer.writeImage(0, &imageInfo);
if (it == this->descriptorSets.end()) {
vk::DescriptorSet set;
bool allocated = writer.build(set);
SOPHIA_ASSERT(allocated, "SpriteRenderSystem: out of texture descriptors");
this->descriptorSets.emplace(static_cast<u32>(handle.index),
CachedDescriptorSet{handle.generation, set});
return set;
}
// The slot was reused by a new texture. Releasing a texture waits for the
// device to go idle, so no in-flight frame can still be using this set.
writer.overwrite(it->second.set);
it->second.generation = handle.generation;
return it->second.set;
}
void SpriteRenderSystem::createDescriptors() {
this->setLayout =
DescriptorSetLayout::Builder(this->device)
.addBinding(0, vk::DescriptorType::eCombinedImageSampler,
vk::ShaderStageFlagBits::eFragment)
.build();
this->descriptorPool =
DescriptorPool::Builder(this->device)
.setMaxSets(MAX_TEXTURES)
.addPoolSize(vk::DescriptorType::eCombinedImageSampler, MAX_TEXTURES)
.build();
}
void SpriteRenderSystem::createPipelineLayout() {
vk::PushConstantRange pushConstantRange{};
pushConstantRange.stageFlags = vk::ShaderStageFlagBits::eVertex;
pushConstantRange.offset = 0;
pushConstantRange.size = sizeof(PushConstantData);
vk::DescriptorSetLayout setLayout = this->setLayout->getDescriptorSetLayout();
vk::PipelineLayoutCreateInfo pipelineLayoutInfo = {};
pipelineLayoutInfo.setLayoutCount = 1;
pipelineLayoutInfo.pSetLayouts = &setLayout;
pipelineLayoutInfo.pushConstantRangeCount = 1;
pipelineLayoutInfo.pPushConstantRanges = &pushConstantRange;
try {
this->pipelineLayout =
this->device.get()->createPipelineLayoutUnique(pipelineLayoutInfo);
log::trace("created vk::PipelineLayout");
} catch (const vk::SystemError& err) {
log::fatal("failed to create vk::PipelineLayout");
throw std::runtime_error("failed to create vk::PipelineLayout");
}
}
void SpriteRenderSystem::createPipeline(vk::Format colorFormat,
vk::Format depthFormat) {
auto vertexShader = Shader::Builder()
.fromSPIRV(shaders::sprite_vert_spv)
.setStage(ShaderStage::VERTEX)
.build();
auto fragmentShader = Shader::Builder()
.fromSPIRV(shaders::sprite_frag_spv)
.setStage(ShaderStage::FRAGMENT)
.build();
std::vector<vk::VertexInputBindingDescription> bindings{
{0, sizeof(Instance), vk::VertexInputRate::eInstance}};
std::vector<vk::VertexInputAttributeDescription> attributes;
// A mat4 attribute takes four consecutive locations, one per column.
for (u32 column = 0; column < 4; column++) {
attributes.push_back({column, 0, vk::Format::eR32G32B32A32Sfloat,
static_cast<u32>(offsetof(Instance, model) +
column * sizeof(glm::vec4))});
}
attributes.push_back({4, 0, vk::Format::eR32G32B32A32Sfloat,
static_cast<u32>(offsetof(Instance, color))});
attributes.push_back({5, 0, vk::Format::eR32G32B32A32Sfloat,
static_cast<u32>(offsetof(Instance, uvRect))});
this->graphicsPipeline =
GraphicsPipeline::Builder(this->device)
.setVertexShader(*vertexShader)
.setFragmentShader(*fragmentShader)
.setPipelineLayout(this->pipelineLayout.get())
.setColorAttachmentFormats({colorFormat})
.setDepthAttachmentFormat(depthFormat)
.setVertexInput(bindings, attributes)
.setAlphaBlending()
.setDepthTest(true, false, vk::CompareOp::eLessOrEqual)
.build();
}
void SpriteRenderSystem::createInstanceBuffers() {
for (std::unique_ptr<Buffer>& buffer : this->instanceBuffers) {
buffer = std::make_unique<Buffer>(
this->device, sizeof(Instance), MAX_INSTANCES_PER_FRAME,
vk::BufferUsageFlagBits::eVertexBuffer,
vk::MemoryPropertyFlagBits::eHostVisible |
vk::MemoryPropertyFlagBits::eHostCoherent);
buffer->map();
}
}
} // namespace sophia
@@ -0,0 +1,96 @@
#pragma once
#include <array>
#include <memory>
#include <sophia/handles.hpp>
#include <sophia/math.hpp>
#include <span>
#include <unordered_map>
#include "renderer/render_context.hpp"
#include "resources/resource_registry.hpp"
#include "vulkan/buffer.hpp"
#include "vulkan/descriptors/descriptor_pool.hpp"
#include "vulkan/descriptors/descriptor_set_layout.hpp"
#include "vulkan/device.hpp"
#include "vulkan/pipelines/graphics_pipeline.hpp"
namespace sophia {
/**
* Draws batches of textured quads. Knows nothing about scenes: callers hand
* it instances already in draw order, grouped into runs that share a texture.
* Sprites are depth tested, so opaque geometry in front hides them, but
* don't write depth, since they blend.
*
* Instances are written to a per-frame host-visible buffer, so every
* render() call within one frame appends to it; call beginFrame() once per
* frame before any render().
*/
class SpriteRenderSystem {
public:
// Layout must match the per-instance inputs in sprite.vert.
struct Instance {
glm::mat4 model{1.f};
glm::vec4 color{1.f};
glm::vec4 uvRect{0.f, 0.f, 1.f, 1.f};
};
// A run of consecutive instances drawn with the same texture.
struct Batch {
TextureHandle texture;
u32 firstInstance;
u32 instanceCount;
};
static constexpr u32 MAX_INSTANCES_PER_FRAME = 32768;
SpriteRenderSystem(const SpriteRenderSystem&) = delete;
SpriteRenderSystem& operator=(const SpriteRenderSystem&) = delete;
SpriteRenderSystem(Device& device, vk::Format colorFormat,
vk::Format depthFormat, ResourceRegistry& resources);
~SpriteRenderSystem();
void beginFrame(u32 frameIndex);
void render(vk::CommandBuffer commandBuffer, const glm::mat4& viewProjection,
std::span<const Instance> instances,
std::span<const Batch> batches);
private:
struct PushConstantData {
glm::mat4 viewProjection{1.f};
};
struct CachedDescriptorSet {
u32 generation;
vk::DescriptorSet set;
};
void createDescriptors();
void createPipelineLayout();
void createPipeline(vk::Format colorFormat, vk::Format depthFormat);
void createInstanceBuffers();
vk::DescriptorSet getDescriptorSet(TextureHandle handle);
Device& device;
ResourceRegistry& resources;
std::unique_ptr<DescriptorSetLayout> setLayout;
std::unique_ptr<DescriptorPool> descriptorPool;
// One set per texture slot, keyed by handle index. A generation mismatch
// means the slot was reused by a new texture, so the set is rewritten.
std::unordered_map<u32, CachedDescriptorSet> descriptorSets;
vk::UniquePipelineLayout pipelineLayout;
std::unique_ptr<GraphicsPipeline> graphicsPipeline;
std::array<std::unique_ptr<Buffer>, RenderContext::MAX_FRAMES_IN_FLIGHT>
instanceBuffers;
u32 frameIndex = 0;
u32 instanceCursor = 0;
};
} // namespace sophia
@@ -1,39 +1,36 @@
#include "mesh.hpp" #include "resources/mesh.hpp"
#include <sophia/util/assert.hpp> #include <sophia/util/assert.hpp>
#include <sophia/util/logger.hpp> #include <sophia/util/logger.hpp>
namespace sophia { namespace sophia {
std::vector<vk::VertexInputBindingDescription> std::vector<vk::VertexInputBindingDescription> Mesh::getBindingDescriptions() {
Mesh::Vertex::getBindingDescriptions() { return {{0, sizeof(MeshVertex), vk::VertexInputRate::eVertex}};
std::vector<vk::VertexInputBindingDescription> bindingDescriptions(1);
bindingDescriptions[0].binding = 0;
bindingDescriptions[0].stride = sizeof(Vertex);
bindingDescriptions[0].inputRate = vk::VertexInputRate::eVertex;
return bindingDescriptions;
} }
std::vector<vk::VertexInputAttributeDescription> std::vector<vk::VertexInputAttributeDescription>
Mesh::Vertex::getAttributeDescriptions() { Mesh::getAttributeDescriptions() {
std::vector<vk::VertexInputAttributeDescription> attributeDescriptions{}; return {
{0, 0, vk::Format::eR32G32B32Sfloat, offsetof(MeshVertex, position)},
attributeDescriptions.push_back( {1, 0, vk::Format::eR32G32B32Sfloat, offsetof(MeshVertex, normal)},
{0, 0, vk::Format::eR32G32Sfloat, offsetof(Vertex, position)}); {2, 0, vk::Format::eR32G32Sfloat, offsetof(MeshVertex, uv)},
attributeDescriptions.push_back( {3, 0, vk::Format::eR32G32B32A32Sfloat, offsetof(MeshVertex, color)},
{1, 0, vk::Format::eR32G32B32Sfloat, offsetof(Vertex, color)}); };
// attributeDescriptions.push_back(
// {2, 0, vk::Format::eR32G32B32Sfloat, offsetof(Vertex, normal)});
// attributeDescriptions.push_back(
// {3, 0, vk::Format::eR32G32Sfloat, offsetof(Vertex, uv)});
return attributeDescriptions;
} }
Mesh::Mesh(Device& device, const Builder& builder) : device{device} { Mesh::Mesh(Device& device, const MeshData& data) : device{device} {
createVertexBuffers(builder.vertices); if (!data.vertices.empty()) {
createIndexBuffers(builder.indices); this->bounds = {data.vertices.front().position,
data.vertices.front().position};
for (const MeshVertex& vertex : data.vertices) {
this->bounds.min = glm::min(this->bounds.min, vertex.position);
this->bounds.max = glm::max(this->bounds.max, vertex.position);
}
}
createVertexBuffers(data.vertices);
createIndexBuffers(data.indices);
} }
Mesh::~Mesh() = default; Mesh::~Mesh() = default;
@@ -49,15 +46,17 @@ void Mesh::bind(vk::CommandBuffer commandBuffer) const {
} }
} }
void Mesh::draw(vk::CommandBuffer commandBuffer) const { void Mesh::draw(vk::CommandBuffer commandBuffer, u32 instanceCount,
u32 firstInstance) const {
if (this->hasIndexBuffer) { if (this->hasIndexBuffer) {
commandBuffer.drawIndexed(this->indexCount, 1, 0, 0, 0); commandBuffer.drawIndexed(this->indexCount, instanceCount, 0, 0,
firstInstance);
} else { } else {
commandBuffer.draw(this->vertexCount, 1, 0, 0); commandBuffer.draw(this->vertexCount, instanceCount, 0, firstInstance);
} }
} }
void Mesh::createVertexBuffers(const std::vector<Vertex>& vertices) { void Mesh::createVertexBuffers(const std::vector<MeshVertex>& vertices) {
this->vertexCount = static_cast<u32>(vertices.size()); this->vertexCount = static_cast<u32>(vertices.size());
SOPHIA_ASSERT(this->vertexCount >= 3, "vertex count must be at least 3"); SOPHIA_ASSERT(this->vertexCount >= 3, "vertex count must be at least 3");
+53
View File
@@ -0,0 +1,53 @@
#pragma once
#include <memory>
#include <sophia/aabb.hpp>
#include <sophia/handles.hpp>
#include <sophia/math.hpp>
#include <sophia/mesh_data.hpp>
#include <sophia/types.hpp>
#include <vector>
#include "vulkan/buffer.hpp"
#include "vulkan/device.hpp"
namespace sophia {
class Mesh {
public:
// Binding 0 (per vertex, MeshVertex) for pipelines that draw meshes.
static std::vector<vk::VertexInputBindingDescription>
getBindingDescriptions();
static std::vector<vk::VertexInputAttributeDescription>
getAttributeDescriptions();
Mesh(Device& device, const MeshData& data);
~Mesh();
Mesh(const Mesh&) = delete;
Mesh& operator=(const Mesh&) = delete;
// Bounds of the vertices, in the mesh's own space.
const AABB& getBounds() const { return this->bounds; }
void bind(vk::CommandBuffer commandBuffer) const;
void draw(vk::CommandBuffer commandBuffer, u32 instanceCount = 1,
u32 firstInstance = 0) const;
private:
void createVertexBuffers(const std::vector<MeshVertex>& vertices);
void createIndexBuffers(const std::vector<u32>& indices);
Device& device;
AABB bounds;
std::unique_ptr<Buffer> vertexBuffer;
u32 vertexCount;
bool hasIndexBuffer = false;
std::unique_ptr<Buffer> indexBuffer;
u32 indexCount;
};
} // namespace sophia
@@ -0,0 +1,31 @@
#include "resources/pools/render_target_pool.hpp"
#include <tracy/Tracy.hpp>
namespace sophia {
RenderTargetPool::RenderTargetPool(Device& device, vk::Format format)
: device{device},
format{format},
// Depth-only formats, so the images have no stencil aspect to track.
depthFormat{device.findSupportedFormat(
{vk::Format::eD32Sfloat, vk::Format::eX8D24UnormPack32},
vk::ImageTiling::eOptimal,
vk::FormatFeatureFlagBits::eDepthStencilAttachment)} {}
RenderTargetHandle RenderTargetPool::create(u32 width, u32 height) {
return pool.create(device, format, depthFormat, width, height);
}
void RenderTargetPool::release(RenderTargetHandle handle) {
SOPHIA_ASSERT(pool.isAlive(handle), "invalid RenderTargetHandle");
device.waitIdle();
pool.release(handle);
}
void RenderTargetPool::applyPendingResizes() {
ZoneScoped;
pool.forEach([](RenderTarget& target) { target.applyPendingResize(); });
}
} // namespace sophia
@@ -0,0 +1,40 @@
#pragma once
#include <sophia/handles.hpp>
#include <sophia/util/pool.hpp>
#include "resources/render_target.hpp"
#include "vulkan/device.hpp"
namespace sophia {
class RenderTargetPool {
public:
RenderTargetPool(Device& device, vk::Format format);
RenderTargetPool(const RenderTargetPool&) = delete;
RenderTargetPool& operator=(const RenderTargetPool&) = delete;
RenderTargetHandle create(u32 width, u32 height);
RenderTarget& get(RenderTargetHandle handle) { return pool.get(handle); }
const RenderTarget& get(RenderTargetHandle handle) const {
return pool.get(handle);
}
void release(RenderTargetHandle handle);
bool isAlive(RenderTargetHandle handle) const { return pool.isAlive(handle); }
vk::Format getFormat() const { return format; }
vk::Format getDepthFormat() const { return depthFormat; }
void applyPendingResizes();
private:
Device& device;
vk::Format format;
vk::Format depthFormat;
Pool<RenderTarget> pool{"RenderTarget"};
};
} // namespace sophia
+55
View File
@@ -0,0 +1,55 @@
#include <array>
#include <sophia/primitives.hpp>
namespace sophia::primitives {
MeshData cube(float size, vec4 color) {
struct Face {
vec3 normal;
// Axes across the face, with u x v = normal, so corners taken
// counter-clockwise in (u, v) wind counter-clockwise seen from outside.
vec3 u;
vec3 v;
};
constexpr std::array<Face, 6> FACES{{
{{1.f, 0.f, 0.f}, {0.f, 0.f, -1.f}, {0.f, 1.f, 0.f}},
{{-1.f, 0.f, 0.f}, {0.f, 0.f, 1.f}, {0.f, 1.f, 0.f}},
{{0.f, 1.f, 0.f}, {1.f, 0.f, 0.f}, {0.f, 0.f, -1.f}},
{{0.f, -1.f, 0.f}, {1.f, 0.f, 0.f}, {0.f, 0.f, 1.f}},
{{0.f, 0.f, 1.f}, {1.f, 0.f, 0.f}, {0.f, 1.f, 0.f}},
{{0.f, 0.f, -1.f}, {-1.f, 0.f, 0.f}, {0.f, 1.f, 0.f}},
}};
constexpr std::array<vec2, 4> CORNERS{vec2{-1.f, -1.f}, vec2{1.f, -1.f},
vec2{1.f, 1.f}, vec2{-1.f, 1.f}};
float half = size / 2.f;
MeshData data;
data.vertices.reserve(FACES.size() * CORNERS.size());
data.indices.reserve(FACES.size() * 6);
for (const Face& face : FACES) {
auto base = static_cast<u32>(data.vertices.size());
for (vec2 corner : CORNERS) {
MeshVertex vertex;
vertex.position =
(face.normal + corner.x * face.u + corner.y * face.v) * half;
vertex.normal = face.normal;
// Texture v runs down while the face's v runs up.
vertex.uv = {(corner.x + 1.f) / 2.f, (1.f - corner.y) / 2.f};
vertex.color = color;
data.vertices.push_back(vertex);
}
for (u32 index : {0u, 1u, 2u, 2u, 3u, 0u}) {
data.indices.push_back(base + index);
}
}
return data;
}
} // namespace sophia::primitives
+62
View File
@@ -0,0 +1,62 @@
#include "resources/render_target.hpp"
#include <algorithm>
namespace sophia {
RenderTarget::RenderTarget(Device& device, vk::Format format,
vk::Format depthFormat, u32 width, u32 height)
: device{device}, format{format}, depthFormat{depthFormat} {
create(std::max(width, 1u), std::max(height, 1u));
}
RenderTarget::~RenderTarget() { destroy(); }
vk::Extent2D RenderTarget::getExtent() const {
vk::Extent3D extent = this->image->getExtent();
return {extent.width, extent.height};
}
void RenderTarget::requestResize(u32 width, u32 height) {
this->pendingExtent = vk::Extent2D{std::max(width, 1u), std::max(height, 1u)};
}
void RenderTarget::applyPendingResize() {
if (!this->pendingExtent) {
return;
}
vk::Extent2D extent = *this->pendingExtent;
this->pendingExtent.reset();
if (extent == getExtent()) {
return;
}
this->device.waitIdle();
destroy();
create(extent.width, extent.height);
}
void RenderTarget::create(u32 width, u32 height) {
this->version++;
this->image = Image::Builder(this->device)
.setExtent(width, height)
.setFormat(this->format)
.asColorAttachment()
.withView()
.build();
this->depthImage = Image::Builder(this->device)
.setExtent(width, height)
.asDepthAttachment()
.setFormat(this->depthFormat)
.withView()
.build();
}
void RenderTarget::destroy() {
this->image.reset();
this->depthImage.reset();
}
} // namespace sophia
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include <memory>
#include <optional>
#include <sophia/types.hpp>
#include <vulkan/vulkan.hpp>
#include "vulkan/device.hpp"
#include "vulkan/image.hpp"
namespace sophia {
/**
* A place rendering can go: an offscreen color image, plus a depth image of
* the same size.
*
* Owned by the ResourceRegistry and addressed through a RenderTargetHandle.
*/
class RenderTarget {
public:
RenderTarget(Device& device, vk::Format format, vk::Format depthFormat,
u32 width, u32 height);
~RenderTarget();
RenderTarget(const RenderTarget&) = delete;
RenderTarget& operator=(const RenderTarget&) = delete;
Image& getImage() const { return *this->image; }
Image& getDepthImage() const { return *this->depthImage; }
vk::Extent2D getExtent() const;
u32 getVersion() const { return this->version; }
void requestResize(u32 width, u32 height);
/**
* Applies a pending resize. Waits for the GPU, so it must run before any of
* the frame's work (or any UI reference to the image) is recorded.
*/
void applyPendingResize();
private:
void create(u32 width, u32 height);
void destroy();
Device& device;
vk::Format format;
vk::Format depthFormat;
std::unique_ptr<Image> image;
std::unique_ptr<Image> depthImage;
std::optional<vk::Extent2D> pendingExtent;
u32 version = 0;
};
} // namespace sophia
@@ -0,0 +1,36 @@
#pragma once
#include <array>
#include <sophia/handles.hpp>
#include <sophia/util/pool.hpp>
#include "resources/mesh.hpp"
#include "resources/pools/render_target_pool.hpp"
#include "resources/texture.hpp"
#include "vulkan/device.hpp"
namespace sophia {
class ResourceRegistry {
public:
ResourceRegistry(const ResourceRegistry&) = delete;
ResourceRegistry& operator=(const ResourceRegistry&) = delete;
ResourceRegistry(Device& device, vk::Format renderTargetFormat)
: device{device}, renderTargetPool{device, renderTargetFormat} {
constexpr std::array<u8, 4> WHITE{0xFF, 0xFF, 0xFF, 0xFF};
whiteTexture = texturePool.create(device, 1, 1, WHITE);
}
Device& device;
Pool<Mesh> meshPool{"Mesh"};
RenderTargetPool renderTargetPool;
Pool<Texture> texturePool{"Texture"};
// MaterialPool materials;
// 1x1 white. Stands in for sprites without a texture, so they draw as a
// solid quad in their tint color.
TextureHandle whiteTexture = INVALID_HANDLE;
};
} // namespace sophia
@@ -0,0 +1,111 @@
#include <stb_image.h>
#include <sophia/resource_registry_ref.hpp>
#include <sophia/util/assert.hpp>
#include <sophia/util/logger.hpp>
#include <stdexcept>
#include "resources/resource_registry.hpp"
namespace sophia {
template <>
MeshHandle ResourceRegistryRef::load<Mesh>(const std::string& path) {
(void)path;
SOPHIA_ASSERT(false,
"Mesh::load(path) is not implemented yet - no model file "
"format is supported; build a MeshData instead");
return MeshHandle{};
}
template <>
TextureHandle ResourceRegistryRef::load<Texture>(const std::string& path) {
int width = 0;
int height = 0;
int channels = 0;
// Always decode to RGBA8, whatever the file has.
stbi_uc* pixels = stbi_load(path.c_str(), &width, &height, &channels, 4);
if (pixels == nullptr) {
log::fatal("failed to load texture {} - {}", path, stbi_failure_reason());
throw std::runtime_error("failed to load texture: " + path);
}
auto size = static_cast<size_t>(width) * static_cast<size_t>(height) * 4;
TextureHandle handle = registry->texturePool.create(
registry->device, static_cast<u32>(width), static_cast<u32>(height),
std::span<const u8>{pixels, size});
stbi_image_free(pixels);
return handle;
}
template <>
void ResourceRegistryRef::release<Mesh>(MeshHandle handle) {
// Frames in flight may still be drawing it.
registry->device.waitIdle();
registry->meshPool.release(handle);
}
template <>
void ResourceRegistryRef::release<RenderTarget>(RenderTargetHandle handle) {
registry->renderTargetPool.release(handle);
}
template <>
void ResourceRegistryRef::release<Texture>(TextureHandle handle) {
SOPHIA_ASSERT(handle != registry->whiteTexture,
"the built-in white texture cannot be released");
// Frames in flight may still be sampling it.
registry->device.waitIdle();
registry->texturePool.release(handle);
}
MeshHandle ResourceRegistryRef::createResource(std::type_identity<Mesh>,
const MeshData& data) {
return registry->meshPool.create(registry->device, data);
}
RenderTargetHandle ResourceRegistryRef::createResource(
std::type_identity<RenderTarget>, u32 width, u32 height) {
return registry->renderTargetPool.create(width, height);
}
TextureHandle ResourceRegistryRef::createResource(std::type_identity<Texture>,
u32 width, u32 height,
std::vector<u8> pixels) {
return registry->texturePool.create(registry->device, width, height,
std::span<const u8>{pixels});
}
void ResourceRegistryRef::requestResize(RenderTargetHandle handle, u32 width,
u32 height) {
registry->renderTargetPool.get(handle).requestResize(width, height);
}
glm::vec2 ResourceRegistryRef::getExtent(RenderTargetHandle handle) const {
vk::Extent2D extent = registry->renderTargetPool.get(handle).getExtent();
return {static_cast<float>(extent.width), static_cast<float>(extent.height)};
}
AABB ResourceRegistryRef::getBounds(MeshHandle handle) const {
return registry->meshPool.get(handle).getBounds();
}
bool ResourceRegistryRef::isAlive(MeshHandle handle) const {
return registry->meshPool.isAlive(handle);
}
bool ResourceRegistryRef::isAlive(RenderTargetHandle handle) const {
return registry->renderTargetPool.isAlive(handle);
}
glm::vec2 ResourceRegistryRef::getExtent(TextureHandle handle) const {
const Texture& texture = registry->texturePool.get(handle);
return {static_cast<float>(texture.getWidth()),
static_cast<float>(texture.getHeight())};
}
bool ResourceRegistryRef::isAlive(TextureHandle handle) const {
return registry->texturePool.isAlive(handle);
}
} // namespace sophia
+32
View File
@@ -0,0 +1,32 @@
#include "resources/texture.hpp"
#include <sophia/util/assert.hpp>
#include <sophia/util/logger.hpp>
namespace sophia {
Texture::Texture(Device& device, u32 width, u32 height,
std::span<const u8> pixels) {
SOPHIA_ASSERT(width > 0 && height > 0, "texture must not be empty");
SOPHIA_ASSERT(pixels.size() == static_cast<size_t>(width) * height * 4,
"texture pixel data must be width * height RGBA8 texels");
this->image = Image::Builder(device)
.setExtent(width, height)
.setFormat(vk::Format::eR8G8B8A8Srgb)
.setUsage(vk::ImageUsageFlagBits::eSampled)
.setInitialData(pixels.data(), pixels.size())
.withView()
.withSampler(vk::Filter::eLinear,
vk::SamplerAddressMode::eClampToEdge)
.build();
}
Texture::~Texture() { log::trace("destroyed sophia::Texture"); }
vk::DescriptorImageInfo Texture::descriptorInfo() const {
return {this->image->getSampler(), this->image->getView(),
vk::ImageLayout::eShaderReadOnlyOptimal};
}
} // namespace sophia
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <memory>
#include <sophia/types.hpp>
#include <span>
#include <vulkan/vulkan.hpp>
#include "vulkan/device.hpp"
#include "vulkan/image.hpp"
namespace sophia {
/**
* A sampled, read-only RGBA8 (sRGB) image.
*
* Owned by the ResourceRegistry and addressed through a TextureHandle.
*/
class Texture {
public:
/** `pixels` is width * height tightly packed RGBA8 texels, top row first. */
Texture(Device& device, u32 width, u32 height, std::span<const u8> pixels);
~Texture();
Texture(const Texture&) = delete;
Texture& operator=(const Texture&) = delete;
const Image& getImage() const { return *this->image; }
u32 getWidth() const { return this->image->getExtent().width; }
u32 getHeight() const { return this->image->getExtent().height; }
vk::DescriptorImageInfo descriptorInfo() const;
private:
std::unique_ptr<Image> image;
};
} // namespace sophia
-17
View File
@@ -1,17 +0,0 @@
#include "scene.hpp"
#include <sophia/components.hpp>
#include <sophia/util/logger.hpp>
namespace sophia {
Scene::Scene() {
entt::entity entity = this->registry.create();
auto& a = this->registry.emplace<TransformComponent>(entity);
auto& b = this->registry.get<TransformComponent>(entity);
}
Scene::~Scene() { log::trace("destroying sophia::Scene"); }
} // namespace sophia
-16
View File
@@ -1,16 +0,0 @@
#pragma once
#include <entt/entt.hpp>
namespace sophia {
class Scene {
public:
Scene();
~Scene();
private:
entt::registry registry;
};
} // namespace sophia
+8
View File
@@ -0,0 +1,8 @@
#include <sophia/entity.hpp>
namespace sophia {
Entity::Entity(entt::entity handle, Scene* scene)
: handle(handle), scene(scene) {}
} // namespace sophia

Some files were not shown because too many files have changed in this diff Show More