Fry3DX 0.4: an easy 3D modeller

Shapes with live settings, shape editing, knife and hole tools, drawing
shapes onto other objects, combining objects (add / subtract / overlap),
copies, thickness, painting with stickers and layers, a Layout tab, a
Print tab with checks, packed .fry files with autosave, export to GLB,
glTF, FBX, OBJ, 3MF, STL and PLY, folders in Layers, its own crash
screen, and a website for GitHub Pages in docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NMSrd5oe1GpUD7E6d825KP
This commit is contained in:
Nexoniarz 2026-10-19 21:18:35 +02:00
commit f3e8eca87b
135 changed files with 23423 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
build/
.cache/
*.blend1
__pycache__/
*.fry~

43
CMakeLists.txt Normal file
View file

@ -0,0 +1,43 @@
cmake_minimum_required(VERSION 3.25)
if(UNIX AND NOT APPLE)
add_link_options(-rdynamic) # function names in crash reports
endif()
project(Fry3DX VERSION 0.1.0 LANGUAGES C CXX)
# Fry3DX — Frying 3D Extreme: a 3D modelling app that's easy to pick up. Built on its own
# engine's window, renderer and UI (engine/).
find_program(FRY_CCACHE ccache)
if(FRY_CCACHE AND NOT CMAKE_CXX_COMPILER_LAUNCHER)
set(CMAKE_C_COMPILER_LAUNCHER ${CMAKE_COMMAND} -E env CCACHE_SLOPPINESS=pch_defines,time_macros,include_file_mtime,include_file_ctime ${FRY_CCACHE})
set(CMAKE_CXX_COMPILER_LAUNCHER ${CMAKE_C_COMPILER_LAUNCHER})
endif()
find_program(FRY_MOLD mold)
if(FRY_MOLD AND CMAKE_SYSTEM_NAME STREQUAL "Linux")
add_link_options(-fuse-ld=mold)
endif()
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
include(cmake/Dependencies.cmake)
include(cmake/Shaders.cmake)
add_subdirectory(engine)
add_subdirectory(src)
# Content next to the program: bin/keel/ (the engine's: fonts, sky, config); bin/Fry3DX/ (the app's own, none yet).
add_custom_target(content ALL
COMMAND ${CMAKE_COMMAND} -E copy_directory ${CMAKE_SOURCE_DIR}/content ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}
COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/keel/fonts ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/Fry3DX
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${dejavu_SOURCE_DIR}/ttf/DejaVuSans.ttf ${dejavu_SOURCE_DIR}/ttf/DejaVuSans-Bold.ttf
${dejavu_SOURCE_DIR}/LICENSE ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/keel/fonts/
COMMENT "Copying content" VERBATIM)
enable_testing()
add_subdirectory(tests EXCLUDE_FROM_ALL)
add_custom_target(check COMMAND ${CMAKE_CTEST_COMMAND} --test-dir ${CMAKE_BINARY_DIR} --output-on-failure
DEPENDS mesh_test USES_TERMINAL VERBATIM)

12
CMakePresets.json Normal file
View file

@ -0,0 +1,12 @@
{
"version": 6,
"configurePresets": [
{ "name": "base", "hidden": true, "generator": "Ninja", "binaryDir": "${sourceDir}/build/${presetName}" },
{ "name": "debug", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } },
{ "name": "release", "inherits": "base", "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo" } }
],
"buildPresets": [
{ "name": "debug", "configurePreset": "debug" },
{ "name": "release", "configurePreset": "release" }
]
}

111
README.md Normal file
View file

@ -0,0 +1,111 @@
# Fry3DX — Frying 3D Extreme
**A 3D modeller that does what you'd expect: pick a tool, click, drag.**
Made by [Nexoniarz](https://github.com/Nexoniarz) · Website: https://nexoniarz.github.io/Fry3DX/
![Fry3DX](docs/img/model.webp)
## Why
Because the well-known modellers suck at being easy: a wall of panels, shortcuts nobody would
guess, modes inside modes. Fry3DX is built the way people think — a toolbox like GIMP's, plain
words on every button, tooltips everywhere — and keeps the useful parts (rounding, mirroring,
combining, holes for screws, checks for 3D printing) one click away.
## Build and run
Linux, a Vulkan graphics card, CMake, Ninja, a C++20 compiler, glslc (shaderc) and SDL3.
Everything else is downloaded while building. On NixOS `shell.nix` has it all:
git clone https://github.com/Nexoniarz/Fry3DX.git && cd Fry3DX
nix-shell --run 'cmake --preset release && cmake --build build/release'
build/release/bin/fry3dx [file.fry]
nix-shell --run 'cmake --build build/debug --target check' # tests
## What's in it (v0.4)
Five tabs at the top (Ctrl+1 to 5): **Model**, **Paint**, **Layout**, **Render**, **Print**.
- **Knife** (K, shape editing): click points along the cut (or drag a straight line; Ctrl for
45° steps), Enter or double-click cuts. Only what you see, or **right through**; then just the
edges, pieces of one object, each piece its own object, or the smaller piece deleted — the
inside **filled** (solid) or **left open** (hollow).
- **Hole** (O): press on a face, drag out the size (or type it in mm): round (any number of
sides, screw sizes M2-M8 ready), rectangle (width and height), triangle, or hexagon (nut sizes
M2-M8 ready, with a little room added for prints), turned as you like. All the way through, a
pocket of a set depth, or **only the outline**: cut into the face as a new face, picked, ready
to Extrude, Bevel or paint. Round edges then rounds its rim too.
- **Paint tab**: paint straight on the model (brush, eraser, fill, pick colour) or on its flat
picture beside it; the surface is laid out flat by itself. **Paste a picture** (Ctrl+V, copied
in GIMP or anywhere) or add one from a file, then drag its four corners to stretch and bend it
into place — you see it on the model as you go — and Enter places it. Pictures save beside the
.fry and go with the glTF export.
- **Pictures as layers**: a pasted picture goes onto the model as a **sticker** — drag it round
the model and over a corner or edge it bends onto the next face like a real one; its corners
size it (Shift stretches), the round handle turns it. Or put it on the flat picture and drag its
corners. **Enter locks** it as a layer (listed in Properties: hide, reorder, delete, paint in for
good); **clicking it in the Picture panel unlocks it** to move it again.
- **Files**: `.fry` is Fry3DX's own file — one file with the pictures and layers inside, packed
small (pictures as colour planes of predicted differences, then zstd). Old text `.fry` files
still open. **Autosave** (File > Autosave, every 2 minutes by default) keeps a highly packed
copy in the background; after a crash it offers it back. Closing, New or Open with changes asks:
**Save / Don't save / Cancel**.
- **Print tab** (Ctrl+5): your printer's plate drawn in the view (Prusa, Bambu, Creality,
Elegoo, Anycubic, or your own size), the size in millimetres (type one in: it keeps its shape),
fits or not, checks (holes, faces inside out, edges with more than two faces, faces with no
size) with buttons that fix them, overhangs shown red, weight and filament from the material
and infill, Measure (M), Lay flat on a face (L), Put on the bed (B), save 3MF / STL.
- **Object menu**: **add objects together, take one out of another, or keep only where they
overlap** (the last picked is kept; Ctrl+Shift+ / Ctrl+Shift-), always closed and printable;
**make copies** in a row or round in a circle (as objects or joined into one); **make it
thick** (a surface gets a back, a closed shape goes hollow); join flat faces side by side.
- **Layers panel**: **folders** (+ Folder, or Ctrl+G groups the picked ones; folders inside
folders; drag things onto them; a folder's eye and lock work on all in it; click it to pick all
of it), a **go-to button** at the end of every row bringing the camera to it, right-click for
rename, duplicate, show only this, move to a folder, delete.
- **Export** (Ctrl+E): GLB, glTF, **FBX** (Blender, Unity, Unreal, Maya; pictures inside), OBJ (+MTL), 3MF, STL (millimetres, for printing), PLY; painted
pictures and their layers go with it.
the sun (round, up, strength, colour), sky or a plain background, ground,
brightness, soft shadows, shade in corners, glow, colour; Save a picture (F12) saves just the
view, without panels or gizmos.
- **Toolbox like GIMP's**: picture buttons (hover for name and key), and *Tool options* under it.
- **Solid view** for modelling (plain grey room, studio light) and **Rendered** (real sky,
sun and shadows) — Z switches; Shift+Z wireframe; Alt+Z X-ray.
- **Shapes with settings you can change after adding** (+ in the toolbox, or Add): cube, rounded
cube, plane, circle, cylinder, tube, cone, pyramid, sphere, capsule, torus, wedge, stairs, arch;
**starter models**: table, chair, shelf, barrel, bottle, pillar, door frame, crate.
- **Draw shape** (D): click an outline on the ground, a side view's plane, or **straight on any
face of another object** → a new solid standing on it, added to it (raised), or cut into it.
- Move / Rotate / Scale (W/E/R): gizmos turn with the object (or the world's way, or — editing a
shape — out of the faces). Scale has a handle on each side; by default the other side stays put
(or the middle, the bottom, the origin); Shift scales both sides at once. Ctrl snaps.
- **Round, smooth, mirror** on any object, changeable any time: round every sharp edge, smooth it
out like clay (keeping sharp edges if you want), mirror across X/Y/Z. Bake them when you like.
- **Shape editing** (Tab or double-click): points / edges / faces (1/2/3); extrude (X), inset (I),
round edges (B), loop cut (C), cut in four, smooth, merge (M), make face, flip, separate (P).
- **Selecting**: click, box, brush or lasso; Shift+click (or Alt+click, double-click) a whole loop,
Shift+Ctrl+click an edge for the ring across it, Ctrl+click adds or takes one; more/less,
joined (L), invert, all/none.
- Join objects (Ctrl+J), reference pictures to trace over, layers (show, lock, rename).
- Undo / redo, `.fry` files, **export to glTF** (Ctrl+E), save a picture of the view (F12).
- F1 lists every key. If it ever crashes, its own crash screen offers to open it again, with the
last autosave offered back.
## Coming
Sculpting, materials (metal/rough shown in the view), scripting; animation later.
## Layout
- `engine/` — Fry3DX's engine: window, Vulkan/OpenGL renderer, UI, files.
- `src/mesh.*`, `src/tools.cpp` — polygon meshes and the edits on them (no window needed; tested).
- `src/shapes.*` — the shape library and starter models.
- `src/icons.*` — the toolbox pictures.
- `branding/` — the logo (SVG, and PNGs made from it with Inkscape).
- `src/scene.*` — objects, folders, `.fry` text; `src/package.cpp` the packed `.fry`.
- `src/export.cpp` — GLB/glTF/FBX/OBJ/3MF/STL/PLY; `src/print.cpp` — print checks.
- `src/boolean.cpp`, `src/modify.cpp` — combining shapes, thickness, joining flat faces.
- `docs/` — the website (GitHub Pages). Its text is all in `docs/content.js`.
- `src/app.cpp` — the app.

BIN
branding/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
branding/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

34
branding/logo.svg Normal file
View file

@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2a2a2f"/><stop offset="1" stop-color="#151518"/>
</linearGradient>
<linearGradient id="pan" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3b3b42"/><stop offset="1" stop-color="#1d1d21"/>
</linearGradient>
<linearGradient id="flame" x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stop-color="#ff4a1c"/><stop offset="1" stop-color="#ffc24a"/>
</linearGradient>
</defs>
<rect x="16" y="16" width="480" height="480" rx="96" fill="url(#bg)"/>
<!-- flames under the pan -->
<path d="M164 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 z" fill="url(#flame)"/>
<path d="M236 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 z" fill="url(#flame)"/>
<path d="M308 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 z" fill="url(#flame)"/>
<!-- handle -->
<path d="M372 300 L470 250" stroke="#ff7828" stroke-width="30" stroke-linecap="round"/>
<!-- the pan, seen a little from above -->
<ellipse cx="236" cy="318" rx="170" ry="72" fill="#ff7828"/>
<ellipse cx="236" cy="306" rx="150" ry="58" fill="url(#pan)"/>
<!-- the cube frying in it -->
<g stroke="#1a1a1d" stroke-width="8" stroke-linejoin="round">
<path d="M236 92 L330 140 L236 188 L142 140 Z" fill="#ffd2a0"/>
<path d="M142 140 L236 188 L236 300 L142 252 Z" fill="#ff8a3a"/>
<path d="M330 140 L236 188 L236 300 L330 252 Z" fill="#c9501a"/>
</g>
<!-- sizzle -->
<g stroke="#ffb070" stroke-width="10" stroke-linecap="round" fill="none" opacity="0.9">
<path d="M100 210 q-14 -18 0 -36 q14 -18 0 -36"/>
<path d="M378 200 q-14 -18 0 -36 q14 -18 0 -36"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

119
cmake/Dependencies.cmake Normal file
View file

@ -0,0 +1,119 @@
# Third-party code, pinned. Everything is fetched so Windows and Linux build the same way.
include(FetchContent)
set(FETCHCONTENT_QUIET ON)
set(KEEL_VK_SDK 1.4.363)
FetchContent_Declare(VulkanHeaders GIT_REPOSITORY https://github.com/KhronosGroup/Vulkan-Headers.git
GIT_TAG vulkan-sdk-${KEEL_VK_SDK}.0 GIT_SHALLOW ON SYSTEM)
FetchContent_Declare(volk GIT_REPOSITORY https://github.com/zeux/volk.git
GIT_TAG vulkan-sdk-${KEEL_VK_SDK}.0 GIT_SHALLOW ON SYSTEM)
FetchContent_Declare(vkbootstrap GIT_REPOSITORY https://github.com/charles-lunarg/vk-bootstrap.git
GIT_TAG v${KEEL_VK_SDK} GIT_SHALLOW ON SYSTEM)
FetchContent_Declare(VulkanMemoryAllocator GIT_REPOSITORY https://github.com/GPUOpen-LibrariesAndSDKs/VulkanMemoryAllocator.git
GIT_TAG v3.3.0 GIT_SHALLOW ON SYSTEM)
# Uses an installed SDL3 if there is one (Nix shell), otherwise builds it.
FetchContent_Declare(SDL3 GIT_REPOSITORY https://github.com/libsdl-org/SDL.git
GIT_TAG release-3.4.18 GIT_SHALLOW ON SYSTEM FIND_PACKAGE_ARGS CONFIG)
FetchContent_Declare(imgui GIT_REPOSITORY https://github.com/ocornut/imgui.git
GIT_TAG v1.92.9b-docking GIT_SHALLOW ON SYSTEM)
FetchContent_Declare(glm GIT_REPOSITORY https://github.com/g-truc/glm.git
GIT_TAG 1.0.3 GIT_SHALLOW ON SYSTEM)
FetchContent_Declare(zstd GIT_REPOSITORY https://github.com/facebook/zstd.git
GIT_TAG v1.5.7 GIT_SHALLOW ON SYSTEM SOURCE_SUBDIR build/cmake)
FetchContent_Declare(cgltf GIT_REPOSITORY https://github.com/jkuhlmann/cgltf.git
GIT_TAG v1.15 GIT_SHALLOW ON SYSTEM SOURCE_SUBDIR no-cmake)
FetchContent_Declare(stb GIT_REPOSITORY https://github.com/nothings/stb.git
GIT_TAG 2c980bb59875b0d32144a71867fbdebb2f77cd20 SYSTEM SOURCE_SUBDIR no-cmake)
FetchContent_Declare(miniaudio GIT_REPOSITORY https://github.com/mackron/miniaudio.git
GIT_TAG 0.11.25 GIT_SHALLOW ON SYSTEM SOURCE_SUBDIR no-cmake)
FetchContent_Declare(ogg GIT_REPOSITORY https://github.com/xiph/ogg.git
GIT_TAG v1.3.6 GIT_SHALLOW ON SYSTEM SOURCE_SUBDIR no-cmake)
FetchContent_Declare(opus GIT_REPOSITORY https://github.com/xiph/opus.git
GIT_TAG v1.6.1 GIT_SHALLOW ON SYSTEM)
FetchContent_Declare(opusfile GIT_REPOSITORY https://github.com/xiph/opusfile.git
GIT_TAG v0.12 GIT_SHALLOW ON SYSTEM SOURCE_SUBDIR no-cmake)
# DejaVu Sans (Bitstream Vera licence): the UI font, close to Source's Verdana look.
FetchContent_Declare(dejavu URL https://github.com/dejavu-fonts/dejavu-fonts/releases/download/version_2_37/dejavu-fonts-ttf-2.37.zip
URL_HASH SHA256=7576310b219e04159d35ff61dd4a4ec4cdba4f35c00e002a136f00e96a908b0a
DOWNLOAD_EXTRACT_TIMESTAMP ON SOURCE_SUBDIR no-cmake)
# KEEL_YAPF_DIR=/path/to/YAPF uses a local checkout instead.
FetchContent_Declare(yapf GIT_REPOSITORY https://github.com/Nexoniarz/yapf.git
GIT_TAG 06a4e7db9e7e6d90132473e5606f0e6f5b1d6001 SYSTEM) # YAPF 2 (+ yapf_tex_to_rgba8: any surface as RGBA8)
if(KEEL_YAPF_DIR)
set(FETCHCONTENT_SOURCE_DIR_YAPF ${KEEL_YAPF_DIR})
endif()
set(VOLK_PULL_IN_VULKAN OFF)
set(SDL_SHARED ON)
set(SDL_STATIC OFF)
set(GLM_BUILD_TESTS OFF)
set(ZSTD_BUILD_PROGRAMS OFF)
set(ZSTD_BUILD_SHARED OFF)
set(ZSTD_BUILD_TESTS OFF)
set(ZSTD_LEGACY_SUPPORT OFF)
if(ANDROID)
set(VK_BOOTSTRAP_POSITION_INDEPENDENT_CODE ON CACHE BOOL "" FORCE) # (it sets its own, off unless shared)
endif()
FetchContent_MakeAvailable(VulkanHeaders volk vkbootstrap VulkanMemoryAllocator SDL3 glm zstd cgltf stb)
# zstd's seekable format (contrib): a normal .zst that also allows random access. YMP packs use it.
add_library(zstd_seekable STATIC
${zstd_SOURCE_DIR}/contrib/seekable_format/zstdseek_compress.c
${zstd_SOURCE_DIR}/contrib/seekable_format/zstdseek_decompress.c)
target_include_directories(zstd_seekable SYSTEM PUBLIC ${zstd_SOURCE_DIR}/contrib/seekable_format ${zstd_SOURCE_DIR}/lib)
target_include_directories(zstd_seekable PRIVATE ${zstd_SOURCE_DIR}/lib/common)
target_link_libraries(zstd_seekable PUBLIC libzstd_static)
add_library(cgltf INTERFACE)
target_include_directories(cgltf SYSTEM INTERFACE ${cgltf_SOURCE_DIR})
add_library(stb INTERFACE)
target_include_directories(stb SYSTEM INTERFACE ${stb_SOURCE_DIR})
target_link_libraries(volk PUBLIC Vulkan::Headers)
# Dear ImGui and YAPF have no CMake of their own; build them here.
FetchContent_MakeAvailable(imgui yapf)
add_library(imgui STATIC
${imgui_SOURCE_DIR}/imgui.cpp ${imgui_SOURCE_DIR}/imgui_draw.cpp
${imgui_SOURCE_DIR}/imgui_tables.cpp ${imgui_SOURCE_DIR}/imgui_widgets.cpp
${imgui_SOURCE_DIR}/imgui_demo.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_sdl3.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_vulkan.cpp
${imgui_SOURCE_DIR}/backends/imgui_impl_opengl3.cpp)
target_include_directories(imgui SYSTEM PUBLIC ${imgui_SOURCE_DIR} ${imgui_SOURCE_DIR}/backends)
target_compile_definitions(imgui PUBLIC IMGUI_IMPL_VULKAN_USE_VOLK)
target_link_libraries(imgui PUBLIC volk SDL3::SDL3)
if(ANDROID) # its OpenGL backend calls GLES directly there
target_link_libraries(imgui PUBLIC GLESv3 EGL)
endif()
add_library(yapf STATIC ${yapf_SOURCE_DIR}/yapf.c)
target_include_directories(yapf SYSTEM PUBLIC ${yapf_SOURCE_DIR})
find_package(Threads REQUIRED)
target_link_libraries(yapf PRIVATE Threads::Threads $<$<NOT:$<PLATFORM_ID:Windows>>:m>)
if(NOT MSVC)
target_compile_options(yapf PRIVATE -O2) # its codecs and the BC7 encoder, even in debug builds
endif()
# Audio: miniaudio for output; Ogg Opus decoding (opusfile).
set(OPUS_BUILD_TESTING OFF)
set(OPUS_BUILD_PROGRAMS OFF)
set(OPUS_INSTALL_PKG_CONFIG_MODULE OFF)
set(OPUS_INSTALL_CMAKE_CONFIG_MODULE OFF)
FetchContent_MakeAvailable(miniaudio ogg opus opusfile dejavu)
file(WRITE ${CMAKE_BINARY_DIR}/ogg-config/ogg/config_types.h
"#pragma once\n#include <stdint.h>\ntypedef int16_t ogg_int16_t;\ntypedef uint16_t ogg_uint16_t;\ntypedef int32_t ogg_int32_t;\ntypedef uint32_t ogg_uint32_t;\ntypedef int64_t ogg_int64_t;\ntypedef uint64_t ogg_uint64_t;\n")
add_library(ogg STATIC ${ogg_SOURCE_DIR}/src/bitwise.c ${ogg_SOURCE_DIR}/src/framing.c)
target_include_directories(ogg SYSTEM PUBLIC ${ogg_SOURCE_DIR}/include ${CMAKE_BINARY_DIR}/ogg-config)
add_library(opusfile STATIC ${opusfile_SOURCE_DIR}/src/info.c ${opusfile_SOURCE_DIR}/src/internal.c
${opusfile_SOURCE_DIR}/src/opusfile.c ${opusfile_SOURCE_DIR}/src/stream.c)
target_include_directories(opusfile SYSTEM PUBLIC ${opusfile_SOURCE_DIR}/include)
target_compile_definitions(opusfile PRIVATE OP_DISABLE_HTTP OP_DISABLE_DOCS)
target_link_libraries(opusfile PUBLIC ogg Opus::opus)
add_library(miniaudio STATIC ${miniaudio_SOURCE_DIR}/miniaudio.c)
target_include_directories(miniaudio SYSTEM PUBLIC ${miniaudio_SOURCE_DIR})
target_link_libraries(miniaudio PUBLIC ${CMAKE_DL_LIBS} Threads::Threads)
if(UNIX AND NOT APPLE)
target_link_libraries(miniaudio PUBLIC m)
endif()

22
cmake/EmbedText.cmake Normal file
View file

@ -0,0 +1,22 @@
# cmake -DOUT=file.inc -DFILES="a;b" -P EmbedText.cmake
# Writes { "name", "text" }, entries as C++ raw string literals, split into chunks so no single
# literal is too long for MSVC.
string(REPLACE "|" ";" FILES "${FILES}")
set(result "")
foreach(f ${FILES})
get_filename_component(name ${f} NAME)
file(READ ${f} text)
string(LENGTH "${text}" len)
set(literal "")
set(pos 0)
while(pos LESS len)
string(SUBSTRING "${text}" ${pos} 4000 chunk)
string(APPEND literal "R\"keel(${chunk})keel\"\n")
math(EXPR pos "${pos} + 4000")
endwhile()
if(literal STREQUAL "")
set(literal "\"\"")
endif()
string(APPEND result "{\"${name}\",\n${literal}},\n")
endforeach()
file(WRITE ${OUT} "${result}")

29
cmake/Shaders.cmake Normal file
View file

@ -0,0 +1,29 @@
# keel_add_shaders(target file...) compiles GLSL to SPIR-V with glslc (KEEL_VULKAN defined) and
# embeds it: `static const uint32_t kSpv[] =\n#include "name.vert.spv.inc"\n;`
# It also embeds every shader's source text (and the shared headers) for the OpenGL backend,
# which compiles them at runtime with KEEL_GL defined: shaders/gl_sources.inc, a list of
# { "name", "source" } entries.
find_program(GLSLC glslc HINTS $ENV{VULKAN_SDK}/bin REQUIRED)
function(keel_add_shaders target)
set(out_dir ${CMAKE_CURRENT_BINARY_DIR}/shaders)
file(GLOB headers ${CMAKE_CURRENT_SOURCE_DIR}/shaders/*.glsl)
foreach(src ${ARGN})
get_filename_component(name ${src} NAME)
set(out ${out_dir}/${name}.spv.inc)
add_custom_command(OUTPUT ${out}
COMMAND ${CMAKE_COMMAND} -E make_directory ${out_dir}
COMMAND ${GLSLC} --target-env=vulkan1.3 -DKEEL_VULKAN -O -mfmt=c -I ${CMAKE_CURRENT_SOURCE_DIR}/shaders -o ${out} ${CMAKE_CURRENT_SOURCE_DIR}/${src}
DEPENDS ${src} ${headers} VERBATIM)
list(APPEND outs ${out})
list(APPEND sources ${CMAKE_CURRENT_SOURCE_DIR}/${src})
endforeach()
set(gl_out ${out_dir}/gl_sources.inc)
string(REPLACE ";" "|" embed_list "${sources};${headers}") # a list can't pass through COMMAND as one argument
add_custom_command(OUTPUT ${gl_out}
COMMAND ${CMAKE_COMMAND} -E make_directory ${out_dir}
COMMAND ${CMAKE_COMMAND} -DOUT=${gl_out} -DFILES=${embed_list} -P ${PROJECT_SOURCE_DIR}/cmake/EmbedText.cmake
DEPENDS ${sources} ${headers} ${PROJECT_SOURCE_DIR}/cmake/EmbedText.cmake VERBATIM)
target_sources(${target} PRIVATE ${outs} ${gl_out})
target_include_directories(${target} PRIVATE ${out_dir})
endfunction()

20
cmake/Version.cmake Normal file
View file

@ -0,0 +1,20 @@
# Run at build time (cmake -P): writes keel_version.h from git — the commit, its date, and
# whether the tree had changes. Not __DATE__: Nix's build shell pins SOURCE_DATE_EPOCH to 1980
# (reproducible builds), and that's what the compiler's date said.
execute_process(COMMAND git -C ${SRC} log -1 --format=%h OUTPUT_VARIABLE commit OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
execute_process(COMMAND git -C ${SRC} log -1 --format=%cd --date=format:%Y-%m-%d\ %H:%M OUTPUT_VARIABLE date OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
execute_process(COMMAND git -C ${SRC} status --porcelain --untracked-files=no OUTPUT_VARIABLE dirty OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
if(NOT commit)
set(commit "unknown")
set(date "unknown")
endif()
if(dirty)
set(commit "${commit}+changes")
endif()
set(text "#pragma once\n#define KEEL_COMMIT \"${commit}\"\n#define KEEL_COMMIT_DATE \"${date}\"\n")
if(EXISTS ${OUT})
file(READ ${OUT} old)
endif()
if(NOT "${old}" STREQUAL "${text}")
file(WRITE ${OUT} "${text}")
endif()

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,3 @@
// Fry3DX: no game keys (the app reads its own). Your settings are saved after this.
unbindall
bind f8 devmenu

View file

@ -0,0 +1,4 @@
skies/day.hdr: "Kloofendal 48d Partly Cloudy (Pure Sky)" by Greg Zaal and Jarod Guest, Poly Haven, CC0 1.0 (public domain)
https://polyhaven.com/a/kloofendal_48d_partly_cloudy_puresky
skies/afternoon.hdr: "Qwantani Afternoon (Pure Sky)" by Greg Zaal and Jarod Guest, Poly Haven, CC0 1.0 (public domain)
https://polyhaven.com/a/qwantani_afternoon_puresky

BIN
content/keel/skies/day.ksky Normal file

Binary file not shown.

77
docs/content.js Normal file
View file

@ -0,0 +1,77 @@
// Everything the page says. To update the site, change this file only:
// add a feature to `features`, a picture to `shots`, a version to `versions` (newest first).
window.FRY = {
name: "Fry3DX",
long: "Frying 3D Extreme",
author: "Nexoniarz",
authorUrl: "https://github.com/Nexoniarz",
repo: "https://github.com/Nexoniarz/Fry3DX",
tagline: "A 3D modeller that does what you'd expect.",
intro: "Pick a tool, click, drag. Plain words instead of jargon, a toolbox like GIMP's, and the useful stuff from the big modellers without the hundred menus around it.",
why: {
title: "Why does this exist?",
text: [
"Because the well-known modellers suck at being easy. You open them and get a wall of panels, shortcuts nobody would ever guess, and modes inside modes. Making a box with a hole for a screw shouldn't need a tutorial.",
"So Nexoniarz made one that's built around how people actually think: pick a tool, click on the thing, drag. Every button says what it does, every tool has a tooltip, and the clever parts (rounding, mirroring, combining, checking a print) are one click away.",
],
},
features: [
{ icon: "▣", title: "Shapes that stay editable", text: "Cube, cylinder, sphere, torus, stairs, arch and more, plus starter models like a table, chair or barrel. Change their sizes any time in Properties." },
{ icon: "✎", title: "Draw a shape anywhere", text: "Click an outline on the ground or straight on the face of another object: it becomes a new part, gets added to it, or cuts into it." },
{ icon: "⊕", title: "Add, subtract, overlap", text: "Combine objects: add them together, take one out of another, or keep only where they overlap. The result is always closed and ready to print." },
{ icon: "◫", title: "Real shape editing", text: "Points, edges and faces. Extrude, inset, round edges, loop cut, knife, merge. Shift+click picks a whole loop, Shift+Ctrl+click a ring." },
{ icon: "◎", title: "Holes for screws and nuts", text: "Round, rectangle, triangle or hexagon. M2 to M8 screw and nut sizes ready, with a little room added so the print actually fits." },
{ icon: "▤", title: "Copies and thickness", text: "Copies in a row or round in a circle. Make a surface thick, or a solid hollow with walls exactly as thick as you need." },
{ icon: "🖌", title: "Paint and stickers", text: "Paint on the model or on its flat picture. Paste any picture (Ctrl+V) and it goes on like a sticker that bends over edges and corners." },
{ icon: "▦", title: "Layout tab", text: "Move, turn and size the flat frames of the surface by hand. The paint follows, so the model looks the same." },
{ icon: "⎙", title: "Made for 3D printing", text: "Your printer's plate in the view, sizes in millimetres, checks for holes and flipped faces with fix buttons, overhangs shown red, weight and filament." },
{ icon: "☀", title: "See it rendered", text: "Solid view for modelling, a real renderer with sky, sun and soft shadows when you want to see it properly. Save a picture with F12." },
{ icon: "🗀", title: "Folders and groups", text: "Layers with folders inside folders, drag and drop, show/hide and lock whole groups, and a button on every row that brings the camera to it." },
{ icon: "💾", title: "Never lose work", text: "Its own packed .fry file, autosave in the background, recovery after a crash, and a Save / Don't save / Cancel when closing." },
],
shots: [
{ img: "img/model.webp", title: "Model", text: "Starter models, a folder of parts, a shape drawn onto a cube's face, rendered with sky and shadows." },
{ img: "img/paint.webp", title: "Paint", text: "A picture pasted on as a sticker: it bends over the cube's edge like a real one." },
{ img: "img/print.webp", title: "Print", text: "The printer's plate, sizes in millimetres, checks and overhangs before you slice." },
{ img: "img/combine.webp", title: "Combine", text: "A cylinder taken out of a cube, copies in a row in a folder, a plane made thick." },
{ img: "img/crash.webp", title: "Even the crash screen", text: "If something does get burnt, your work comes back from the last autosave." },
],
formats: {
open: [".fry"],
save: [".fry (packed, with pictures)"],
export: ["GLB", "glTF", "FBX", "OBJ + MTL", "3MF", "STL", "PLY"],
note: "FBX opens in Blender, Unity, Unreal and Maya. 3MF and STL are in millimetres for slicers (PrusaSlicer, Bambu Studio, Cura, OrcaSlicer).",
},
keys: [
["Q W E R", "Select, move, turn, scale"],
["Tab", "Edit an object's shape"],
["D", "Draw a shape"],
["O", "Make a hole"],
["Ctrl+G", "Group into a folder"],
["Ctrl+Shift + / −", "Add together / take out"],
["Ctrl+1 … 5", "Model, Paint, Layout, Render, Print"],
["F1", "Every key, in the app"],
],
build: {
text: "Fry3DX builds from source on Linux with CMake. Everything else it needs is downloaded while building. On NixOS, shell.nix has the tools.",
steps: [
"git clone https://github.com/Nexoniarz/Fry3DX.git",
"cd Fry3DX",
"nix-shell --run 'cmake --preset release && cmake --build build/release'",
"build/release/bin/fry3dx",
],
needs: "Needs: a Vulkan graphics card, CMake, Ninja, a C++20 compiler, glslc (shaderc) and SDL3.",
},
versions: [
{ v: "0.4", date: "2026-10-19", items: ["Combine objects: add, subtract, overlap", "Draw shapes onto other objects", "Copies in a row or circle, thicken and hollow", "FBX export", "Folders and groups in Layers, go-to buttons", "Shift scales both sides, Shift+click loops and rings", "A crash screen of its own"] },
{ v: "0.3", date: "2026-10-19", items: ["Stickers that bend over edges, picture layers", "Packed .fry files, autosave and recovery", "Layout tab, Print tab, 3MF / STL / OBJ / PLY / GLB export", "Hole tool: hexagon nut pockets, rectangles, outlines"] },
{ v: "0.1", date: "2026-10-19", items: ["First version: shapes, shape editing, knife, painting, rendering"] },
],
};

BIN
docs/img/combine.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
docs/img/crash.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
docs/img/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

34
docs/img/logo.svg Normal file
View file

@ -0,0 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#2a2a2f"/><stop offset="1" stop-color="#151518"/>
</linearGradient>
<linearGradient id="pan" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3b3b42"/><stop offset="1" stop-color="#1d1d21"/>
</linearGradient>
<linearGradient id="flame" x1="0" y1="1" x2="0" y2="0">
<stop offset="0" stop-color="#ff4a1c"/><stop offset="1" stop-color="#ffc24a"/>
</linearGradient>
</defs>
<rect x="16" y="16" width="480" height="480" rx="96" fill="url(#bg)"/>
<!-- flames under the pan -->
<path d="M164 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 z" fill="url(#flame)"/>
<path d="M236 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 z" fill="url(#flame)"/>
<path d="M308 462 c -34 -14 -30 -60 0 -96 c 30 36 34 82 0 96 z" fill="url(#flame)"/>
<!-- handle -->
<path d="M372 300 L470 250" stroke="#ff7828" stroke-width="30" stroke-linecap="round"/>
<!-- the pan, seen a little from above -->
<ellipse cx="236" cy="318" rx="170" ry="72" fill="#ff7828"/>
<ellipse cx="236" cy="306" rx="150" ry="58" fill="url(#pan)"/>
<!-- the cube frying in it -->
<g stroke="#1a1a1d" stroke-width="8" stroke-linejoin="round">
<path d="M236 92 L330 140 L236 188 L142 140 Z" fill="#ffd2a0"/>
<path d="M142 140 L236 188 L236 300 L142 252 Z" fill="#ff8a3a"/>
<path d="M330 140 L236 188 L236 300 L330 252 Z" fill="#c9501a"/>
</g>
<!-- sizzle -->
<g stroke="#ffb070" stroke-width="10" stroke-linecap="round" fill="none" opacity="0.9">
<path d="M100 210 q-14 -18 0 -36 q14 -18 0 -36"/>
<path d="M378 200 q-14 -18 0 -36 q14 -18 0 -36"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
docs/img/model.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
docs/img/paint.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
docs/img/print.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

86
docs/index.html Normal file
View file

@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fry3DX</title>
<meta name="description" content="Fry3DX (Frying 3D Extreme): a 3D modeller that does what you'd expect. Made by Nexoniarz.">
<meta property="og:title" content="Fry3DX">
<meta property="og:description" content="A 3D modeller that does what you'd expect. Pick a tool, click, drag.">
<meta property="og:image" content="img/model.webp">
<link rel="icon" href="img/icon.png">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- The page is built from content.js by site.js: edit content.js to update it. -->
<header class="top">
<a class="brand" href="#"><img src="img/logo.svg" alt=""><span>Fry3DX</span></a>
<nav>
<a href="#features">Features</a>
<a href="#look">Look</a>
<a href="#get">Get it</a>
<a href="#versions">Versions</a>
<a class="gh" id="gh-top" href="#">GitHub</a>
</nav>
</header>
<main>
<section class="hero">
<div class="hero-text">
<p class="kicker" id="long"></p>
<h1 id="tagline"></h1>
<p class="lead" id="intro"></p>
<div class="buttons">
<a class="btn hot" href="#get">Get Fry3DX</a>
<a class="btn" id="gh-hero" href="#">Source on GitHub</a>
</div>
</div>
<figure class="hero-shot"><img src="img/model.webp" alt="Fry3DX with a table, chair, barrel and crate"></figure>
</section>
<section class="why" id="why"></section>
<section id="features">
<h2>What it does</h2>
<div class="grid" id="feature-grid"></div>
</section>
<section id="look">
<h2>How it looks</h2>
<div class="tabs" id="shot-tabs" role="tablist"></div>
<figure class="shot"><img id="shot-img" alt=""><figcaption id="shot-text"></figcaption></figure>
</section>
<section class="two">
<div>
<h2>Files</h2>
<div id="formats"></div>
</div>
<div>
<h2>Keys that make sense</h2>
<table class="keys" id="keys"></table>
</div>
</section>
<section id="get">
<h2>Get it</h2>
<p id="build-text"></p>
<div class="code"><button class="copy" id="copy">Copy</button><pre id="build-steps"></pre></div>
<p class="muted" id="build-needs"></p>
</section>
<section id="versions">
<h2>Versions</h2>
<div id="version-list"></div>
</section>
</main>
<footer>
<p>Fry3DX, made by <a id="author" href="#"></a>.</p>
<p class="muted">Sky pictures from Poly Haven (CC0).</p>
</footer>
<script src="content.js"></script>
<script src="site.js"></script>
</body>
</html>

91
docs/site.js Normal file
View file

@ -0,0 +1,91 @@
// Builds the page from window.FRY (content.js). Nothing here needs changing for a content update.
(function () {
const F = window.FRY;
const $ = (id) => document.getElementById(id);
const el = (tag, cls, text) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
};
$("long").textContent = F.long;
$("tagline").textContent = F.tagline;
$("intro").textContent = F.intro;
for (const id of ["gh-top", "gh-hero"]) $(id).href = F.repo;
$("author").textContent = F.author;
$("author").href = F.authorUrl;
const why = $("why");
why.append(el("h2", null, F.why.title));
for (const p of F.why.text) why.append(el("p", null, p));
const grid = $("feature-grid");
for (const f of F.features) {
const card = el("article", "card");
card.append(el("div", "icon", f.icon), el("h3", null, f.title), el("p", null, f.text));
grid.append(card);
}
// Screenshots: a tab each.
const tabs = $("shot-tabs");
const show = (i) => {
const s = F.shots[i];
$("shot-img").src = s.img;
$("shot-img").alt = s.title;
$("shot-text").textContent = s.text;
[...tabs.children].forEach((b, j) => b.setAttribute("aria-selected", i === j));
};
F.shots.forEach((s, i) => {
const b = el("button", "tab", s.title);
b.setAttribute("role", "tab");
b.onclick = () => show(i);
tabs.append(b);
new Image().src = s.img; // (loaded ahead, so switching is instant)
});
show(0);
const fm = $("formats");
const row = (label, items) => {
const d = el("div", "fmt");
d.append(el("span", "fmt-label", label));
for (const x of items) d.append(el("span", "chip", x));
fm.append(d);
};
row("Save", F.formats.save);
row("Export", F.formats.export);
fm.append(el("p", "muted", F.formats.note));
const keys = $("keys");
for (const [k, what] of F.keys) {
const tr = el("tr");
const td = el("td");
td.append(el("kbd", null, k));
tr.append(td, el("td", null, what));
keys.append(tr);
}
$("build-text").textContent = F.build.text;
$("build-steps").textContent = F.build.steps.join("\n");
$("build-needs").textContent = F.build.needs;
$("copy").onclick = async () => {
try {
await navigator.clipboard.writeText(F.build.steps.join("\n"));
$("copy").textContent = "Copied";
} catch {
$("copy").textContent = "Select and copy";
}
setTimeout(() => ($("copy").textContent = "Copy"), 1800);
};
const vl = $("version-list");
for (const v of F.versions) {
const d = el("div", "version");
const h = el("h3");
h.append(el("span", "v", "v" + v.v), el("span", "muted", " " + v.date));
const ul = el("ul");
for (const it of v.items) ul.append(el("li", null, it));
d.append(h, ul);
vl.append(d);
}
})();

103
docs/style.css Normal file
View file

@ -0,0 +1,103 @@
:root {
--bg: #161618;
--panel: #1f1f23;
--panel2: #26262b;
--line: #34343b;
--ink: #ecebe6;
--dim: #9c9a94;
--hot: #ff7828;
--hot2: #ffc24a;
--radius: 10px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
a { color: var(--hot); text-decoration: none; }
a:hover { text-decoration: underline; }
img { max-width: 100%; display: block; }
h1, h2, h3 { line-height: 1.2; margin: 0 0 .6em; }
h2 { font-size: 1.7rem; }
h2::after { content: ""; display: block; width: 48px; height: 4px; margin-top: .4em; border-radius: 2px; background: linear-gradient(90deg, var(--hot), var(--hot2)); }
.muted { color: var(--dim); }
.top {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 10px max(16px, calc((100% - 1120px) / 2));
background: rgba(22, 22, 24, .9); backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 10px; color: var(--ink); font-weight: 700; font-size: 1.15rem; }
.brand img { width: 32px; height: 32px; }
.brand:hover { text-decoration: none; }
nav { display: flex; gap: 18px; flex-wrap: wrap; justify-content: flex-end; }
nav a { color: var(--dim); font-size: .95rem; }
nav a:hover { color: var(--ink); text-decoration: none; }
nav .gh { color: var(--hot); }
main { max-width: 1120px; margin: 0 auto; padding: 0 16px; }
section { padding: 56px 0; border-bottom: 1px solid var(--line); }
section:last-child { border-bottom: 0; }
.hero { display: grid; grid-template-columns: 1fr 1.25fr; gap: 40px; align-items: center; padding-top: 64px; }
.kicker { color: var(--hot); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; font-size: .85rem; margin: 0 0 .6em; }
.hero h1 { font-size: clamp(2rem, 4.5vw, 3.2rem); }
.lead { color: var(--dim); font-size: 1.1rem; }
.buttons { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 24px; }
.btn { display: inline-block; padding: 12px 20px; border-radius: var(--radius); background: var(--panel2); color: var(--ink); font-weight: 600; border: 1px solid var(--line); }
.btn:hover { text-decoration: none; border-color: var(--hot); }
.btn.hot { background: linear-gradient(135deg, var(--hot), #ff4a1c); border-color: transparent; color: #fff; }
.hero-shot { margin: 0; }
.hero-shot img, .shot img { border-radius: var(--radius); border: 1px solid var(--line); box-shadow: 0 20px 60px rgba(0, 0, 0, .45); }
.why { max-width: 760px; }
.why p { font-size: 1.08rem; color: #cfcdc7; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; transition: border-color .15s, transform .15s; }
.card:hover { border-color: var(--hot); transform: translateY(-2px); }
.card .icon { font-size: 1.5rem; color: var(--hot); margin-bottom: 6px; }
.card h3 { font-size: 1.05rem; }
.card p { margin: 0; color: var(--dim); font-size: .95rem; }
.tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.tab { font: inherit; color: var(--dim); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: 6px 16px; cursor: pointer; }
.tab:hover { color: var(--ink); }
.tab[aria-selected="true"] { color: #fff; background: var(--hot); border-color: var(--hot); }
.shot { margin: 0; }
.shot figcaption { color: var(--dim); margin-top: 12px; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
.fmt { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; }
.fmt-label { width: 64px; color: var(--dim); }
.chip { background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 2px 10px; font-size: .9rem; }
.keys { border-collapse: collapse; width: 100%; }
.keys td { padding: 7px 0; border-bottom: 1px solid var(--line); }
.keys td:first-child { width: 45%; }
kbd { font: .85rem ui-monospace, "DejaVu Sans Mono", monospace; background: var(--panel2); border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px; padding: 2px 7px; }
.code { position: relative; background: #0f0f11; border: 1px solid var(--line); border-left: 3px solid var(--hot); border-radius: var(--radius); }
.code pre { margin: 0; padding: 18px 20px; overflow-x: auto; font: .92rem/1.7 ui-monospace, "DejaVu Sans Mono", monospace; color: #d6d4cd; }
.copy { position: absolute; top: 10px; right: 10px; font: inherit; font-size: .85rem; color: var(--ink); background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 4px 12px; cursor: pointer; }
.copy:hover { border-color: var(--hot); }
.version { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px 22px; margin-bottom: 12px; }
.version h3 { margin-bottom: .3em; }
.version .v { color: var(--hot); }
.version ul { margin: 0; padding-left: 20px; color: #cfcdc7; }
footer { text-align: center; padding: 40px 16px; border-top: 1px solid var(--line); color: var(--dim); }
footer p { margin: 4px 0; }
@media (max-width: 820px) {
.hero, .two { grid-template-columns: 1fr; }
.hero { padding-top: 32px; gap: 28px; }
nav a:not(.gh) { display: none; }
section { padding: 40px 0; }
}

29
engine/CMakeLists.txt Normal file
View file

@ -0,0 +1,29 @@
# Fry3DX's engine, what a desktop tool needs: window, input, Vulkan/OpenGL renderer, ImGui, console and cvars, files,
# audio. No networking, physics, scripts or game framework.
add_library(keel STATIC
core/log.cpp core/cvar.cpp core/console.cpp core/sysinfo.cpp
fs/ymp.cpp fs/vfs.cpp
audio/audio.cpp
platform/window.cpp platform/input.cpp platform/touch.cpp
rhi/vulkan/vk_device.cpp rhi/vulkan/vk_backend.cpp
rhi/gl/gl_loader.cpp rhi/gl/gl_backend.cpp rhi/gl/gl_legacy.cpp
render/renderer.cpp render/resources.cpp render/clusters.cpp render/screenshot.cpp
ui/ui.cpp
engine.cpp crash.cpp)
target_include_directories(keel PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
add_custom_target(keel_version COMMAND ${CMAKE_COMMAND} -DSRC=${PROJECT_SOURCE_DIR} -DOUT=${CMAKE_BINARY_DIR}/generated/keel_version.h
-P ${PROJECT_SOURCE_DIR}/cmake/Version.cmake BYPRODUCTS ${CMAKE_BINARY_DIR}/generated/keel_version.h VERBATIM)
add_dependencies(keel keel_version)
target_include_directories(keel PRIVATE ${CMAKE_BINARY_DIR}/generated)
target_link_libraries(keel PRIVATE stb miniaudio opusfile)
target_link_libraries(keel PUBLIC zstd_seekable glm::glm yapf SDL3::SDL3 volk vk-bootstrap::vk-bootstrap GPUOpen::VulkanMemoryAllocator imgui)
target_compile_definitions(keel PUBLIC GLM_FORCE_DEPTH_ZERO_TO_ONE GLM_ENABLE_EXPERIMENTAL VK_NO_PROTOTYPES)
keel_add_shaders(keel shaders/box.vert shaders/box.frag shaders/mesh.vert shaders/mesh.frag shaders/depth.frag shaders/cull.comp shaders/froxel.comp shaders/froxel_rt.comp shaders/gi.comp shaders/skinned.vert
shaders/fullscreen.vert shaders/tonemap.frag shaders/sky.vert shaders/sky.frag shaders/smoke_sky.frag shaders/hzb.frag
shaders/ssao.frag shaders/ssao_blur.frag shaders/bloom_down.frag shaders/bloom_up.frag shaders/taa.frag shaders/ssr.frag shaders/adapt.frag shaders/volumetric.frag shaders/sprite.vert shaders/sprite.frag)
if(MSVC)
target_compile_options(keel PRIVATE /W4)
else()
target_compile_options(keel PRIVATE -Wall -Wextra -Wno-missing-field-initializers)
endif()
target_precompile_headers(keel PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/core/pch.h)

177
engine/asset/kmap.h Normal file
View file

@ -0,0 +1,177 @@
#pragma once
// KMAP: a compiled map, written by assetc from a TrenchBroom .map file.
// KmapHeader
// render model: a complete KMDL blob (renderModelSize bytes)
// KmapBrush × brushCount, then glm-compatible float[3] × pointCount
// entity text (entitySize bytes), Quake style: { "key" "value" ... } ...
// light probes: float × 19 per probe (ambient cube: 6 × rgb for +X -X +Y -Y +Z -Z, then sun visibility)
// material names, '\n'-separated (materialTableSize bytes): what KmapBrush::material indexes
#include "asset/kmdl.h"
#include <glm/glm.hpp>
#include <map>
#include <string>
namespace keel {
inline constexpr char kKmapMagic[4] = {'K', 'M', 'A', 'P'};
inline constexpr uint32_t kKmapVersion = 5;
enum BrushContents : uint32_t {
CONTENTS_SOLID = 1 << 0,
CONTENTS_PLAYERCLIP = 1 << 1, // blocks players only (tools/clip)
CONTENTS_TRIGGER = 1 << 2, // not solid; reports players inside (trigger_* entities)
};
inline constexpr uint32_t kWorldEntity = 0xFFFFFFFF;
struct KmapHeader {
char magic[4];
uint32_t version;
uint32_t renderModelSize;
uint32_t brushCount, pointCount;
uint32_t entitySize;
float probeOrigin[3];
float probeSpacing;
uint32_t probeDims[3];
uint32_t materialTableSize;
};
inline constexpr int kProbeFloats = 19;
// Baked lighting for moving things: a grid of ambient cubes (like Source).
struct KmapProbes {
glm::vec3 origin{0.0f};
float spacing = 0.0f;
glm::uvec3 dims{0u};
std::vector<float> data; // kProbeFloats per probe, x fastest
};
struct KmapBrush {
uint32_t firstPoint, pointCount;
uint32_t contents;
uint32_t entity; // index into entities, or kWorldEntity
// Texture of its top, sides and bottom (into KmapData::materials): what you walk on,
// run into, bump your head on. Sounds and effects look up the surface from it.
uint16_t material[3];
uint16_t pad;
};
enum BrushFace { FACE_TOP = 0, FACE_SIDE = 1, FACE_BOTTOM = 2 };
using EntityKeys = std::map<std::string, std::string>;
struct KmapData {
KmdlModel render;
KmapProbes probes;
std::vector<KmapBrush> brushes;
std::vector<std::string> materials;
std::vector<float> points; // xyz triples
std::vector<EntityKeys> entities;
};
// Parses `{ "k" "v" }` blocks. Shared by the compiler (for .map entity keys) and loader.
inline std::vector<EntityKeys> parseEntityText(const std::string& text) {
std::vector<EntityKeys> out;
size_t i = 0;
auto quoted = [&](std::string& s) {
while (i < text.size() && text[i] != '"' && text[i] != '}') ++i;
if (i >= text.size() || text[i] == '}') return false;
const size_t end = text.find('"', i + 1);
if (end == std::string::npos) return false;
s = text.substr(i + 1, end - i - 1);
i = end + 1;
return true;
};
while ((i = text.find('{', i)) != std::string::npos) {
++i;
EntityKeys e;
std::string k, v;
while (quoted(k) && quoted(v)) e[k] = v;
out.push_back(std::move(e));
i = text.find('}', i);
if (i == std::string::npos) break;
}
return out;
}
inline std::string entityText(const std::vector<EntityKeys>& entities) {
std::string out;
for (const auto& e : entities) {
out += "{\n";
for (const auto& [k, v] : e) out += "\"" + k + "\" \"" + v + "\"\n";
out += "}\n";
}
return out;
}
inline std::vector<uint8_t> writeKmap(const KmapData& m) {
const std::vector<uint8_t> render = writeKmdl(m.render);
const std::string ents = entityText(m.entities);
KmapHeader h{};
std::memcpy(h.magic, kKmapMagic, 4);
h.version = kKmapVersion;
h.renderModelSize = uint32_t(render.size());
h.brushCount = uint32_t(m.brushes.size());
h.pointCount = uint32_t(m.points.size() / 3);
h.entitySize = uint32_t(ents.size());
std::memcpy(h.probeOrigin, &m.probes.origin, 12);
h.probeSpacing = m.probes.spacing;
std::memcpy(h.probeDims, &m.probes.dims, 12);
std::string table;
for (const std::string& name : m.materials) table += name + "\n";
h.materialTableSize = uint32_t(table.size());
std::vector<uint8_t> out;
auto put = [&](const void* p, size_t n) { out.insert(out.end(), (const uint8_t*)p, (const uint8_t*)p + n); };
put(&h, sizeof h);
put(render.data(), render.size());
put(m.brushes.data(), m.brushes.size() * sizeof(KmapBrush));
put(m.points.data(), m.points.size() * sizeof(float));
put(ents.data(), ents.size());
put(m.probes.data.data(), m.probes.data.size() * sizeof(float));
put(table.data(), table.size());
return out;
}
inline std::optional<KmapData> readKmap(const std::vector<uint8_t>& data) {
KmapHeader h;
if (data.size() < sizeof h) return std::nullopt;
std::memcpy(&h, data.data(), sizeof h);
if (std::memcmp(h.magic, kKmapMagic, 4) != 0 || h.version != kKmapVersion) return std::nullopt;
const uint64_t need = sizeof h + uint64_t(h.renderModelSize) + uint64_t(h.brushCount) * sizeof(KmapBrush) +
uint64_t(h.pointCount) * 12 + h.entitySize;
if (data.size() < need) return std::nullopt;
KmapData m;
size_t pos = sizeof h;
auto render = readKmdl(std::vector<uint8_t>(data.begin() + pos, data.begin() + pos + h.renderModelSize));
if (h.renderModelSize && !render) return std::nullopt;
if (render) m.render = std::move(*render);
pos += h.renderModelSize;
m.brushes.resize(h.brushCount);
std::memcpy(m.brushes.data(), data.data() + pos, h.brushCount * sizeof(KmapBrush));
pos += h.brushCount * sizeof(KmapBrush);
m.points.resize(size_t(h.pointCount) * 3);
std::memcpy(m.points.data(), data.data() + pos, m.points.size() * sizeof(float));
pos += m.points.size() * sizeof(float);
for (const KmapBrush& b : m.brushes)
if (uint64_t(b.firstPoint) + b.pointCount > h.pointCount) return std::nullopt;
m.entities = parseEntityText(std::string((const char*)data.data() + pos, h.entitySize));
pos += h.entitySize;
const uint64_t probes = uint64_t(h.probeDims[0]) * h.probeDims[1] * h.probeDims[2];
if (probes > 4'000'000 || data.size() < pos + probes * kProbeFloats * 4) return std::nullopt;
std::memcpy(&m.probes.origin, h.probeOrigin, 12);
m.probes.spacing = h.probeSpacing;
std::memcpy(&m.probes.dims, h.probeDims, 12);
m.probes.data.resize(size_t(probes) * kProbeFloats);
std::memcpy(m.probes.data.data(), data.data() + pos, m.probes.data.size() * 4);
pos += m.probes.data.size() * 4;
if (data.size() < pos + h.materialTableSize) return std::nullopt;
const std::string table((const char*)data.data() + pos, h.materialTableSize);
for (size_t a = 0, b; (b = table.find('\n', a)) != std::string::npos; a = b + 1) m.materials.push_back(table.substr(a, b - a));
for (const KmapBrush& b : m.brushes)
for (uint16_t mat : b.material)
if (mat >= m.materials.size() && !m.materials.empty()) return std::nullopt;
return m;
}
} // namespace keel

200
engine/asset/kmdl.h Normal file
View file

@ -0,0 +1,200 @@
#pragma once
// KMDL: the engine's runtime model format, written by assetc from glTF. Laid out so
// the loader is a few memcpys:
// KmdlHeader
// KmdlMaterial × materialCount
// KmdlSubmesh × submeshCount
// KmdlVertex × vertexCount
// uint32_t × indexCount
// if jointCount > 0:
// KmdlJoint × jointCount (parents before children)
// KmdlSkin × vertexCount
// animationCount × { KmdlAnimation, then per channel: KmdlChannel,
// float times[keyCount], float values[keyCount × 3 or 4] }
// hullCount × { uint32_t pointCount, float xyz[pointCount × 3] }
// Hulls are the collision model (like Source's VPhysics .phy): convex pieces, model space.
// None = collide as the convex hull of all vertices.
#include <cstdint>
#include <cstring>
#include <optional>
#include <vector>
namespace keel {
inline constexpr char kKmdlMagic[4] = {'K', 'M', 'D', 'L'};
inline constexpr uint32_t kKmdlVersion = 5;
// KmdlHeader::flags
inline constexpr uint32_t KMDL_PLAYER_SOLID = 1u; // players collide with it whatever its size (doors)
struct KmdlHeader {
char magic[4];
uint32_t version;
uint32_t materialCount, submeshCount, vertexCount, indexCount;
float boundsMin[3], boundsMax[3];
uint32_t jointCount, animationCount;
uint32_t hullCount;
uint32_t flags;
};
struct KmdlJoint {
char name[32];
int32_t parent; // -1 = root
float inverseBind[16]; // column-major
float translation[3], rotation[4], scale[3]; // rest pose, local; rotation xyzw
};
struct KmdlSkin {
uint8_t joints[4];
uint8_t weights[4]; // sum to 255
};
enum KmdlChannelPath : uint32_t { KMDL_TRANSLATION = 0, KMDL_ROTATION = 1, KMDL_SCALE = 2 };
struct KmdlAnimationHeader {
char name[32];
float duration;
uint32_t channelCount;
};
struct KmdlChannelHeader {
uint32_t joint, path, keyCount;
};
struct KmdlChannel {
KmdlChannelHeader header;
std::vector<float> times, values; // values: 3 per key (T, S) or 4 (R, xyzw)
};
struct KmdlAnimation {
KmdlAnimationHeader header;
std::vector<KmdlChannel> channels;
};
struct KmdlMaterial {
char texture[120]; // game path to a .yapf, empty = white
char lightmap[120]; // baked lighting (RGBM .yapf, uses KmdlVertex::lightmapUv); empty = lit at runtime
float baseColor[4];
};
struct KmdlSubmesh {
uint32_t firstIndex, indexCount, material;
};
struct KmdlVertex {
float position[3];
float normal[3];
float uv[2];
float lightmapUv[2];
};
struct KmdlModel {
KmdlHeader header{};
std::vector<KmdlMaterial> materials;
std::vector<KmdlSubmesh> submeshes;
std::vector<KmdlVertex> vertices;
std::vector<uint32_t> indices;
std::vector<KmdlJoint> joints;
std::vector<KmdlSkin> skin; // one per vertex when skinned
std::vector<KmdlAnimation> animations;
std::vector<std::vector<float>> hulls; // xyz triples
};
inline std::vector<uint8_t> writeKmdl(KmdlModel m) {
std::memcpy(m.header.magic, kKmdlMagic, 4);
m.header.version = kKmdlVersion;
m.header.materialCount = uint32_t(m.materials.size());
m.header.submeshCount = uint32_t(m.submeshes.size());
m.header.vertexCount = uint32_t(m.vertices.size());
m.header.indexCount = uint32_t(m.indices.size());
m.header.jointCount = uint32_t(m.joints.size());
m.header.animationCount = uint32_t(m.animations.size());
m.header.hullCount = uint32_t(m.hulls.size());
std::vector<uint8_t> out;
auto put = [&](const void* p, size_t n) { out.insert(out.end(), (const uint8_t*)p, (const uint8_t*)p + n); };
put(&m.header, sizeof m.header);
put(m.materials.data(), m.materials.size() * sizeof(KmdlMaterial));
put(m.submeshes.data(), m.submeshes.size() * sizeof(KmdlSubmesh));
put(m.vertices.data(), m.vertices.size() * sizeof(KmdlVertex));
put(m.indices.data(), m.indices.size() * sizeof(uint32_t));
if (!m.joints.empty()) {
put(m.joints.data(), m.joints.size() * sizeof(KmdlJoint));
put(m.skin.data(), m.skin.size() * sizeof(KmdlSkin));
for (auto& a : m.animations) {
a.header.channelCount = uint32_t(a.channels.size());
put(&a.header, sizeof a.header);
for (auto& c : a.channels) {
c.header.keyCount = uint32_t(c.times.size());
put(&c.header, sizeof c.header);
put(c.times.data(), c.times.size() * sizeof(float));
put(c.values.data(), c.values.size() * sizeof(float));
}
}
}
for (const auto& h : m.hulls) {
const uint32_t n = uint32_t(h.size() / 3);
put(&n, 4);
put(h.data(), size_t(n) * 3 * sizeof(float));
}
return out;
}
inline std::optional<KmdlModel> readKmdl(const std::vector<uint8_t>& data) {
KmdlModel m;
size_t pos = 0;
auto take = [&](void* p, size_t n) {
if (pos + n > data.size()) return false;
std::memcpy(p, data.data() + pos, n);
pos += n;
return true;
};
if (!take(&m.header, sizeof m.header) || std::memcmp(m.header.magic, kKmdlMagic, 4) != 0 || m.header.version != kKmdlVersion)
return std::nullopt;
m.materials.resize(m.header.materialCount);
m.submeshes.resize(m.header.submeshCount);
m.vertices.resize(m.header.vertexCount);
m.indices.resize(m.header.indexCount);
if (!take(m.materials.data(), m.materials.size() * sizeof(KmdlMaterial)) ||
!take(m.submeshes.data(), m.submeshes.size() * sizeof(KmdlSubmesh)) ||
!take(m.vertices.data(), m.vertices.size() * sizeof(KmdlVertex)) ||
!take(m.indices.data(), m.indices.size() * sizeof(uint32_t)))
return std::nullopt;
for (uint32_t i : m.indices)
if (i >= m.vertices.size()) return std::nullopt;
if (m.header.jointCount) {
if (m.header.jointCount > 255) return std::nullopt;
m.joints.resize(m.header.jointCount);
m.skin.resize(m.header.vertexCount);
if (!take(m.joints.data(), m.joints.size() * sizeof(KmdlJoint)) || !take(m.skin.data(), m.skin.size() * sizeof(KmdlSkin)))
return std::nullopt;
for (size_t j = 0; j < m.joints.size(); ++j)
if (m.joints[j].parent >= int32_t(j)) return std::nullopt; // parents first
for (const KmdlSkin& sk : m.skin)
for (uint8_t j : sk.joints)
if (j >= m.joints.size()) return std::nullopt;
m.animations.resize(m.header.animationCount);
for (auto& a : m.animations) {
if (!take(&a.header, sizeof a.header) || a.header.channelCount > 4096) return std::nullopt;
a.channels.resize(a.header.channelCount);
for (auto& c : a.channels) {
if (!take(&c.header, sizeof c.header) || c.header.joint >= m.joints.size() || c.header.path > 2 || c.header.keyCount > 1'000'000)
return std::nullopt;
c.times.resize(c.header.keyCount);
c.values.resize(size_t(c.header.keyCount) * (c.header.path == KMDL_ROTATION ? 4 : 3));
if (!take(c.times.data(), c.times.size() * 4) || !take(c.values.data(), c.values.size() * 4)) return std::nullopt;
}
}
}
if (m.header.hullCount > 4096) return std::nullopt;
m.hulls.resize(m.header.hullCount);
for (auto& h : m.hulls) {
uint32_t n = 0;
if (!take(&n, 4) || n > 65536) return std::nullopt;
h.resize(size_t(n) * 3);
if (!take(h.data(), h.size() * sizeof(float))) return std::nullopt;
}
for (auto& s : m.submeshes)
if (uint64_t(s.firstIndex) + s.indexCount > m.indices.size() || s.material >= m.materials.size()) return std::nullopt;
return m;
}
} // namespace keel

25
engine/asset/ksky.h Normal file
View file

@ -0,0 +1,25 @@
#pragma once
// .ksky: a sky for the renderer, compiled by assetc from an equirectangular .hdr.
// KskyHeader
// `levels` images of width >> k × height >> k RGBM8 pixels (rows top-down), largest first
// Directions are world space (+Z up): u = atan2(d.y, d.x) / 2π + 0.5, v = acos(d.z) / π.
// A pixel's linear radiance is rgb · a · kSkyRange. The sky is normalised so its upper half
// averages 1 (the engine scales it by the map's sky intensity), and the sun is taken out of
// it (the engine draws its own, matching the shadows). Level k is the sky as a surface of
// roughness k / (levels - 1) reflects it (GGX prefiltered), for image-based reflections.
#include <cstdint>
namespace keel {
inline constexpr uint32_t kKskyVersion = 1;
inline constexpr float kSkyRange = 16.0f;
struct KskyHeader {
char magic[4]; // "KSKY"
uint32_t version;
uint32_t width, height, levels;
float sunDir[3]; // towards where the sun was in the photo
float sh[27]; // 9 RGB spherical harmonics of the light it casts: Σ shᵢ·Yᵢ(n) = irradiance / π
};
} // namespace keel

334
engine/audio/audio.cpp Normal file
View file

@ -0,0 +1,334 @@
#include "audio/audio.h"
#include "core/cvar.h"
#include "core/log.h"
#include "fs/vfs.h"
#include <miniaudio.h>
#include <opusfile.h>
#include <algorithm>
#include <random>
#include <cmath>
#include <list>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
namespace keel::audio {
namespace {
Cvar s_volume("volume", "0.8", CVAR_ARCHIVE, "Master volume 0..1");
Cvar s_musicvolume("s_musicvolume", "0.3", CVAR_ARCHIVE, "Music volume 0..1");
using Bytes = std::shared_ptr<const std::vector<uint8_t>>;
// ── Ogg Opus as a miniaudio data source, decoding from memory as it plays ──
struct OpusSource {
ma_data_source_base base;
Bytes bytes;
OggOpusFile* file = nullptr;
int channels = 1;
};
ma_result opusRead(ma_data_source* ds, void* out, ma_uint64 frames, ma_uint64* read) {
auto* s = static_cast<OpusSource*>(ds);
ma_uint64 done = 0;
float* dst = static_cast<float*>(out);
while (done < frames) {
const int n = s->channels == 2 ? op_read_float_stereo(s->file, dst + done * 2, int((frames - done) * 2))
: op_read_float(s->file, dst + done, int(frames - done), nullptr);
if (n <= 0) break;
done += ma_uint64(n);
}
*read = done;
return done == 0 && frames > 0 ? MA_AT_END : MA_SUCCESS;
}
ma_result opusSeek(ma_data_source* ds, ma_uint64 frame) {
return op_pcm_seek(static_cast<OpusSource*>(ds)->file, ogg_int64_t(frame)) == 0 ? MA_SUCCESS : MA_ERROR;
}
ma_result opusFormat(ma_data_source* ds, ma_format* fmt, ma_uint32* channels, ma_uint32* rate, ma_channel* map, size_t mapCap) {
*fmt = ma_format_f32;
*channels = ma_uint32(static_cast<OpusSource*>(ds)->channels);
*rate = 48000; // Opus always decodes at 48 kHz
if (map) ma_channel_map_init_standard(ma_standard_channel_map_default, map, mapCap, *channels);
return MA_SUCCESS;
}
ma_result opusCursor(ma_data_source* ds, ma_uint64* cursor) {
const ogg_int64_t t = op_pcm_tell(static_cast<OpusSource*>(ds)->file);
*cursor = t < 0 ? 0 : ma_uint64(t);
return MA_SUCCESS;
}
ma_result opusLength(ma_data_source* ds, ma_uint64* length) {
const ogg_int64_t t = op_pcm_total(static_cast<OpusSource*>(ds)->file, -1);
*length = t < 0 ? 0 : ma_uint64(t);
return MA_SUCCESS;
}
const ma_data_source_vtable kOpusVtable = {opusRead, opusSeek, opusFormat, opusCursor, opusLength, nullptr, 0};
// A playing sound and whatever decodes it.
struct Voice {
ma_sound sound{};
OpusSource opus{};
ma_decoder decoder{};
bool isOpus = false, hasDecoder = false, music = false;
Bytes bytes;
~Voice() {
ma_sound_uninit(&sound);
if (isOpus) {
op_free(opus.file);
ma_data_source_uninit(&opus.base);
}
if (hasDecoder) ma_decoder_uninit(&decoder);
}
};
std::unique_ptr<ma_engine> g_engine;
std::list<Voice> g_voices;
std::unordered_map<std::string, Bytes> g_cache; // file bytes, shared by voices
std::unordered_map<std::string, int64_t> g_times; // their files' times (hot reload)
Voice* g_music = nullptr;
std::unordered_map<std::string, Voice*> g_ambience; // looping background sounds by path
struct Loop {
Voice* voice;
std::string path;
int frame; // the update it was last set in
};
std::unordered_map<int, Loop> g_loops; // looping sounds in the world (setLoop)
int g_frame = 0;
Bytes load(std::string_view path) {
const std::string key(path);
if (auto it = g_cache.find(key); it != g_cache.end()) return it->second;
auto data = fs::read(path);
if (!data) log::warn("Sound not found: {}", key);
if (auto t = fs::mtime(key)) g_times[key] = *t;
return g_cache[key] = data ? std::make_shared<const std::vector<uint8_t>>(std::move(*data)) : nullptr;
}
Voice* start(std::string_view path, bool spatial) {
Bytes bytes = load(path);
if (!g_engine || !bytes) return nullptr;
Voice& v = g_voices.emplace_back();
v.bytes = bytes;
ma_data_source* source = nullptr;
if (path.ends_with(".opus") || path.ends_with(".ogg")) {
int err = 0;
v.opus.file = op_open_memory(bytes->data(), bytes->size(), &err);
if (!v.opus.file) {
log::warn("{}: not an Ogg Opus file ({})", path, err);
g_voices.pop_back();
return nullptr;
}
v.isOpus = true;
v.opus.channels = std::min(op_channel_count(v.opus.file, -1), 2);
ma_data_source_config cfg = ma_data_source_config_init();
cfg.vtable = &kOpusVtable;
ma_data_source_init(&cfg, &v.opus.base);
source = &v.opus.base;
} else {
if (ma_decoder_init_memory(bytes->data(), bytes->size(), nullptr, &v.decoder) != MA_SUCCESS) {
log::warn("{}: unsupported audio file", path);
g_voices.pop_back();
return nullptr;
}
v.hasDecoder = true;
source = &v.decoder;
}
const ma_uint32 flags = spatial ? 0 : MA_SOUND_FLAG_NO_SPATIALIZATION;
if (ma_sound_init_from_data_source(g_engine.get(), source, flags, nullptr, &v.sound) != MA_SUCCESS) {
g_voices.pop_back();
return nullptr;
}
if (spatial) {
ma_sound_set_attenuation_model(&v.sound, ma_attenuation_model_inverse);
ma_sound_set_min_distance(&v.sound, 80.0f);
ma_sound_set_max_distance(&v.sound, 3000.0f);
}
return &v;
}
Cmd cmd_play("play", [](const Args& a) {
if (a.size() > 1) play(a[1]);
}, "Play a sound file");
} // namespace
bool init(bool noDevice) {
g_engine = std::make_unique<ma_engine>();
ma_engine_config cfg = ma_engine_config_init();
if (noDevice) {
cfg.noDevice = MA_TRUE;
cfg.channels = 2;
cfg.sampleRate = 48000;
}
if (ma_engine_init(&cfg, g_engine.get()) != MA_SUCCESS) {
log::warn("No audio device; sound is off");
g_engine.reset();
return false;
}
ma_engine_listener_set_world_up(g_engine.get(), 0, 0, 0, 1);
return true;
}
void shutdown() {
g_music = nullptr;
g_ambience.clear();
g_loops.clear();
g_voices.clear();
g_cache.clear();
if (g_engine) ma_engine_uninit(g_engine.get());
g_engine.reset();
}
bool active() { return g_engine != nullptr; }
Cvar snd_mute_losefocus("snd_mute_losefocus", "1", CVAR_ARCHIVE, "Silence it while its window isn't focused");
bool g_unfocused = false;
float masterVolume() { return g_unfocused && snd_mute_losefocus.b() ? 0.0f : std::clamp(s_volume.f(), 0.0f, 1.0f); }
void setFocused(bool focused) {
g_unfocused = !focused;
if (g_engine) ma_engine_set_volume(g_engine.get(), masterVolume());
}
void update(const glm::vec3& listener, float yaw, float pitch) {
if (!g_engine) return;
ma_engine_set_volume(g_engine.get(), masterVolume());
const float y = glm::radians(yaw), p = glm::radians(pitch);
ma_engine_listener_set_position(g_engine.get(), 0, listener.x, listener.y, listener.z);
ma_engine_listener_set_direction(g_engine.get(), 0, std::cos(p) * std::cos(y), std::cos(p) * std::sin(y), -std::sin(p));
if (g_music) ma_sound_set_volume(&g_music->sound, std::clamp(s_musicvolume.f(), 0.0f, 1.0f));
g_voices.remove_if([](Voice& v) { return !v.music && ma_sound_at_end(&v.sound); });
// Loops nobody set lately (their car went away) stop.
++g_frame;
for (auto it = g_loops.begin(); it != g_loops.end();) {
if (g_frame - it->second.frame > 60) {
Voice* v = it->second.voice;
g_voices.remove_if([&](Voice& x) { return &x == v; });
it = g_loops.erase(it);
} else ++it;
}
}
void setLoop(int key, std::string_view path, const glm::vec3& position, float volume, float pitch) {
auto it = g_loops.find(key);
if (it != g_loops.end() && (volume <= 0.001f || it->second.path != path)) {
Voice* v = it->second.voice;
g_voices.remove_if([&](Voice& x) { return &x == v; });
g_loops.erase(it);
it = g_loops.end();
}
if (volume <= 0.001f) return;
if (it == g_loops.end()) {
Voice* v = start(path, true);
if (!v) return;
v->music = true; // (kept when it ends; it loops anyway)
ma_sound_set_looping(&v->sound, MA_TRUE);
ma_sound_start(&v->sound);
it = g_loops.emplace(key, Loop{v, std::string(path), g_frame}).first;
}
Loop& l = it->second;
l.frame = g_frame;
ma_sound_set_position(&l.voice->sound, position.x, position.y, position.z);
ma_sound_set_volume(&l.voice->sound, volume);
ma_sound_set_pitch(&l.voice->sound, std::clamp(pitch, 0.1f, 4.0f));
}
// Variants: "foo.opus" missing but "foo1.opus", "foo2.opus"... there → one of those at random.
std::string pickVariant(std::string_view path) {
static std::unordered_map<std::string, int> counts; // 0 = use the path as is
static std::mt19937 rng{std::random_device{}()};
const std::string key(path);
auto it = counts.find(key);
if (it == counts.end()) {
int n = 0;
if (!fs::exists(key)) {
const size_t dot = key.rfind('.');
const std::string stem = key.substr(0, dot), ext = dot == std::string::npos ? "" : key.substr(dot);
while (fs::exists(stem + std::to_string(n + 1) + ext)) ++n;
}
it = counts.emplace(key, n).first;
}
if (it->second == 0) return key;
const size_t dot = key.rfind('.');
return key.substr(0, dot) + std::to_string(std::uniform_int_distribution<int>(1, it->second)(rng)) +
(dot == std::string::npos ? "" : key.substr(dot));
}
std::vector<std::string> reloadChanged() {
std::vector<std::string> changed;
for (auto it = g_cache.begin(); it != g_cache.end();) {
const auto t = fs::mtime(it->first);
const auto known = g_times.find(it->first);
if (t && known != g_times.end() && known->second != *t) {
changed.push_back(it->first);
g_times.erase(known);
it = g_cache.erase(it); // playing voices keep their bytes; the next play reads the new file
} else {
++it;
}
}
return changed;
}
void play(std::string_view path, std::optional<glm::vec3> position, float volume) {
if (g_voices.size() > 128) return; // something is spamming; drop rather than stutter
Voice* v = start(pickVariant(path), position.has_value());
if (!v) return;
if (position) ma_sound_set_position(&v->sound, position->x, position->y, position->z);
ma_sound_set_volume(&v->sound, volume);
// A slightly different pitch each time, so repeated sounds don't sound copy-pasted.
static std::mt19937 jitter{12345};
ma_sound_set_pitch(&v->sound, std::uniform_real_distribution<float>(0.95f, 1.05f)(jitter));
ma_sound_start(&v->sound);
}
void playMusic(std::string_view path, bool loop, int fadeMs) {
stopMusic();
g_music = start(path, false);
if (!g_music) return;
g_music->music = true;
ma_sound_set_looping(&g_music->sound, loop);
ma_sound_set_volume(&g_music->sound, std::clamp(s_musicvolume.f(), 0.0f, 1.0f));
if (fadeMs > 0) ma_sound_set_fade_in_milliseconds(&g_music->sound, 0.0f, 1.0f, ma_uint64(fadeMs));
ma_sound_start(&g_music->sound);
}
void stopMusic() {
if (!g_music) return;
g_voices.remove_if([](Voice& v) { return &v == g_music; });
g_music = nullptr;
}
void setAmbience(std::string_view path, float volume) {
const std::string key(path);
auto it = g_ambience.find(key);
if (volume <= 0.001f) {
if (it != g_ambience.end()) {
Voice* v = it->second;
g_voices.remove_if([&](Voice& x) { return &x == v; });
g_ambience.erase(it);
}
return;
}
if (it == g_ambience.end()) {
Voice* v = start(path, false);
if (!v) return;
v->music = true; // (kept when it ends; it loops anyway)
ma_sound_set_looping(&v->sound, MA_TRUE);
ma_sound_set_volume(&v->sound, 0.0f);
ma_sound_start(&v->sound);
it = g_ambience.emplace(key, v).first;
}
const float now = ma_sound_get_volume(&it->second->sound);
ma_sound_set_volume(&it->second->sound, now + (volume - now) * 0.08f);
}
int playingCount() { return int(g_voices.size()); }
void stopSounds() { g_voices.remove_if([](Voice& v) { return !v.music; }); }
void readFrames(float* out, int frames) {
if (g_engine) ma_engine_read_pcm_frames(g_engine.get(), out, ma_uint64(frames), nullptr);
}
} // namespace keel::audio

45
engine/audio/audio.h Normal file
View file

@ -0,0 +1,45 @@
#pragma once
#include <string>
#include <vector>
// Sound output (miniaudio). Files load through the VFS: .opus (Ogg Opus, what
// assetc produces and the game ships) plus .wav/.flac/.mp3 for convenience.
// Everything streams, so long music costs no more memory than a short sound.
// World space matches the renderer: Z up, game units.
#include <glm/glm.hpp>
#include <optional>
#include <string_view>
namespace keel::audio {
// noDevice: mix without an output device (tests, headless); read with readFrames.
bool init(bool noDevice = false);
void shutdown();
bool active();
// Call once per frame: places the listener and frees finished sounds.
void update(const glm::vec3& listener, float yaw, float pitch);
// Silences everything while the window is in the background (snd_mute_losefocus).
void setFocused(bool focused);
// One-shot sound; with a position it's 3D, otherwise it plays flat (UI, local).
// Hot reload: sounds whose files changed are read again on their next play. Returns their paths.
std::vector<std::string> reloadChanged();
void play(std::string_view path, std::optional<glm::vec3> position = std::nullopt, float volume = 1.0f);
// fadeMs: rises from silence over that long.
void playMusic(std::string_view path, bool loop = true, int fadeMs = 0);
void stopMusic();
// A looping background sound (rain, wind) at a volume, set every frame or when it changes;
// 0 stops it. It eases to the new volume rather than jumping.
void setAmbience(std::string_view path, float volume);
// A looping sound at a place (a car's engine), by key: set it every frame, with its volume and
// pitch (1 as recorded); volume 0 stops it. Keys not set for a second stop by themselves.
void setLoop(int key, std::string_view path, const glm::vec3& position, float volume, float pitch);
// Stops every sound playing (not the music): an editor's Stop.
void stopSounds();
int playingCount();
// Headless mixing (init(true)): stereo float frames at 48 kHz.
void readFrames(float* out, int frames);
} // namespace keel::audio

309
engine/core/console.cpp Normal file
View file

@ -0,0 +1,309 @@
// The developer console, Source-style: it drops down from the top; as you type, a list under
// the input shows the commands and cvars that match (cvars with their values) and, once a
// command is typed, what its argument can be (map names for "map", files for "exec", ...).
// Arrows walk the list (writing the pick into the input, like Source), Tab completes, Enter
// runs. History survives restarts.
#include "core/console.h"
#include "core/cvar.h"
#include "core/log.h"
#include <imgui.h>
#include <algorithm>
#include <cstdio>
#include <fstream>
#include <string>
#include <vector>
namespace keel::console {
namespace {
bool g_open = false;
bool g_focus = false;
float g_slide = 0.0f; // 0 = up, 1 = down
char g_input[512] = {};
std::vector<std::string> g_history;
int g_historyPos = -1;
std::string g_historyFile;
struct Suggestion {
std::string text; // the whole input it stands for
std::string label; // what's shown: the name or argument
std::string value; // cvars: their value
std::string help;
bool pick = true; // false: an info row (a cvar's description)
};
std::vector<Suggestion> g_list;
int g_sel = -1;
std::string g_typed; // what the user typed (the list is built from it, not from picks)
std::string g_pending; // replace the input with this on the next callback (a click)
bool g_hasPending = false;
constexpr size_t kMaxSuggestions = 14;
bool containsNoCase(std::string_view hay, std::string_view needle) {
auto low = [](char c) { return char(c >= 'A' && c <= 'Z' ? c + 32 : c); };
return std::search(hay.begin(), hay.end(), needle.begin(), needle.end(), [&](char a, char b) { return low(a) == low(b); }) != hay.end();
}
void describe(Suggestion& s, const std::string& name) {
if (Cmd* c = Cmd::find(name)) s.help = c->help();
else if (Cvar* v = Cvar::find(name)) s.value = v->str(), s.help = v->help();
else
for (const auto& [n, text] : aliases())
if (n == name) s.help = "alias: " + text;
}
void buildList() {
g_list.clear();
g_sel = -1;
const std::string& in = g_typed;
// Only the last command of a "a; b; c" line.
const size_t semi = in.rfind(';');
std::string head = semi == std::string::npos ? "" : in.substr(0, semi + 1) + " ";
std::string seg = semi == std::string::npos ? in : in.substr(semi + 1);
seg.erase(0, seg.find_first_not_of(' '));
if (semi != std::string::npos) head = in.substr(0, in.size() - seg.size());
if (seg.empty()) return;
const size_t space = seg.find(' ');
if (space == std::string::npos) {
// A name: those starting with it first, then those containing it.
const std::vector<std::string> names = allNames();
for (int pass = 0; pass < 2 && g_list.size() < kMaxSuggestions; ++pass)
for (const std::string& n : names) {
const bool prefix = n.starts_with(seg);
if (pass == 0 ? !prefix : (prefix || !containsNoCase(n, seg))) continue;
Suggestion s{head + n + " ", n};
describe(s, n);
g_list.push_back(std::move(s));
if (g_list.size() >= kMaxSuggestions) break;
}
return;
}
// An argument: the command's own suggestions, or what a cvar is.
const std::string name = seg.substr(0, space);
const size_t lastSpace = seg.rfind(' ');
const std::string partial = seg.substr(lastSpace + 1);
const std::string before = head + seg.substr(0, lastSpace + 1);
if (Cmd* c = Cmd::find(name); c && c->completer()) {
const size_t arg = tokenize(seg.substr(0, lastSpace)).size(); // the command is token 0
for (const std::string& v : c->completer()(partial, arg)) {
Suggestion s{before + v + " ", v};
if (name == "help" || name == "reset" || name == "toggle" || name == "set" || name == "incrementvar") describe(s, v);
g_list.push_back(std::move(s));
if (g_list.size() >= kMaxSuggestions) break;
}
} else if (Cvar* v = Cvar::find(name)) {
Suggestion s{"", name, v->str(), std::string(v->help()) + " (default \"" + v->defaultValue() + "\")"};
s.pick = false;
g_list.push_back(std::move(s));
} else if (Cmd* c2 = Cmd::find(name)) {
Suggestion s{"", name, "", c2->help()};
s.pick = false;
g_list.push_back(std::move(s));
}
}
void setInput(ImGuiInputTextCallbackData* data, const std::string& text) {
data->DeleteChars(0, data->BufTextLen);
data->InsertChars(0, text.c_str());
}
int inputCallback(ImGuiInputTextCallbackData* data) {
switch (data->EventFlag) {
case ImGuiInputTextFlags_CallbackAlways:
if (g_hasPending) {
setInput(data, g_pending);
g_hasPending = false;
}
break;
case ImGuiInputTextFlags_CallbackEdit:
g_typed.assign(data->Buf, size_t(data->BufTextLen));
g_historyPos = -1;
buildList();
break;
case ImGuiInputTextFlags_CallbackHistory: {
const bool up = data->EventKey == ImGuiKey_UpArrow;
int picks = 0;
for (const auto& s : g_list) picks += s.pick;
if (picks > 0 && !g_typed.empty()) { // walk the suggestions
int i = g_sel;
do i = up ? (i <= 0 ? int(g_list.size()) - 1 : i - 1) : (i + 1) % int(g_list.size());
while (!g_list[size_t(i)].pick);
g_sel = i;
setInput(data, g_list[size_t(i)].text);
} else if (!g_history.empty()) { // or the history
if (up) g_historyPos = g_historyPos < 0 ? int(g_history.size()) - 1 : std::max(0, g_historyPos - 1);
else if (g_historyPos >= 0 && ++g_historyPos >= int(g_history.size())) g_historyPos = -1;
setInput(data, g_historyPos >= 0 ? g_history[size_t(g_historyPos)] : "");
}
break;
}
case ImGuiInputTextFlags_CallbackCompletion: {
// Tab: the picked suggestion, else the first, else the longest common start.
std::vector<const Suggestion*> picks;
for (const auto& s : g_list)
if (s.pick) picks.push_back(&s);
if (picks.empty()) break;
const Suggestion* s = g_sel >= 0 && g_list[size_t(g_sel)].pick ? &g_list[size_t(g_sel)] : picks.front();
setInput(data, s->text);
g_typed = s->text;
buildList();
break;
}
default:
break;
}
return 0;
}
void remember(const std::string& line) {
if (g_history.empty() || g_history.back() != line) g_history.push_back(line);
if (g_history.size() > 200) g_history.erase(g_history.begin());
if (g_historyFile.empty()) return;
std::ofstream out(g_historyFile);
for (const auto& h : g_history) out << h << '\n';
}
} // namespace
namespace {
Cmd cmd_toggleconsole("toggleconsole", [](const Args&) { toggle(); }, "Open or close the console");
Cmd cmd_con_input("con_input", [](const Args& a) {
std::string text;
for (size_t i = 1; i < a.size(); ++i) text += (i > 1 ? " " : "") + a[i];
if (a.size() > 1 && a.back().empty()) text += " ";
std::snprintf(g_input, sizeof g_input, "%s", text.c_str());
g_pending = text; // the input box keeps its own copy while focused
g_hasPending = true;
g_typed = text;
buildList();
if (!g_open) toggle();
}, "con_input <text>: open the console with this typed (scripts, tests)");
} // namespace
void toggle() {
g_open = !g_open;
g_focus = g_open;
}
bool isOpen() { return g_open; }
void setHistoryFile(const std::string& path) {
g_historyFile = path;
g_history.clear();
std::ifstream in(path);
for (std::string line; std::getline(in, line);)
if (!line.empty()) g_history.push_back(line);
}
void draw() {
const float dt = ImGui::GetIO().DeltaTime;
g_slide = std::clamp(g_slide + (g_open ? dt : -dt) * 7.0f, 0.0f, 1.0f);
if (g_slide <= 0.0f) return;
const float k = 1.0f - (1.0f - g_slide) * (1.0f - g_slide); // ease out
const ImGuiViewport* vp = ImGui::GetMainViewport();
const float fullH = vp->WorkSize.y * 0.46f;
const ImU32 accent = IM_COL32(226, 54, 54, 255);
ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x, vp->WorkPos.y - fullH * (1.0f - k)));
ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, fullH));
ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(14, 13, 15, 238));
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(255, 255, 255, 10));
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(14, 10));
ImGui::Begin("Console", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings);
const ImVec2 wp = ImGui::GetWindowPos(), ws = ImGui::GetWindowSize();
ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(wp.x, wp.y + ws.y - 2), ImVec2(wp.x + ws.x, wp.y + ws.y), accent);
const float footer = ImGui::GetFrameHeightWithSpacing() + 4;
ImGui::BeginChild("lines", ImVec2(0, -footer));
for (const log::Line& line : log::history()) {
const bool echo = line.text.starts_with("] ");
const ImVec4 color = line.level == log::Level::Error ? ImVec4(1, .42f, .42f, 1)
: line.level == log::Level::Warn ? ImVec4(1, .78f, .35f, 1)
: echo ? ImVec4(.95f, .55f, .5f, 1)
: ImVec4(.86f, .85f, .83f, 1);
ImGui::TextColored(color, "%s", line.text.c_str());
}
if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) ImGui::SetScrollHereY(1.0f);
ImGui::EndChild();
// The input, with a prompt.
ImGui::TextColored(ImVec4(.89f, .21f, .21f, 1), ">");
ImGui::SameLine();
ImGui::SetNextItemWidth(-1);
if (g_focus) {
ImGui::SetKeyboardFocusHere();
g_focus = false;
}
const ImVec2 inputPos = ImGui::GetCursorScreenPos();
const auto flags = ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackHistory | ImGuiInputTextFlags_CallbackCompletion |
ImGuiInputTextFlags_CallbackEdit | ImGuiInputTextFlags_CallbackAlways;
if (ImGui::InputText("##input", g_input, sizeof g_input, flags, inputCallback)) {
std::string line = g_input;
while (!line.empty() && line.back() == ' ') line.pop_back();
if (!line.empty()) {
log::info("] {}", line);
remember(line);
execute(line);
}
g_input[0] = 0;
g_typed.clear();
g_list.clear();
g_sel = -1;
g_historyPos = -1;
ImGui::SetKeyboardFocusHere(-1);
}
if (ImGui::IsItemActive() && ImGui::IsKeyPressed(ImGuiKey_L) && ImGui::GetIO().KeyCtrl) log::clearHistory();
const float inputH = ImGui::GetItemRectSize().y;
ImGui::End();
ImGui::PopStyleVar(2);
ImGui::PopStyleColor(2);
// The suggestions, under the console like Source's.
if (g_list.empty() || g_slide < 1.0f || !g_open) return;
ImGui::SetNextWindowPos(ImVec2(inputPos.x, inputPos.y + inputH + 6));
ImGui::SetNextWindowBgAlpha(0.96f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(22, 20, 23, 245));
ImGui::PushStyleColor(ImGuiCol_Border, IM_COL32(226, 54, 54, 120));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 6));
ImGui::Begin("##suggestions", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing |
ImGuiWindowFlags_NoNav);
float nameW = 0, valueW = 0;
for (const auto& s : g_list) {
nameW = std::max(nameW, ImGui::CalcTextSize(s.label.c_str()).x);
valueW = std::max(valueW, s.value.empty() ? 0.0f : ImGui::CalcTextSize(("\"" + s.value + "\"").c_str()).x);
}
const float maxW = vp->WorkSize.x - (inputPos.x - vp->WorkPos.x) - 40;
for (int i = 0; i < int(g_list.size()); ++i) {
const Suggestion& s = g_list[size_t(i)];
ImGui::PushID(i);
const ImVec2 p = ImGui::GetCursorScreenPos();
const float rowW = std::min(maxW, nameW + valueW + 520);
if (s.pick) {
if (ImGui::Selectable("##row", g_sel == i, 0, ImVec2(rowW, 0))) {
g_pending = s.text;
g_hasPending = true;
g_typed = s.text;
buildList();
g_focus = true;
}
} else {
ImGui::Dummy(ImVec2(rowW, ImGui::GetTextLineHeight()));
}
ImDrawList* dl = ImGui::GetWindowDrawList();
if (g_sel == i) dl->AddRectFilled(p, ImVec2(p.x + 3, p.y + ImGui::GetTextLineHeight()), accent);
dl->AddText(ImVec2(p.x + 8, p.y), IM_COL32(240, 236, 228, 255), s.label.c_str());
float x = p.x + 8 + nameW + 16;
if (!s.value.empty()) dl->AddText(ImVec2(x, p.y), IM_COL32(240, 120, 110, 255), ("\"" + s.value + "\"").c_str());
x += valueW + (valueW > 0 ? 16 : 0);
if (!s.help.empty()) {
ImGui::PushClipRect(ImVec2(x, p.y), ImVec2(p.x + rowW, p.y + 40), true);
dl->AddText(ImVec2(x, p.y), IM_COL32(150, 145, 138, 255), s.help.c_str());
ImGui::PopClipRect();
}
ImGui::PopID();
}
ImGui::End();
ImGui::PopStyleVar();
ImGui::PopStyleColor(2);
}
} // namespace keel::console

13
engine/core/console.h Normal file
View file

@ -0,0 +1,13 @@
#pragma once
#include <string>
namespace keel::console {
void toggle();
bool isOpen();
// Draws the console window with Dear ImGui. Call between ImGui::NewFrame and ImGui::Render.
void draw();
// Where typed lines are kept between runs (loaded now, saved as you type).
void setHistoryFile(const std::string& path);
} // namespace keel::console

322
engine/core/cvar.cpp Normal file
View file

@ -0,0 +1,322 @@
#include "core/cvar.h"
#include "core/log.h"
#include <algorithm>
#include <charconv>
#include <cstdio>
#include <cstdlib>
#include <utility>
namespace keel {
namespace {
// Function-local statics: safe to use from other files' static initializers.
std::vector<Cvar*>& cvars() { static std::vector<Cvar*> v; return v; }
std::vector<Cmd*>& cmds() { static std::vector<Cmd*> v; return v; }
std::vector<std::pair<std::string, std::string>>& aliasList() { static std::vector<std::pair<std::string, std::string>> v; return v; }
int g_aliasDepth = 0; // an alias that runs itself stops here
template <class T> T* findByName(std::vector<T*>& list, std::string_view name) {
auto it = std::find_if(list.begin(), list.end(), [&](T* x) { return name == x->name(); });
return it == list.end() ? nullptr : *it;
}
} // namespace
Cvar::Cvar(const char* name, const char* default_value, unsigned flags, const char* help)
: name_(name), help_(help), default_(default_value), flags_(flags) {
set(default_value);
cvars().push_back(this);
}
Cvar::~Cvar() { std::erase(cvars(), this); }
void Cvar::set(std::string_view value) {
value_ = value;
#if defined(__ANDROID__) // (the NDK's library has no float from_chars; bionic's locale is always "C")
const std::string text(value);
char* end = nullptr;
const float parsed = std::strtof(text.c_str(), &end);
float_ = end != text.c_str() ? parsed : 0.0f;
#else
float parsed = 0.0f;
auto [_, ec] = std::from_chars(value.data(), value.data() + value.size(), parsed);
float_ = ec == std::errc() ? parsed : 0.0f;
#endif
modified_ = true;
}
bool Cvar::consumeModified() { return std::exchange(modified_, false); }
Cvar* Cvar::find(std::string_view name) { return findByName(cvars(), name); }
const std::vector<Cvar*>& Cvar::all() { return cvars(); }
Cmd::Cmd(const char* name, Fn fn, const char* help) : name_(name), help_(help), fn_(std::move(fn)) {
cmds().push_back(this);
}
Cmd::~Cmd() { std::erase(cmds(), this); }
Cmd* Cmd::find(std::string_view name) { return findByName(cmds(), name); }
const std::vector<Cmd*>& Cmd::all() { return cmds(); }
Args tokenize(std::string_view line) {
Args out;
size_t i = 0;
while (i < line.size()) {
while (i < line.size() && line[i] <= ' ') ++i;
if (i >= line.size()) break;
if (line[i] == '"') {
size_t end = line.find('"', ++i);
if (end == std::string_view::npos) end = line.size();
out.emplace_back(line.substr(i, end - i));
i = end + 1;
} else {
size_t start = i;
while (i < line.size() && line[i] > ' ') ++i;
out.emplace_back(line.substr(start, i - start));
}
}
return out;
}
namespace {
struct Deferred {
int frames;
std::string text;
};
std::vector<Deferred> g_deferred;
} // namespace
void executeDeferred() {
std::vector<Deferred> due;
for (auto it = g_deferred.begin(); it != g_deferred.end();)
if (--it->frames <= 0) {
due.push_back(std::move(*it));
it = g_deferred.erase(it);
} else {
++it;
}
for (const Deferred& d : due) execute(d.text);
}
void execute(std::string_view text) {
// Split on ';' and newlines outside quotes.
bool quoted = false;
size_t start = 0;
for (size_t i = 0; i <= text.size(); ++i) {
if (i < text.size() && text[i] == '"') quoted = !quoted;
if (i < text.size() && (quoted || (text[i] != ';' && text[i] != '\n'))) continue;
Args args = tokenize(text.substr(start, i - start));
start = i + 1;
if (args.empty() || args[0].starts_with("//")) continue;
if (args[0] == "wait") {
const int frames = args.size() > 1 ? std::max(std::atoi(args[1].c_str()), 1) : 1;
if (i < text.size()) g_deferred.push_back({frames, std::string(text.substr(i + 1))});
return;
}
auto alias = std::find_if(aliasList().begin(), aliasList().end(), [&](const auto& a) { return a.first == args[0]; });
if (Cmd* cmd = Cmd::find(args[0])) {
cmd->run(args);
} else if (alias != aliasList().end()) {
if (g_aliasDepth > 32) {
log::warn("alias {} runs itself too deeply", args[0]);
continue;
}
++g_aliasDepth;
execute(alias->second);
--g_aliasDepth;
} else if (Cvar* cvar = Cvar::find(args[0])) {
if (args.size() == 1) {
log::info("\"{}\" = \"{}\" (default \"{}\"){}", cvar->name(), cvar->str(), cvar->defaultValue(),
cvar->flags() & CVAR_CHEAT ? " cheat" : "");
log::info(" {}", cvar->help());
} else if ((cvar->flags() & CVAR_CHEAT) && !Cvar::find("sv_cheats")->b()) {
log::warn("{} is cheat protected", cvar->name());
} else {
cvar->set(args[1]);
}
} else {
log::warn("Unknown command \"{}\"", args[0]);
}
}
}
static Cvar sv_cheats("sv_cheats", "0", CVAR_REPLICATE, "Allow cheat-protected cvars");
static Cmd cmd_echo("echo", [](const Args& a) {
std::string s;
for (size_t i = 1; i < a.size(); ++i) s += (i > 1 ? " " : "") + a[i];
log::info("{}", s);
}, "Print text to the console");
static Cmd cmd_set("set", [](const Args& a) {
if (a.size() < 3) return log::info("usage: set <cvar> <value>");
if (Cvar* c = Cvar::find(a[1])) c->set(a[2]);
else log::warn("Unknown cvar \"{}\"", a[1]);
}, "Set a cvar");
const std::vector<std::pair<std::string, std::string>>& aliases() { return aliasList(); }
std::vector<std::string> allNames() {
std::vector<std::string> out;
for (Cmd* c : Cmd::all()) out.emplace_back(c->name());
for (Cvar* c : Cvar::all()) out.emplace_back(c->name());
for (const auto& a : aliasList()) out.push_back(a.first);
std::sort(out.begin(), out.end());
out.erase(std::unique(out.begin(), out.end()), out.end());
return out;
}
std::vector<std::string> matching(const std::vector<std::string>& names, std::string_view partial) {
std::vector<std::string> out;
for (const std::string& n : names)
if (n.starts_with(partial)) out.push_back(n);
return out;
}
namespace {
std::string flagText(unsigned f) {
std::string s;
if (f & CVAR_ARCHIVE) s += " saved";
if (f & CVAR_CHEAT) s += " cheat";
if (f & CVAR_REPLICATE) s += " replicated";
return s;
}
bool contains(std::string_view hay, std::string_view needle) {
auto low = [](char c) { return char(c >= 'A' && c <= 'Z' ? c + 32 : c); };
return std::search(hay.begin(), hay.end(), needle.begin(), needle.end(), [&](char a, char b) { return low(a) == low(b); }) != hay.end();
}
Cmd::Completer names() {
return [](std::string_view p, size_t) { return matching(allNames(), p); };
}
Cmd::Completer cvarNames() {
return [](std::string_view p, size_t) {
std::vector<std::string> v;
for (Cvar* c : Cvar::all()) v.emplace_back(c->name());
std::sort(v.begin(), v.end());
return matching(v, p);
};
}
} // namespace
// toggle <cvar> [values...]: 0 ↔ 1, or the next of the values given (Source).
static Cmd cmd_toggle("toggle", [](const Args& a) {
if (a.size() < 2) return log::info("usage: toggle <cvar> [value1 value2 ...]");
Cvar* c = Cvar::find(a[1]);
if (!c) return log::warn("Unknown cvar \"{}\"", a[1]);
if (a.size() < 4) return execute(a[1] + (c->b() ? " 0" : " 1"));
size_t next = 2;
for (size_t i = 2; i < a.size(); ++i)
if (a[i] == c->str()) next = i + 1 < a.size() ? i + 1 : 2;
execute(a[1] + " \"" + a[next] + "\"");
}, "toggle <cvar> [values...]: flip between 0 and 1, or step through the values given");
static Cmd cmd_incrementvar("incrementvar", [](const Args& a) {
if (a.size() < 5) return log::info("usage: incrementvar <cvar> <min> <max> <step>");
Cvar* c = Cvar::find(a[1]);
if (!c) return log::warn("Unknown cvar \"{}\"", a[1]);
const float lo = float(std::atof(a[2].c_str())), hi = float(std::atof(a[3].c_str())), step = float(std::atof(a[4].c_str()));
float v = c->f() + step;
if (v > hi) v = lo;
if (v < lo) v = hi;
execute(std::string(a[1]) + " " + std::to_string(v));
}, "incrementvar <cvar> <min> <max> <step>: add step, wrapping round (bind it to keys)");
static Cmd cmd_find("find", [](const Args& a) {
if (a.size() < 2) return log::info("usage: find <text> (searches names and descriptions)");
int n = 0;
for (const std::string& name : allNames()) {
Cmd* cmd = Cmd::find(name);
Cvar* cv = cmd ? nullptr : Cvar::find(name);
const char* help = cmd ? cmd->help() : cv ? cv->help() : "";
if (!contains(name, a[1]) && !contains(help, a[1])) continue;
if (cv) log::info(" {} = \"{}\" - {}", name, cv->str(), help);
else log::info(" {} - {}", name, cmd ? help : "alias");
++n;
}
log::info("{} found", n);
}, "find <text>: commands and cvars whose name or description mentions it");
static Cmd cmd_help("help", [](const Args& a) {
if (a.size() < 2) {
log::info("Type a command or cvar name; a list of matches shows as you type (Tab completes, arrows pick).");
log::info(" help <name> what it does find <text> search names and descriptions");
log::info(" cvarlist [x] all cvars cmdlist [x] all commands");
log::info(" differences cvars you changed reset <cvar> back to its default");
log::info(" alias, bind, exec, toggle, incrementvar, echo, clear, condump, wait");
return;
}
if (Cmd* c = Cmd::find(a[1])) return log::info("{} (command) - {}", c->name(), c->help());
if (Cvar* c = Cvar::find(a[1])) {
log::info("\"{}\" = \"{}\" (default \"{}\"){}", c->name(), c->str(), c->defaultValue(), flagText(c->flags()));
return log::info(" {}", c->help());
}
for (const auto& [name, text] : aliasList())
if (name == a[1]) return log::info("{} (alias) = \"{}\"", name, text);
log::warn("Nothing called \"{}\". Try: find {}", a[1], a[1]);
}, "help [name]: how the console works, or what a command/cvar does");
static Cmd cmd_cvarlist("cvarlist", [](const Args& a) {
std::vector<Cvar*> v(Cvar::all().begin(), Cvar::all().end());
std::sort(v.begin(), v.end(), [](Cvar* x, Cvar* y) { return std::string_view(x->name()) < y->name(); });
int n = 0;
for (Cvar* c : v)
if (a.size() < 2 || std::string_view(c->name()).starts_with(a[1]))
log::info(" {:<24} {:<10}{:<9} {}", c->name(), "\"" + c->str() + "\"", flagText(c->flags()), c->help()), ++n;
log::info("{} cvars", n);
}, "cvarlist [prefix]: every cvar, its value and what it does");
static Cmd cmd_cmdlist("cmdlist", [](const Args& a) {
std::vector<Cmd*> v(Cmd::all().begin(), Cmd::all().end());
std::sort(v.begin(), v.end(), [](Cmd* x, Cmd* y) { return std::string_view(x->name()) < y->name(); });
int n = 0;
for (Cmd* c : v)
if (a.size() < 2 || std::string_view(c->name()).starts_with(a[1])) log::info(" {:<20} {}", c->name(), c->help()), ++n;
log::info("{} commands", n);
}, "cmdlist [prefix]: every command and what it does");
static Cmd cmd_differences("differences", [](const Args&) {
int n = 0;
for (Cvar* c : Cvar::all())
if (c->str() != c->defaultValue()) log::info(" {} = \"{}\" (default \"{}\")", c->name(), c->str(), c->defaultValue()), ++n;
log::info("{} changed from their defaults", n);
}, "Cvars that aren't at their default value");
static Cmd cmd_reset("reset", [](const Args& a) {
if (a.size() < 2) return log::info("usage: reset <cvar>");
if (Cvar* c = Cvar::find(a[1])) c->set(c->defaultValue()), log::info("{} = \"{}\"", c->name(), c->str());
else log::warn("Unknown cvar \"{}\"", a[1]);
}, "reset <cvar>: back to its default value");
static Cmd cmd_alias("alias", [](const Args& a) {
auto& list = aliasList();
if (a.size() < 2) {
for (const auto& [name, text] : list) log::info(" {} = \"{}\"", name, text);
return log::info("{} aliases", list.size());
}
std::erase_if(list, [&](const auto& x) { return x.first == a[1]; });
if (a.size() < 3) return;
std::string text;
for (size_t i = 2; i < a.size(); ++i) text += (i > 2 ? " " : "") + a[i];
list.emplace_back(a[1], text);
}, "alias <name> \"commands; more commands\": make a new command (alias <name> alone removes it)");
static Cmd cmd_clear("clear", [](const Args&) { log::clearHistory(); }, "Clear the console");
static Cmd cmd_condump("condump", [](const Args& a) {
const std::string path = a.size() > 1 ? a[1] : "condump.txt";
std::FILE* f = std::fopen(path.c_str(), "w");
if (!f) return log::warn("Can't write {}", path);
for (const log::Line& l : log::history()) std::fprintf(f, "%s\n", l.text.c_str());
std::fclose(f);
log::info("Console written to {}", path);
}, "condump [file]: save the console's text to a file");
// What the console suggests for their arguments.
[[maybe_unused]] static const bool g_completers = [] {
cmd_help.complete(names());
for (Cmd* c : {&cmd_reset, &cmd_toggle, &cmd_incrementvar, &cmd_set}) c->complete(cvarNames());
return true;
}();
} // namespace keel

101
engine/core/cvar.h Normal file
View file

@ -0,0 +1,101 @@
#pragma once
// Quake-style console variables and commands.
//
// static keel::Cvar r_vsync("r_vsync", "1", keel::CVAR_ARCHIVE, "Wait for vertical sync");
// static keel::Cmd quit("quit", [](const keel::Args&) { ... }, "Quit");
//
// Both register themselves at static-init time and are reachable from the console.
#include <functional>
#include <utility>
#include <string>
#include <string_view>
#include <vector>
namespace keel {
enum CvarFlags : unsigned {
CVAR_NONE = 0,
CVAR_ARCHIVE = 1 << 0, // saved to config.cfg
CVAR_CHEAT = 1 << 1, // needs sv_cheats 1
CVAR_REPLICATE = 1 << 2, // server value is sent to clients
};
class Cvar {
public:
Cvar(const char* name, const char* default_value, unsigned flags, const char* help);
~Cvar(); // unregisters (scripts create and destroy cvars at runtime)
Cvar(const Cvar&) = delete;
Cvar& operator=(const Cvar&) = delete;
const char* name() const { return name_; }
const char* help() const { return help_; }
const std::string& defaultValue() const { return default_; }
unsigned flags() const { return flags_; }
const std::string& str() const { return value_; }
float f() const { return float_; }
int i() const { return int(float_); }
bool b() const { return float_ != 0.0f; }
void set(std::string_view value);
// True once after the value changed; lets systems react (e.g. recreate the swapchain).
bool consumeModified();
static Cvar* find(std::string_view name);
static const std::vector<Cvar*>& all();
private:
const char* name_;
const char* help_;
std::string default_;
std::string value_;
float float_ = 0.0f;
unsigned flags_;
bool modified_ = true;
};
using Args = std::vector<std::string>; // args[0] is the command name
class Cmd {
public:
using Fn = std::function<void(const Args&)>;
// Suggestions for the argument being typed (the console's list): given what's typed of
// it and which argument it is (1 = the first), the values it could be. Set with complete().
using Completer = std::function<std::vector<std::string>(std::string_view partial, size_t arg)>;
Cmd(const char* name, Fn fn, const char* help);
~Cmd();
Cmd(const Cmd&) = delete;
Cmd& operator=(const Cmd&) = delete;
const char* name() const { return name_; }
const char* help() const { return help_; }
void run(const Args& args) const { fn_(args); }
Cmd& complete(Completer c) { completer_ = std::move(c); return *this; }
const Completer& completer() const { return completer_; }
static Cmd* find(std::string_view name);
static const std::vector<Cmd*>& all();
private:
const char* name_;
const char* help_;
Fn fn_;
Completer completer_;
};
// alias name "commands": a new command that runs them (Quake/Source).
const std::vector<std::pair<std::string, std::string>>& aliases();
// Every command, cvar and alias name, sorted (for completion).
std::vector<std::string> allNames();
// The names of things in `names` that start with `partial` (completers use it).
std::vector<std::string> matching(const std::vector<std::string>& names, std::string_view partial);
// Runs a line of console text: "cmd args; name value; name". "wait [frames]" defers
// the rest of the line (Quake style), e.g. "map test; wait 120; screenshot".
void execute(std::string_view text);
// Runs text deferred by "wait" whose frames have passed. Called once per frame.
void executeDeferred();
// Splits a line into arguments, honouring "quoted strings".
Args tokenize(std::string_view line);
} // namespace keel

33
engine/core/log.cpp Normal file
View file

@ -0,0 +1,33 @@
#include "core/log.h"
#include <cstdio>
#include <mutex>
#include <string>
#if defined(__ANDROID__)
#include <android/log.h>
#endif
namespace keel::log {
namespace {
constexpr size_t kMaxLines = 4096;
std::mutex g_mutex;
std::vector<Line> g_lines;
} // namespace
void write(Level level, std::string_view text) {
static const char* const prefix[] = {"", "WARN: ", "ERROR: "};
std::lock_guard lock(g_mutex);
std::fprintf(level == Level::Info ? stdout : stderr, "%s%.*s\n", prefix[int(level)], int(text.size()), text.data());
std::fflush(level == Level::Info ? stdout : stderr);
#if defined(__ANDROID__) // (a phone has no console: adb logcat -s Fry3DX shows it)
static const int priority[] = {ANDROID_LOG_INFO, ANDROID_LOG_WARN, ANDROID_LOG_ERROR};
__android_log_print(priority[int(level)], "Fry3DX", "%.*s", int(text.size()), text.data());
#endif
if (g_lines.size() >= kMaxLines) g_lines.erase(g_lines.begin(), g_lines.begin() + kMaxLines / 4);
g_lines.push_back({level, std::string(text)});
}
const std::vector<Line>& history() { return g_lines; }
void clearHistory() { g_lines.clear(); }
} // namespace keel::log

25
engine/core/log.h Normal file
View file

@ -0,0 +1,25 @@
#pragma once
#include <format>
#include <string>
#include <string_view>
#include <vector>
namespace keel::log {
enum class Level { Info, Warn, Error };
struct Line {
Level level;
std::string text;
};
void write(Level level, std::string_view text);
// The last few thousand lines, for the console.
const std::vector<Line>& history();
void clearHistory();
template <class... A> void info(std::format_string<A...> f, A&&... a) { write(Level::Info, std::format(f, std::forward<A>(a)...)); }
template <class... A> void warn(std::format_string<A...> f, A&&... a) { write(Level::Warn, std::format(f, std::forward<A>(a)...)); }
template <class... A> void error(std::format_string<A...> f, A&&... a) { write(Level::Error, std::format(f, std::forward<A>(a)...)); }
} // namespace keel::log

21
engine/core/pch.h Normal file
View file

@ -0,0 +1,21 @@
#pragma once
// Precompiled for the engine's libraries (engine/CMakeLists.txt): the standard library and glm
// headers nearly every file pulls in, parsed once per library instead of once per file.
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <format>
#include <functional>
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/quaternion.hpp>

117
engine/core/sysinfo.cpp Normal file
View file

@ -0,0 +1,117 @@
#include "core/sysinfo.h"
#include <cstdio>
#include <cstring>
#include <fstream>
#include <thread>
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#define PSAPI_VERSION 2 // (K32GetProcessMemoryInfo, in kernel32)
#include <windows.h>
#include <psapi.h>
#else
#include <sys/utsname.h>
#include <unistd.h>
#endif
#if defined(_MSC_VER)
#include <intrin.h>
#elif defined(__x86_64__) || defined(__i386__)
#include <cpuid.h>
#endif
namespace keel::sys {
namespace {
std::string trim(std::string s) {
while (!s.empty() && (s.back() == ' ' || s.back() == '\n' || s.back() == '\r' || s.back() == '\0')) s.pop_back();
size_t i = 0;
while (i < s.size() && s[i] == ' ') ++i;
return s.substr(i);
}
// The CPU's own name for itself (x86: cpuid's brand string; elsewhere /proc/cpuinfo).
std::string cpuName() {
#if defined(_MSC_VER) || defined(__x86_64__) || defined(__i386__)
unsigned regs[12] = {};
for (unsigned i = 0; i < 3; ++i) {
#if defined(_MSC_VER)
int r[4];
__cpuid(r, int(0x80000002u + i));
std::memcpy(regs + i * 4, r, sizeof r);
#else
__get_cpuid(0x80000002u + i, &regs[i * 4], &regs[i * 4 + 1], &regs[i * 4 + 2], &regs[i * 4 + 3]);
#endif
}
char brand[49] = {};
std::memcpy(brand, regs, 48);
if (brand[0]) return trim(brand);
#endif
std::ifstream in("/proc/cpuinfo");
for (std::string line; std::getline(in, line);)
for (const char* key : {"model name", "Hardware", "Processor"})
if (line.rfind(key, 0) == 0)
if (const size_t c = line.find(':'); c != std::string::npos) return trim(line.substr(c + 1));
return "unknown CPU";
}
std::string osName() {
#if defined(_WIN32)
// GetVersionEx lies to programs without a manifest; ntdll's RtlGetVersion doesn't.
using RtlGetVersionFn = LONG(WINAPI*)(PRTL_OSVERSIONINFOW);
RTL_OSVERSIONINFOW v{sizeof v};
if (auto fn = reinterpret_cast<RtlGetVersionFn>(reinterpret_cast<void*>(GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "RtlGetVersion"))))
fn(&v);
const char* name = v.dwMajorVersion == 10 ? (v.dwBuildNumber >= 22000 ? "Windows 11" : "Windows 10") : "Windows";
return std::string(name) + " (build " + std::to_string(v.dwBuildNumber) + ")";
#else
utsname u{};
uname(&u);
std::string pretty;
std::ifstream in("/etc/os-release");
for (std::string line; std::getline(in, line);)
if (line.rfind("PRETTY_NAME=", 0) == 0) {
pretty = line.substr(12);
if (pretty.size() >= 2 && pretty.front() == '"') pretty = pretty.substr(1, pretty.size() - 2);
}
#if defined(__ANDROID__)
if (pretty.empty()) pretty = "Android";
#endif
if (pretty.empty()) pretty = u.sysname;
return pretty + " (" + u.sysname + " " + u.release + ")";
#endif
}
} // namespace
const Machine& machine() {
static const Machine m{osName(), cpuName(), int(std::thread::hardware_concurrency())};
return m;
}
Memory memory() {
Memory m;
#if defined(_WIN32)
MEMORYSTATUSEX s{sizeof s};
if (GlobalMemoryStatusEx(&s)) m.total = s.ullTotalPhys, m.used = s.ullTotalPhys - s.ullAvailPhys;
PROCESS_MEMORY_COUNTERS pmc{sizeof pmc};
if (K32GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof pmc)) m.ours = pmc.WorkingSetSize;
#else
uint64_t available = 0;
std::ifstream in("/proc/meminfo");
for (std::string line; std::getline(in, line);) {
unsigned long long kb = 0;
if (std::sscanf(line.c_str(), "MemTotal: %llu kB", &kb) == 1) m.total = kb * 1024;
else if (std::sscanf(line.c_str(), "MemAvailable: %llu kB", &kb) == 1) available = kb * 1024;
}
m.used = m.total > available ? m.total - available : 0;
long pages = 0, resident = 0;
if (std::FILE* f = std::fopen("/proc/self/statm", "r")) {
if (std::fscanf(f, "%ld %ld", &pages, &resident) != 2) resident = 0;
std::fclose(f);
}
m.ours = uint64_t(resident) * uint64_t(sysconf(_SC_PAGESIZE));
#endif
return m;
}
} // namespace keel::sys

21
engine/core/sysinfo.h Normal file
View file

@ -0,0 +1,21 @@
#pragma once
// What machine this is (the debug overlay's corner, crash reports): the OS, the CPU, memory.
#include <cstdint>
#include <string>
namespace keel::sys {
struct Machine {
std::string os; // "NixOS 25.11 (Linux 6.18.54)", "Windows 11 (build 26100)"
std::string cpu; // "AMD Ryzen 7 5700G with Radeon Graphics"
int threads = 0;
};
const Machine& machine(); // (worked out once)
struct Memory {
uint64_t total = 0, used = 0; // the whole machine's RAM (used: not available to programs)
uint64_t ours = 0; // this process's resident memory
};
Memory memory();
} // namespace keel::sys

13
engine/core/time.h Normal file
View file

@ -0,0 +1,13 @@
#pragma once
#include <chrono>
namespace keel {
// Seconds since program start, monotonic.
inline double now() {
using clock = std::chrono::steady_clock;
static const clock::time_point start = clock::now();
return std::chrono::duration<double>(clock::now() - start).count();
}
} // namespace keel

394
engine/crash.cpp Normal file
View file

@ -0,0 +1,394 @@
#include "crash.h"
#include <core/log.h>
#include <SDL3/SDL.h>
#include <algorithm>
#include <atomic>
#include <cmath>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <exception>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <thread>
#include <vector>
#ifdef _WIN32
#include <process.h>
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <windows.h>
#else
#include <csignal>
#if !defined(__ANDROID__) // (bionic: backtrace() from Android 13 only)
#include <execinfo.h>
#endif
#include <fcntl.h>
#include <pthread.h>
#include <unistd.h>
#endif
namespace keel::crash {
namespace {
std::string g_report, g_exe;
std::vector<std::string> g_args; // reporter's command line (exe -crashreport file -- args)
std::vector<char*> g_argv; // ...as pointers, made before anything goes wrong
std::atomic<double> g_beat{0.0};
std::atomic<int> g_busy{0};
std::atomic<bool> g_fired{false};
#ifndef _WIN32
pthread_t g_main;
#endif
double seconds() { return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count(); }
// The report: a headline, then the backtrace and the end of the log.
void writeReport(const char* what) {
#ifdef _WIN32
FILE* f = std::fopen(g_report.c_str(), "w");
if (!f) return;
std::fprintf(f, "%s\n\n", what);
const auto& h = log::history();
std::fprintf(f, "--- last log lines ---\n");
for (size_t i = h.size() > 80 ? h.size() - 80 : 0; i < h.size(); ++i) std::fprintf(f, "%s\n", h[i].text.c_str());
std::fclose(f);
#else
const int fd = open(g_report.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) return;
auto put = [&](const char* s) { (void)!::write(fd, s, std::strlen(s)); };
put(what);
put("\n\n--- backtrace ---\n");
#if defined(__ANDROID__)
put("(none on Android: see logcat)\n");
#else
void* frames[64];
backtrace_symbols_fd(frames, backtrace(frames, 64), fd);
#endif
put("\n--- last log lines ---\n");
const auto& h = log::history(); // best effort: we're crashing anyway
for (size_t i = h.size() > 80 ? h.size() - 80 : 0; i < h.size(); ++i) put(h[i].text.c_str()), put("\n");
close(fd);
#endif
}
void launchReporter() {
#ifdef _WIN32
_spawnv(_P_NOWAIT, g_exe.c_str(), g_argv.data());
#elif defined(__ANDROID__)
// (an app is a library in the Java runtime: no second process to report with; the report
// file and logcat have it)
#else
if (fork() == 0) {
setsid(); // on its own: whatever waited on the app (a terminal, a pipe) isn't kept waiting
if (const int null = open("/dev/null", O_RDWR); null >= 0) dup2(null, 0), dup2(null, 1), dup2(null, 2);
execv(g_exe.c_str(), g_argv.data());
_exit(127);
}
#endif
}
[[noreturn]] void fail(const char* what) {
if (!g_fired.exchange(true)) {
writeReport(what);
launchReporter();
}
#ifdef _WIN32
TerminateProcess(GetCurrentProcess(), 3);
#endif
_exit(3);
}
#ifdef _WIN32
LONG WINAPI onException(EXCEPTION_POINTERS* e) {
char what[128];
std::snprintf(what, sizeof what, "Fry3DX crashed (exception 0x%08lX).", e->ExceptionRecord->ExceptionCode);
fail(what);
}
#else
void onSignal(int sig) {
const char* what = sig == SIGSEGV ? "Fry3DX crashed: it touched memory it shouldn't have (SIGSEGV)."
: sig == SIGBUS ? "Fry3DX crashed: a bad memory access (SIGBUS)."
: sig == SIGFPE ? "Fry3DX crashed: a maths error, like dividing by zero (SIGFPE)."
: sig == SIGILL ? "Fry3DX crashed: an illegal instruction (SIGILL)."
: sig == SIGABRT ? "Fry3DX crashed: it gave up on an error it couldn't handle (abort)."
: sig == SIGUSR2 ? "Fry3DX froze: no frame for a long time (the watchdog stopped it). Backtrace of the stuck thread:"
: "Fry3DX crashed.";
fail(what);
}
#endif
bool debuggerAttached() {
#ifdef _WIN32
return IsDebuggerPresent();
#else
std::ifstream f("/proc/self/status");
for (std::string line; std::getline(f, line);)
if (line.rfind("TracerPid:", 0) == 0) return std::atoi(line.c_str() + 10) != 0;
return false;
#endif
}
void watchdog() {
constexpr double kTimeout = 45.0;
for (;;) {
std::this_thread::sleep_for(std::chrono::seconds(1));
if (g_busy > 0 || g_beat == 0.0) {
g_beat = seconds();
continue;
}
if (seconds() - g_beat < kTimeout || debuggerAttached()) continue;
#ifndef _WIN32
pthread_kill(g_main, SIGUSR2); // its handler reports from the stuck thread (its backtrace)
std::this_thread::sleep_for(std::chrono::seconds(3));
#endif
fail("Fry3DX froze: no frame for a long time (the watchdog stopped it).");
}
}
} // namespace
void install(const std::string& prefPath, const std::vector<std::string>& args, bool dog) {
g_report = prefPath + "crash.txt";
#ifdef _WIN32
char exe[1024] = {};
GetModuleFileNameA(nullptr, exe, sizeof exe);
g_exe = exe;
#else
g_exe = std::filesystem::read_symlink("/proc/self/exe").string();
g_main = pthread_self();
#endif
g_args = {g_exe, "-crashreport", g_report, "--"};
for (size_t i = 1; i < args.size(); ++i) g_args.push_back(args[i]);
#ifdef _WIN32
for (std::string& x : g_args)
if (x.find(' ') != std::string::npos) x = "\"" + x + "\"";
#endif
for (std::string& a : g_args) g_argv.push_back(a.data());
g_argv.push_back(nullptr);
#ifdef _WIN32
SetUnhandledExceptionFilter(onException);
#else
// On a stack of its own, so a stack overflow can still be reported.
static std::vector<char> altStack(1 << 16);
stack_t ss{};
ss.ss_sp = altStack.data(), ss.ss_size = altStack.size();
sigaltstack(&ss, nullptr);
struct sigaction sa{};
sa.sa_handler = onSignal;
sa.sa_flags = SA_ONSTACK;
for (int sig : {SIGSEGV, SIGBUS, SIGFPE, SIGILL, SIGABRT, SIGUSR2}) sigaction(sig, &sa, nullptr);
#endif
std::set_terminate([] {
std::string what = "Fry3DX crashed: an error nobody caught";
try {
if (auto e = std::current_exception()) std::rethrow_exception(e);
} catch (const std::exception& e) {
what += std::string(" (") + e.what() + ")";
} catch (...) {
}
what += ".";
fail(what.c_str());
});
if (dog) std::thread(watchdog).detach();
}
void heartbeat() { g_beat = seconds(); }
Busy::Busy(const char*) { ++g_busy; }
Busy::~Busy() {
heartbeat();
--g_busy;
}
// ── The crash screen: SDL's 2D renderer and its built-in 8×8 font, scaled up ──
int runReporter(int argc, char** argv) {
if (argc < 3 || std::strcmp(argv[1], "-crashreport") != 0) return -1;
std::string report;
{
std::ifstream f(argv[2]);
std::stringstream ss;
ss << f.rdbuf();
report = ss.str();
}
std::vector<std::string> restart;
for (int i = 3; i < argc; ++i)
if (std::strcmp(argv[i], "--") != 0) restart.emplace_back(argv[i]);
std::vector<std::string> lines;
{
std::stringstream ss(report);
for (std::string l; std::getline(ss, l);) lines.push_back(l);
}
const std::string headline = lines.empty() ? "Fry3DX crashed." : lines[0];
if (!SDL_Init(SDL_INIT_VIDEO)) return 1;
SDL_Window* win = nullptr;
SDL_Renderer* ren = nullptr;
if (!SDL_CreateWindowAndRenderer("Fry3DX - something got burnt", 1000, 620, SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY, &win, &ren)) return 1;
SDL_SetRenderVSync(ren, 1);
SDL_SetRenderDrawBlendMode(ren, SDL_BLENDMODE_BLEND);
auto text = [&](float x, float y, float scale, const std::string& s, SDL_Color c) {
SDL_SetRenderScale(ren, scale, scale);
SDL_SetRenderDrawColor(ren, c.r, c.g, c.b, c.a);
SDL_RenderDebugText(ren, x / scale, y / scale, s.c_str());
SDL_SetRenderScale(ren, 1, 1);
};
// Shapes for the picture: an ellipse (filled), a box.
auto ellipse = [&](float cx, float cy, float rx, float ry, SDL_FColor c) {
std::vector<SDL_Vertex> v;
const int n = 48;
for (int i = 0; i < n; ++i) {
const float a0 = 6.2831853f * i / n, a1 = 6.2831853f * (i + 1) / n;
v.push_back({{cx, cy}, c, {}});
v.push_back({{cx + std::cos(a0) * rx, cy + std::sin(a0) * ry}, c, {}});
v.push_back({{cx + std::cos(a1) * rx, cy + std::sin(a1) * ry}, c, {}});
}
SDL_RenderGeometry(ren, nullptr, v.data(), int(v.size()), nullptr, 0);
};
auto quad = [&](SDL_FPoint a, SDL_FPoint b, SDL_FPoint c, SDL_FPoint d, SDL_FColor col) {
const SDL_Vertex v[6] = {{a, col, {}}, {b, col, {}}, {c, col, {}}, {a, col, {}}, {c, col, {}}, {d, col, {}}};
SDL_RenderGeometry(ren, nullptr, v, 6, nullptr, 0);
};
auto rgb = [](int r, int g, int b, float a = 1.0f) { return SDL_FColor{r / 255.0f, g / 255.0f, b / 255.0f, a}; };
// Word wrap for the 8-pixel font.
auto wrap = [](const std::string& s, size_t cols) {
std::vector<std::string> out;
std::string line;
std::stringstream ss(s);
for (std::string w; ss >> w;) {
if (!line.empty() && line.size() + 1 + w.size() > cols) out.push_back(line), line.clear();
line += (line.empty() ? "" : " ") + w;
}
if (!line.empty()) out.push_back(line);
return out;
};
const SDL_Color ink{236, 232, 226, 255}, dim{150, 146, 140, 255}, orange{255, 140, 60, 255};
int scroll = 0, result = 0;
std::string toast;
double toastUntil = 0;
for (bool running = true; running;) {
int w = 0, h = 0;
SDL_GetRenderOutputSize(ren, &w, &h);
const float s = std::min(h / 620.0f, w / 1000.0f);
const double t = SDL_GetTicks() / 1000.0;
struct Button { const char* label; float x; } buttons[] = {{"OPEN IT AGAIN", 60 * s}, {"COPY REPORT", 290 * s}, {"REPORT ON GITHUB", 520 * s}, {"CLOSE", 750 * s}};
const float by = h - 90 * s, bw = 210 * s, bh = 50 * s;
float mx = 0, my = 0;
SDL_GetMouseState(&mx, &my);
float px = 1, py = 1; // window → pixels
{
int ww, wh;
SDL_GetWindowSize(win, &ww, &wh);
px = float(w) / ww, py = float(h) / wh;
}
mx *= px, my *= py;
int clicked = -1;
for (SDL_Event e; SDL_PollEvent(&e);) {
if (e.type == SDL_EVENT_QUIT) running = false;
if (e.type == SDL_EVENT_KEY_DOWN) {
if (e.key.key == SDLK_ESCAPE || e.key.key == SDLK_Q) running = false;
if (e.key.key == SDLK_R) clicked = 0;
if (e.key.key == SDLK_C) clicked = 1;
}
if (e.type == SDL_EVENT_MOUSE_WHEEL) scroll = std::max(0, scroll - int(e.wheel.y * 3));
if (e.type == SDL_EVENT_MOUSE_BUTTON_DOWN && e.button.button == SDL_BUTTON_LEFT)
for (int i = 0; i < 4; ++i)
if (mx >= buttons[i].x && mx < buttons[i].x + bw && my >= by && my < by + bh) clicked = i;
}
if (clicked == 0) { // opened again as it was started (it offers the autosave back)
std::vector<char*> a;
std::string exe = argv[0];
a.push_back(exe.data());
for (std::string& r : restart) a.push_back(r.data());
a.push_back(nullptr);
SDL_DestroyRenderer(ren);
SDL_DestroyWindow(win);
SDL_Quit();
#ifdef _WIN32
_spawnv(_P_NOWAIT, exe.c_str(), a.data());
return 0;
#else
execv(std::filesystem::read_symlink("/proc/self/exe").c_str(), a.data());
return 1;
#endif
}
if (clicked == 1 || clicked == 2) {
SDL_SetClipboardText(report.c_str());
toast = clicked == 1 ? "Copied: paste it into a bug report." : "Copied: paste it into the new issue.";
toastUntil = t + 3.0;
if (clicked == 2) SDL_OpenURL("https://github.com/Nexoniarz/Fry3DX/issues/new");
}
if (clicked == 3) running = false;
SDL_SetRenderDrawColor(ren, 22, 22, 25, 255);
SDL_RenderClear(ren);
// The picture, top right: a pan, a cube on it burnt black, smoke going up.
{
const float cx = w - 230 * s, cy = 170 * s;
quad({cx + 80 * s, cy - 6 * s}, {cx + 200 * s, cy - 30 * s}, {cx + 204 * s, cy - 16 * s}, {cx + 84 * s, cy + 8 * s}, rgb(58, 58, 64)); // handle
ellipse(cx, cy + 10 * s, 105 * s, 34 * s, rgb(34, 34, 38));
ellipse(cx, cy, 105 * s, 34 * s, rgb(70, 70, 78));
ellipse(cx, cy + 2 * s, 88 * s, 26 * s, rgb(28, 28, 32));
for (int i = 0; i < 3; ++i) { // flames under it
const float f = float(std::sin(t * 7 + i * 2.1) * 0.5 + 0.5);
const float fx = cx + (i - 1) * 50 * s;
ellipse(fx, cy + 46 * s, (14 + 4 * f) * s, (12 + 6 * f) * s, rgb(255, 74, 28, 0.85f));
ellipse(fx, cy + 48 * s, 7 * s, (6 + 4 * f) * s, rgb(255, 194, 74, 0.9f));
}
const float k = 26 * s; // the cube (seen from a corner), charred
quad({cx, cy - 2 * k}, {cx + k, cy - 1.5f * k}, {cx, cy - k}, {cx - k, cy - 1.5f * k}, rgb(64, 52, 46));
quad({cx - k, cy - 1.5f * k}, {cx, cy - k}, {cx, cy}, {cx - k, cy - 0.5f * k}, rgb(40, 32, 28));
quad({cx, cy - k}, {cx + k, cy - 1.5f * k}, {cx + k, cy - 0.5f * k}, {cx, cy}, rgb(26, 21, 19));
for (int i = 0; i < 6; ++i) { // smoke, rising and fading
const double life = std::fmod(t * 0.45 + i / 6.0, 1.0);
const float sx = cx + float(std::sin(life * 6 + i) * 18 * s), sy = cy - 2.2f * k - float(life * 80 * s);
ellipse(sx, sy, float(10 + life * 22) * s, float(8 + life * 16) * s, rgb(150, 150, 158, float(0.35 * (1 - life))));
}
}
text(60 * s, 60 * s, 4 * s, "Fry3DX", orange);
text(60 * s, 110 * s, 2.5f * s, "Something got burnt.", ink);
float y = 160 * s;
for (const std::string& l : wrap(headline, size_t((w - 400 * s) / (16 * s))))
text(60 * s, y, 2 * s, l, ink), y += 24 * s;
y += 6 * s;
for (const std::string& l : wrap("Your work isn't lost: Open it again and Fry3DX offers back the last autosave.", size_t((w - 400 * s) / (12 * s))))
text(60 * s, y, 1.5f * s, l, {130, 210, 120, 255}), y += 18 * s;
text(60 * s, y + 4 * s, 1.25f * s, "Report saved to " + std::string(argv[2]), dim);
y += 30 * s;
// The report's tail, in a panel, scrollable.
const SDL_FRect panel{50 * s, y - 8 * s, w - 100 * s, by - 20 * s - (y - 8 * s)};
SDL_SetRenderDrawColor(ren, 15, 15, 17, 255);
SDL_RenderFillRect(ren, &panel);
SDL_SetRenderDrawColor(ren, 255, 120, 40, 255);
const SDL_FRect edge{panel.x, panel.y, 3 * s, panel.h};
SDL_RenderFillRect(ren, &edge);
const int visible = std::max(1, int((panel.h - 10 * s) / (13 * s)));
const int start = std::clamp(int(lines.size()) - visible - scroll, 1, std::max(1, int(lines.size()) - 1));
for (int i = start; i < int(lines.size()) && y < by - 30 * s; ++i, y += 13 * s)
text(64 * s, y, 1.25f * s, lines[size_t(i)].substr(0, size_t((w - 140 * s) / (10 * s))), {165, 160, 152, 255});
for (int i = 0; i < 4; ++i) {
const bool hover = mx >= buttons[i].x && mx < buttons[i].x + bw && my >= by && my < by + bh;
SDL_FRect r{buttons[i].x, by, bw, bh};
if (i == 0 || hover) SDL_SetRenderDrawColor(ren, hover ? 255 : 220, hover ? 140 : 100, hover ? 60 : 30, 255);
else SDL_SetRenderDrawColor(ren, 46, 46, 52, 255);
SDL_RenderFillRect(ren, &r);
const float n = float(std::strlen(buttons[i].label)), ts = std::min(1.75f * s, (bw - 24 * s) / (n * 8)); // (fits its button)
text(buttons[i].x + (bw - n * 8 * ts) / 2, by + bh / 2 - 4 * ts, ts, buttons[i].label, ink);
}
text(60 * s, by + bh + 12 * s, 1.25f * s, "R open again C copy Esc close", dim);
if (t < toastUntil) text(60 * s, by - 16 * s, 1.5f * s, toast, orange);
if (const char* shot = std::getenv("KEEL_CRASHSHOT")) { // for checking the screen: one frame to a file, then quit
if (SDL_Surface* px = SDL_RenderReadPixels(ren, nullptr)) SDL_SaveBMP(px, shot), SDL_DestroySurface(px);
running = false;
}
SDL_RenderPresent(ren);
}
SDL_DestroyRenderer(ren);
SDL_DestroyWindow(win);
SDL_Quit();
return result;
}
} // namespace keel::crash

25
engine/crash.h Normal file
View file

@ -0,0 +1,25 @@
#pragma once
// Crashes and freezes end on a crash screen instead of a frozen or vanished window: a crash
// (segfault, abort, an uncaught exception) or a freeze (no frame for a while: the watchdog)
// writes a report (what happened, a backtrace, the last log lines) to <pref>/crash.txt and
// starts the program again as `-crashreport <file>`, which shows ":(" with what happened and
// Restart / Copy report / Quit. Off under a debugger and with -nowatchdog (the watchdog only).
#include <string>
#include <vector>
namespace keel::crash {
// After the pref path is known; args: the command line, for Restart.
void install(const std::string& prefPath, const std::vector<std::string>& args, bool watchdog);
// Once a frame: the main loop is alive.
void heartbeat();
// While something on the main thread is slow on purpose (building, loading), the watchdog waits.
struct Busy {
explicit Busy(const char* what);
~Busy();
};
// `-crashreport <file> [-- args]`: shows the crash screen and returns the exit code; -1 if the
// command line isn't one.
int runReporter(int argc, char** argv);
} // namespace keel::crash

580
engine/engine.cpp Normal file
View file

@ -0,0 +1,580 @@
#include "engine.h"
#include "crash.h"
#include "audio/audio.h"
#include "core/console.h"
#include "core/cvar.h"
#include "core/log.h"
#include "core/time.h"
#include "fs/vfs.h"
#include "platform/input.h"
#include "platform/touch.h"
#include "platform/window.h"
#include "render/renderer.h"
#include "render/screenshot.h"
#include "ui/ui.h"
#include <keel_version.h>
#include <yapf.h>
#include <SDL3/SDL.h>
#include <imgui.h>
#include <imgui_impl_sdl3.h>
#include <stb_image.h>
#include <algorithm>
#include <csignal>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
#include <thread>
#include <stdexcept>
#include <vector>
#ifdef _WIN32
#include <process.h>
extern "C" __declspec(dllimport) unsigned long __stdcall GetModuleFileNameA(void*, char*, unsigned long);
#else
#include <unistd.h>
#endif
namespace keel {
namespace {
volatile std::sig_atomic_t g_quit = false;
bool g_dedicated = false;
// -test: for automated visual checks while someone uses the machine. The window doesn't
// take focus, ignores mouse and keyboard, plays no sound and never saves the config.
bool g_testMode = false;
std::string g_configPath;
#ifdef NDEBUG
Cvar r_validation("r_validation", "0", CVAR_ARCHIVE, "Enable Vulkan validation layers (restart needed)");
#else
Cvar r_validation("r_validation", "1", CVAR_ARCHIVE, "Enable Vulkan validation layers (restart needed)");
#endif
Cvar vid_mode("vid_mode", "0", CVAR_ARCHIVE, "0 = window, 1 = borderless fullscreen, 2 = fullscreen");
Cvar vid_width("vid_width", "1600", CVAR_ARCHIVE, "Window / fullscreen width");
Cvar vid_height("vid_height", "900", CVAR_ARCHIVE, "Window / fullscreen height");
Cvar r_renderer("r_renderer", "vulkan", CVAR_ARCHIVE,
"Graphics API: vulkan, opengl (4.6), or the cut-down fallbacks for old GPUs: opengl40, opengl31 (restart needed)");
Cvar hotreload("hotreload", "1", CVAR_ARCHIVE,
"Reload textures, models, sounds, scripts and the map when their files change (with tools/watch.py compiling as you save)");
Cvar fps_max_menu("fps_max_menu", "120", CVAR_ARCHIVE, "Frame rate limit in menus and while paused (0 = same as fps_max)");
Cvar fps_max_nofocus("fps_max_nofocus", "30", CVAR_ARCHIVE, "Frame rate limit while another window has focus (10 when minimized; 0 = no limit)");
Cvar fps_max("fps_max", "0", CVAR_ARCHIVE, "Frame rate limit (0 = none; vsync also limits it)");
Window* g_window = nullptr;
Cmd cmd_vid_modes("vid_modes", [](const Args&) {
if (!g_window) return;
for (auto [w, h] : g_window->resolutions()) log::info(" {}x{}", w, h);
}, "List the display's fullscreen resolutions");
Cvar r_speeds("r_speeds", "0", CVAR_NONE, "Show what the renderer draws each frame (draws, culled, triangles, shadows)");
Cvar cl_showfps("cl_showfps", "0", CVAR_ARCHIVE, "Show frame rate in the corner");
Cmd cmd_quit("quit", [](const Args&) { g_quit = true; }, "Quit");
Cmd cmd_exit("exit", [](const Args&) { g_quit = true; }, "Quit");
Renderer* g_renderer = nullptr;
std::string g_prefPath;
Cmd cmd_version("version", [](const Args&) {
log::info("Fry3DX, build {} from {}, {} renderer", KEEL_COMMIT, KEEL_COMMIT_DATE, g_renderer ? g_renderer->apiName() : "no");
}, "Engine version and build date");
Cmd cmd_screenshot("screenshot", [](const Args& a) {
if (!g_renderer) return;
std::string name = a.size() > 1 ? a[1] : "";
if (name.empty()) {
char stamp[32];
const std::time_t now = std::time(nullptr);
std::strftime(stamp, sizeof stamp, "%Y%m%d_%H%M%S", std::localtime(&now));
name = std::string("shot_") + stamp + ".png";
}
if (!name.ends_with(".png")) name += ".png";
g_renderer->requestScreenshot(std::filesystem::path(name).is_absolute() ? name : g_prefPath + "screenshots/" + name);
}, "Save the screen as a PNG: screenshot [name or absolute path]");
Cmd cmd_exec("exec", [](const Args& a) {
if (a.size() < 2) return log::info("usage: exec <file>");
// Game files first (cfg/autoexec.cfg), then a plain path on disk.
std::optional<std::string> text = fs::readText(a[1]);
if (!text) text = fs::readText("cfg/" + a[1]);
if (!text) {
std::ifstream in(a[1]);
if (!in) return log::warn("Couldn't exec {}", a[1]);
std::stringstream ss;
ss << in.rdbuf();
text = ss.str();
}
execute(*text);
}, "Run commands from a file");
void saveConfig() {
if (g_testMode) return; // (automated runs mustn't change the player's settings)
std::ofstream out(g_configPath);
out << "// Written on exit - edit autoexec.cfg instead.\n";
if (!g_dedicated) out << input::bindingsConfig();
for (Cvar* c : Cvar::all())
if ((c->flags() & CVAR_ARCHIVE) && c->str() != c->defaultValue())
out << c->name() << " \"" << c->str() << "\"\n";
}
// Typing, for automated tests of text boxes: real key and text events through the event loop.
// sdl_type "hello" or keys in braces: {enter} {esc} {y}.
int g_typedEvents = 0; // sdl_type's, let through in -test
Cmd cmd_sdl_type("sdl_type", [](const Args& a) {
static std::vector<std::string> texts; // (SDL keeps a pointer to the text until it's handled)
std::string s;
for (size_t i = 1; i < a.size(); ++i) s += (i > 1 ? " " : "") + a[i];
auto key = [](SDL_Scancode sc, bool down) {
SDL_Event e{};
e.type = down ? SDL_EVENT_KEY_DOWN : SDL_EVENT_KEY_UP;
e.key.scancode = sc;
e.key.key = SDL_GetKeyFromScancode(sc, SDL_KMOD_NONE, false);
e.key.down = down;
e.key.windowID = g_window ? SDL_GetWindowID(g_window->handle()) : 0;
if (SDL_PushEvent(&e)) ++g_typedEvents;
};
{ // (tests run unfocused, and text boxes ignore an unfocused window)
SDL_Event f{};
f.type = SDL_EVENT_WINDOW_FOCUS_GAINED;
f.window.windowID = g_window ? SDL_GetWindowID(g_window->handle()) : 0;
if (SDL_PushEvent(&f)) ++g_typedEvents;
}
for (size_t i = 0; i < s.size(); ++i) {
if (s[i] == '{') {
const size_t end = s.find('}', i);
const std::string name = s.substr(i + 1, end - i - 1);
const SDL_Scancode sc = name == "enter" ? SDL_SCANCODE_RETURN : name == "esc" ? SDL_SCANCODE_ESCAPE : SDL_GetScancodeFromName(name.c_str());
key(sc, true), key(sc, false);
i = end;
continue;
}
const SDL_Scancode sc = SDL_GetScancodeFromKey(SDL_Keycode(std::tolower(static_cast<unsigned char>(s[i]))), nullptr);
key(sc, true);
texts.push_back(std::string(1, s[i]));
SDL_Event t{};
t.type = SDL_EVENT_TEXT_INPUT;
t.text.text = texts.back().c_str();
t.text.windowID = g_window ? SDL_GetWindowID(g_window->handle()) : 0;
if (SDL_PushEvent(&t)) ++g_typedEvents;
key(sc, false);
}
}, "(tests) Type text in as key presses: sdl_type \"hi{enter}\"");
Cmd cmd_writeconfig("host_writeconfig", [](const Args&) {
saveConfig();
log::info("Wrote {}", g_configPath);
}, "Save settings and key bindings now (they're also saved on exit)");
// "+map foo +name bob" -> {"map foo", "name \"bob\""}
std::vector<std::string> commandLine(int argc, char** argv) {
std::vector<std::string> out;
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
if (arg.starts_with('+')) out.push_back(arg.substr(1));
else if (arg == "-dedicated") g_dedicated = true;
else if (arg == "-test") g_testMode = true;
else if (arg == "-dev") out.push_back("developer 1");
else if (!out.empty()) out.back() += " \"" + arg + "\"";
}
return out;
}
// Like Source: cvar settings on the command line apply before any commands, so
// "+map test +name bob" joins as bob. Pass 0 = settings, pass 1 = commands.
// Commands run as one line so "+map test +wait 60 +screenshot" chains through wait.
void executeCommandLine(const std::vector<std::string>& lines, int pass) {
std::string commands;
for (const std::string& line : lines) {
const Args args = tokenize(line);
const bool isSetting = !args.empty() && (Cvar::find(args[0]) || args[0] == "set");
if (isSetting != (pass == 0)) continue;
if (pass == 0) execute(line);
else commands += line + ";";
}
if (!commands.empty()) execute(commands);
}
Cvar fs_mods("fs_mods", "", CVAR_ARCHIVE, "Mods to load from the mods folder: \"all\" or \"name1,name2\" (restart needed)");
Cvar r_profile("r_profile", "0", CVAR_ARCHIVE, "Where each frame's time goes: the app, the CPU's rendering work, each GPU pass (ms)");
float g_gameMs = 0, g_drawMs = 0;
// r_profile 1: a bar per part of the frame (like Source's VProf / showbudget).
void drawProfile() {
if (!r_profile.b() || !g_renderer) return;
const Renderer::Timings& t = g_renderer->timings();
static float smooth[32] = {};
std::vector<std::pair<std::string, float>> rows = {{"game (logic, network, sound)", g_gameMs}, {"game draws (UI, scene list)", g_drawMs},
{"render: build draw list", t.build}, {"render: record + submit", t.record},
{"waiting for the GPU", t.wait}};
float gpu = 0;
for (const auto& [name, ms] : t.gpu) rows.push_back({std::string("gpu: ") + name, ms}), gpu += ms;
rows.push_back({"gpu total", gpu});
ImGui::SetNextWindowPos(ImVec2(10, ImGui::GetMainViewport()->WorkSize.y * 0.3f), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.65f);
ImGui::Begin("r_profile", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_AlwaysAutoResize);
ImGui::Text("%.2f ms %.0f fps", 1000.0f / ImGui::GetIO().Framerate, ImGui::GetIO().Framerate);
ImDrawList* dl = ImGui::GetWindowDrawList();
for (size_t i = 0; i < rows.size() && i < 32; ++i) {
smooth[i] += (rows[i].second - smooth[i]) * 0.1f; // steady numbers
const float ms = smooth[i];
const ImVec2 p = ImGui::GetCursorScreenPos();
const float w = std::min(ms / 16.67f, 1.0f) * 200.0f; // the bar: a 60 fps frame is full width
const ImU32 col = rows[i].first.starts_with("gpu") ? IM_COL32(90, 170, 255, 200) : IM_COL32(255, 180, 70, 200);
dl->AddRectFilled(ImVec2(p.x, p.y + 2), ImVec2(p.x + w, p.y + 14), col);
ImGui::Dummy(ImVec2(205, 16));
ImGui::SameLine();
ImGui::Text("%6.2f %s", ms, rows[i].first.c_str());
}
ImGui::End();
}
void drawFps() {
drawProfile();
if (r_speeds.b() && g_renderer) { // Quake's r_speeds: what the renderer did last frame
const Renderer::DrawStats& d = g_renderer->drawStats();
const Renderer::LightStats& l = g_renderer->lightStats();
ImGui::SetNextWindowPos(ImVec2(ImGui::GetMainViewport()->WorkSize.x - 10, 60), 0, ImVec2(1, 0));
ImGui::SetNextWindowBgAlpha(0.5f);
ImGui::Begin("r_speeds", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_AlwaysAutoResize);
ImGui::Text("%.2f ms %.0f fps", 1000.0f / ImGui::GetIO().Framerate, ImGui::GetIO().Framerate);
ImGui::Text("draws %u (+%u translucent), culled %u (%u hidden behind things)", d.drawn, d.translucent, d.culled, d.occluded);
ImGui::Text("triangles %u", d.triangles);
ImGui::Text("sun shadow draws %u, light shadow draws %u", d.sunShadow, d.lightShadow);
ImGui::Text("lights %u (%u shadowed)", l.lights, l.shadowed);
ImGui::End();
}
if (!cl_showfps.b()) return;
ImGui::SetNextWindowPos(ImVec2(ImGui::GetMainViewport()->WorkSize.x - 10, 10), 0, ImVec2(1, 0));
ImGui::SetNextWindowBgAlpha(0.4f);
ImGui::Begin("fps", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_AlwaysAutoResize);
const float fps = ImGui::GetIO().Framerate;
ImGui::Text("%.0f fps %.2f ms", fps, 1000.0f / fps);
ImGui::End();
}
int runDedicated(Game& game) {
// No SDL here, so handle Ctrl+C / kill ourselves to shut down cleanly.
std::signal(SIGINT, [](int) { g_quit = true; });
std::signal(SIGTERM, [](int) { g_quit = true; });
double last = now();
while (!g_quit) {
crash::heartbeat();
const double t = now();
game.frame(t - last);
executeDeferred();
last = t;
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
return 0;
}
int runClient(Game& game) {
Window window;
Renderer renderer;
g_renderer = &renderer;
if (g_testMode) {
SDL_SetHint(SDL_HINT_WINDOW_ACTIVATE_WHEN_SHOWN, "0");
input::disableCapture();
}
g_window = &window;
const int w = std::max(vid_width.i(), 640), h = std::max(vid_height.i(), 480);
// The API asked for, then the others: the modern pair, then the fallbacks for old GPUs.
const std::string want = r_renderer.str();
const RenderApi first = want == "opengl" ? RenderApi::OpenGL : want == "opengl40" ? RenderApi::OpenGL40 : want == "opengl31" ? RenderApi::OpenGL31 : RenderApi::Vulkan;
std::vector<RenderApi> order{first};
for (RenderApi a : {RenderApi::Vulkan, RenderApi::OpenGL, RenderApi::OpenGL40, RenderApi::OpenGL31})
if (std::find(order.begin(), order.end(), a) == order.end() && (first != RenderApi::OpenGL31 || a == RenderApi::OpenGL31)) order.push_back(a);
bool ok = false;
for (RenderApi api : order) {
ok = window.init(game.title(), w, h, api != RenderApi::Vulkan) && renderer.init(window, r_validation.b(), api);
if (ok) {
if (api != first)
ui::popup("Renderer changed", std::string("Your graphics driver couldn't start the ") + (want.empty() ? "vulkan" : want) +
" renderer, so it's using " + renderer.apiName() + " instead.");
break;
}
log::warn("Renderer {} unavailable, trying the next", int(api));
renderer.shutdown();
window.shutdown();
}
if (!ok) {
renderer.shutdown();
window.shutdown();
return 1;
}
if (game.dockingUi()) ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_DockingEnable;
if (auto png = fs::read("branding/icon.png")) { // the window/taskbar icon
int w = 0, h = 0, n = 0;
if (stbi_uc* px = stbi_load_from_memory(png->data(), int(png->size()), &w, &h, &n, 4)) {
window.setIcon(px, w, h);
stbi_image_free(px);
}
} else if (auto data = fs::read("branding/keel_emblem.yapf"))
if (yapf_texture_t* tex = yapf_tex_load_memory(data->data(), data->size(), 0, 1, nullptr)) {
std::vector<uint8_t> rgba(size_t(tex->width) * tex->height * 4);
if (yapf_tex_to_rgba8(tex, yapf_tex_surface(tex, 0, 0, 0, 0), 0, rgba.data()) == YAPF_OK)
window.setIcon(rgba.data(), int(tex->width), int(tex->height));
yapf_tex_free(tex);
}
if (!g_testMode) audio::init();
ui::init();
log::info("Press ` (tilde key) to open the console");
double last = now();
while (!g_quit) {
crash::heartbeat();
SDL_Event ev;
while (SDL_PollEvent(&ev)) {
const bool typed = g_typedEvents > 0 && (ev.type == SDL_EVENT_KEY_DOWN || ev.type == SDL_EVENT_KEY_UP || ev.type == SDL_EVENT_TEXT_INPUT ||
ev.type == SDL_EVENT_WINDOW_FOCUS_GAINED);
if (typed) --g_typedEvents;
if (g_testMode && !typed) { // nobody's playing: only window events (and sdl_type's)
if (ev.type == SDL_EVENT_QUIT) g_quit = true;
if (ev.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) renderer.onResize();
continue;
}
const bool keyDown = ev.type == SDL_EVENT_KEY_DOWN && !ev.key.repeat;
if (keyDown && ev.key.scancode == SDL_SCANCODE_GRAVE) {
console::toggle();
continue;
}
touch::handleEvent(ev, input::captured());
// Escape, or a phone's back button.
if (keyDown && (ev.key.scancode == SDL_SCANCODE_ESCAPE || ev.key.scancode == SDL_SCANCODE_AC_BACK)) {
if (console::isOpen()) console::toggle();
else if (ui::popupOpen()) ui::closePopup();
else game.onEscape();
continue;
}
// Don't let the toggle key's character land in the console input.
if (ev.type == SDL_EVENT_TEXT_INPUT && ev.text.text[0] == '`') continue;
input::handleEvent(ev);
if (!input::captured()) ImGui_ImplSDL3_ProcessEvent(&ev);
if (ev.type == SDL_EVENT_QUIT && game.allowQuit()) g_quit = true;
if (ev.type == SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED) renderer.onResize();
}
const bool focused = SDL_GetWindowFlags(window.handle()) & SDL_WINDOW_INPUT_FOCUS;
audio::setFocused(focused);
if (vid_mode.consumeModified() | vid_width.consumeModified() | vid_height.consumeModified())
window.setMode(vid_mode.i(), std::max(vid_width.i(), 640), std::max(vid_height.i(), 480));
// Menus need no more than fps_max_menu (like Source's): high frame rates there only heat
// the GPU and make coil whine (often heard in headphones on the onboard jack).
int limit = fps_max.i();
if (game.wantsCursor() && fps_max_menu.i() > 0 && !g_testMode) limit = limit > 0 ? std::min(limit, fps_max_menu.i()) : fps_max_menu.i();
// In the background (like Source's engine_no_focus_sleep): another window has the
// keyboard, or it's minimized; no need to draw flat out (and take the GPU from the game
// you're looking at).
if (!focused && !g_testMode && fps_max_nofocus.i() > 0) {
const bool minimized = SDL_GetWindowFlags(window.handle()) & SDL_WINDOW_MINIMIZED;
const int cap = minimized ? 10 : fps_max_nofocus.i();
limit = limit > 0 ? std::min(limit, cap) : cap;
}
if (limit > 0) { // sleep off the rest of the frame
static double nextFrame = 0;
const double frame = 1.0 / std::max(limit, 10);
nextFrame = std::max(nextFrame + frame, now());
while (now() < nextFrame - 0.0015) std::this_thread::sleep_for(std::chrono::milliseconds(1));
while (now() < nextFrame) {}
}
const bool capture = focused && !console::isOpen() && !game.wantsCursor() && !ui::popupOpen();
// Handing the keyboard between the game and the menus: the menus forget what was held.
// (Keys let go while the game had them never reach the menus: Enter that sent a chat line
// stayed "held" and repeated, closing the chat the moment it opened again.)
if (capture != input::captured()) ImGui::GetIO().ClearInputKeys();
input::setCaptured(capture);
if (touch::takeEscape()) { // the touch controls' pause button
if (console::isOpen()) console::toggle();
else if (ui::popupOpen()) ui::closePopup();
else game.onEscape();
}
const double t = now();
game.frame(t - last);
g_gameMs = float((now() - t) * 1000.0);
last = t;
// Hot reload (Source 2's resource system, in spirit): what changed on disk comes back in.
static double nextCheck = 0.0;
if (hotreload.i() >= (g_testMode ? 2 : 1) && now() >= nextCheck) { // (tests: hotreload 2)
nextCheck = now() + 0.5;
std::vector<std::string> changed = renderer.reloadChanged();
for (std::string& s : audio::reloadChanged()) changed.push_back(std::move(s));
for (const std::string& s : changed) log::info("Reloaded {}", s);
game.hotReload(changed);
}
renderer.beginUi();
int winW = 0, winH = 0;
SDL_GetWindowSizeInPixels(window.handle(), &winW, &winH);
ui::beginFrame(float(winH));
const double drawStart = now();
game.render(renderer);
g_drawMs = float((now() - drawStart) * 1000.0);
touch::draw(input::captured());
ui::drawPopups();
console::draw();
drawFps();
renderer.drawFrame();
executeDeferred();
}
input::setCaptured(false);
audio::shutdown();
waitForScreenshots();
renderer.shutdown();
window.shutdown();
return 0;
}
} // namespace
bool isDedicated() { return g_dedicated; }
bool isTestMode() { return g_testMode; }
const char* rendererName() { return g_renderer ? g_renderer->apiName() : "none"; }
const char* buildVersion() { return KEEL_COMMIT ", " KEEL_COMMIT_DATE; }
// Replaces this process with a fresh one (Windows: starts it, then this one exits).
void relaunch(const std::vector<std::string>& args) {
std::vector<std::string> a = args;
#ifdef _WIN32
char exe[1024] = {};
GetModuleFileNameA(nullptr, exe, sizeof exe);
for (std::string& x : a)
if (x.find(' ') != std::string::npos) x = "\"" + x + "\"";
#else
const std::string exe = std::filesystem::read_symlink("/proc/self/exe").string();
#endif
std::vector<char*> argv;
for (std::string& x : a) argv.push_back(x.data());
argv.push_back(nullptr);
log::info("Restarting");
#ifdef _WIN32
_spawnv(_P_NOWAIT, exe, argv.data());
#else
execv(exe.c_str(), argv.data());
log::error("Restart failed");
#endif
}
std::string g_modsDir;
std::vector<std::string> g_argv;
bool g_restart = false;
const std::string& modsDirectory() { return g_modsDir; }
void applyMods() {
fs::unmountMods();
fs::mountMods(g_modsDir, fs_mods.str());
}
void restart() {
g_restart = true;
execute("quit");
}
std::vector<std::pair<int, int>> displayResolutions() { return g_window ? g_window->resolutions() : std::vector<std::pair<int, int>>{}; }
// Tries the crash screen: "crash" crashes, "crash freeze" hangs (the watchdog steps in), "crash throw" throws.
Cmd cmd_crash("crash", [](const Args& a) {
const std::string how = a.size() > 1 ? a[1] : "";
if (how == "freeze") for (volatile bool stuck = true; stuck;) {}
if (how == "throw") throw std::runtime_error("crash throw (from the console)");
volatile int* p = nullptr;
*p = 1;
}, "crash [freeze|throw]: crash on purpose, to see the crash screen");
#if defined(__ANDROID__)
// On a phone the content ships inside the APK (assets/, listed in content.txt with a version on
// its first line). It's copied out to the app's own storage the first time, and after an
// update, where it's read like any game folder. Returns that folder.
std::string unpackAndroidContent() {
const char* internal = SDL_GetAndroidInternalStoragePath();
const std::string dir = std::string(internal ? internal : ".") + "/";
size_t size = 0;
void* list = SDL_LoadFile("content.txt", &size); // (relative paths read from the APK's assets)
if (!list) {
log::error("No content list in the APK");
return dir;
}
std::istringstream in(std::string(static_cast<const char*>(list), size));
SDL_free(list);
std::string version, have;
std::getline(in, version);
if (std::ifstream v(dir + "content.version"); v) std::getline(v, have);
if (have == version) return dir;
log::info("Unpacking the content ({})", version);
for (std::string rel; std::getline(in, rel);) {
if (rel.empty()) continue;
size_t n = 0;
void* data = SDL_LoadFile(rel.c_str(), &n);
if (!data) {
log::warn("Not in the APK: {}", rel);
continue;
}
std::error_code ec;
std::filesystem::create_directories(std::filesystem::path(dir + rel).parent_path(), ec);
std::ofstream(dir + rel, std::ios::binary).write(static_cast<const char*>(data), std::streamsize(n));
SDL_free(data);
}
std::ofstream(dir + "content.version") << version << "\n";
return dir;
}
#endif
int run(Game& game, int argc, char** argv) {
if (const int r = crash::runReporter(argc, argv); r >= 0) return r; // the crash screen
log::info("{}", game.title());
const std::vector<std::string> startup = commandLine(argc, argv); // also detects -dedicated
char* pref = SDL_GetPrefPath("Nexoniarz", game.name());
g_prefPath = pref ? pref : "";
g_configPath = g_prefPath + (g_dedicated ? "server.cfg" : "config.cfg");
if (!g_testMode) console::setHistoryFile(g_prefPath + "console_history.txt");
cmd_exec.complete([](std::string_view p, size_t) {
std::vector<std::string> v;
for (const std::string& f : fs::list("cfg", ".cfg")) v.push_back(f.substr(4)); // "cfg/x.cfg" → "x.cfg"
return matching(v, p);
});
SDL_free(pref);
{
std::vector<std::string> args(argv, argv + argc);
bool dog = !g_testMode;
for (const std::string& a : args) dog = a == "-watchdog" ? true : a == "-nowatchdog" ? false : dog;
crash::install(g_prefPath, args, dog);
}
// Content: the engine's own (<exe dir>/keel/), then the game's (<exe dir>/<game>/) over it,
// each with its packs — like Source's hl2/ under a mod.
#if defined(__ANDROID__)
const std::string exeDir = unpackAndroidContent();
if (const char* cache = SDL_GetAndroidCachePath()) setenv("TMPDIR", cache, 1); // temp files (script modules) go there
const bool firstRun = !std::filesystem::exists(g_configPath);
#else
const char* base = SDL_GetBasePath();
const std::string exeDir = base ? base : "./";
#endif
if (!fs::mountGameDir(exeDir + "keel")) log::warn("Engine folder {}keel not found; fonts and UI won't load", exeDir);
const std::string gameDir = exeDir + game.name();
if (!fs::mountGameDir(gameDir)) log::warn("Game folder {} not found; no content will load", gameDir);
game.init();
// Order matters: saved config first, then the user's autoexec, then the command line.
// Like Source's config_default.cfg: default binds/settings, then the player's own.
if (!g_dedicated && fs::exists("cfg/default.cfg")) execute("exec default.cfg");
if (std::filesystem::exists(g_configPath)) execute("exec \"" + g_configPath + "\"");
#if defined(__ANDROID__)
if (firstRun) execute("preset Low"); // a phone: start light (the options change it)
#endif
if (fs::exists("cfg/autoexec.cfg")) execute("exec autoexec.cfg");
executeCommandLine(startup, 0);
g_modsDir = exeDir + "mods/" + game.name();
for (int i = 0; i < argc; ++i) g_argv.emplace_back(argv[i]);
fs::mountMods(g_modsDir, fs_mods.str());
executeCommandLine(startup, 1);
const int result = g_dedicated ? runDedicated(game) : runClient(game);
game.shutdown();
if (!g_testMode) saveConfig();
if (g_restart) relaunch(g_argv);
return result;
}
const std::string& prefPath() { return g_prefPath; }
} // namespace keel

62
engine/engine.h Normal file
View file

@ -0,0 +1,62 @@
#pragma once
#include <utility>
#include <string>
#include <vector>
namespace keel {
// Where this game keeps its config and saves (SDL_GetPrefPath), "" before it starts.
const std::string& prefPath();
class Renderer;
// What a game plugs into the engine. Like Source, the game owns its server and
// client code; the engine owns the window, renderer, console and main loop.
class Game {
public:
virtual ~Game() = default;
virtual const char* name() const = 0; // folder for config/saves, e.g. "fry3dx"
virtual const char* title() const = 0; // window title
virtual void init() {}
virtual void shutdown() {}
// Every frame, before rendering. dt in seconds.
virtual void frame(double dt) = 0;
// Queue draws and UI. Not called on a dedicated server.
virtual void render(Renderer&) {}
// True while the game shows a menu: the mouse is released and binds don't run.
virtual bool wantsCursor() const { return false; }
// Dear ImGui docking (windows that dock into each other and the screen): tools like this one.
virtual bool dockingUi() const { return false; }
// Escape was pressed (the console has its own key).
virtual void onEscape() {}
// The window's close button (or the OS asking it to close): false keeps it open (to ask
// about saving first; the game quits later itself). The quit command always quits.
virtual bool allowQuit() { return true; }
// Hot reload: called twice a second (hotreload 1) after the engine reloaded what changed
// (`changed`: textures, models, sounds); the game reloads its own (scripts, the map).
virtual void hotReload(const std::vector<std::string>& changed) { (void)changed; }
};
// Runs until quit. Command line works like Quake/Source:
// fry3dx file.fry +set r_vsync 0
int run(Game& game, int argc, char** argv);
bool isDedicated();
// Started with -test (automated runs: no input, no audio, no splash).
bool isTestMode();
// Fullscreen resolutions of the window's display, largest first (empty when dedicated).
std::vector<std::pair<int, int>> displayResolutions();
// The graphics API in use ("Vulkan 1.3", "OpenGL 4.6").
const char* rendererName();
// This build: its git commit and date ("1a2b3c4, 2026-10-11 02:14").
const char* buildVersion();
// The game's mods folder (<exe dir>/mods/<game>/).
const std::string& modsDirectory();
// Takes the mods down and mounts the ones fs_mods names now (scripts and new files apply from
// the next map; textures/models already loaded keep their old look until a restart).
void applyMods();
// Starts the game again with the same command line (after the config is saved).
void restart();
} // namespace keel

212
engine/fs/vfs.cpp Normal file
View file

@ -0,0 +1,212 @@
#include "fs/vfs.h"
#include "core/cvar.h"
#include "core/log.h"
#include "fs/ymp.h"
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <memory>
#include <set>
namespace keel::fs {
namespace {
namespace stdfs = std::filesystem;
struct Mount {
std::string root; // directory, or pack file
std::unique_ptr<YmpArchive> pack;
bool mod = false;
};
bool g_mountingMods = false;
std::vector<Mount> g_mounts; // later = higher priority
std::string normalize(std::string_view p) {
std::string s(p);
std::replace(s.begin(), s.end(), '\\', '/');
while (s.starts_with("./")) s.erase(0, 2);
while (s.starts_with('/')) s.erase(0, 1);
return s;
}
bool safe(const std::string& p) { return p.find("..") == std::string::npos; }
Cmd cmd_path("path", [](const Args&) {
for (auto it = g_mounts.rbegin(); it != g_mounts.rend(); ++it)
log::info(" {}{}", it->root, it->pack ? " (pack)" : "");
}, "Show mounted directories and packs, highest priority first");
} // namespace
bool mountDir(const std::string& dir) {
if (!stdfs::is_directory(dir)) return false;
g_mounts.push_back({stdfs::path(dir).lexically_normal().generic_string(), nullptr, g_mountingMods});
return true;
}
bool mountPack(const std::string& file) {
auto pack = std::make_unique<YmpArchive>();
if (!pack->open(file)) {
log::warn("Couldn't open pack {}", file);
return false;
}
g_mounts.push_back({file, std::move(pack), g_mountingMods});
return true;
}
bool mountGameDir(const std::string& dir) {
if (!stdfs::is_directory(dir)) return false;
std::vector<std::string> packs;
for (auto& e : stdfs::directory_iterator(dir))
if (e.is_regular_file() && e.path().extension() == ".ymp") packs.push_back(e.path().generic_string());
std::sort(packs.begin(), packs.end());
for (auto& p : packs) mountPack(p);
return mountDir(dir);
}
void mountMods(const std::string& modsDir, const std::string& which) {
if (which.empty() || !stdfs::is_directory(modsDir)) return;
std::vector<stdfs::path> entries;
for (auto& e : stdfs::directory_iterator(modsDir)) entries.push_back(e.path());
std::sort(entries.begin(), entries.end());
g_mountingMods = true;
for (const auto& p : entries) {
const std::string name = p.stem().string();
if (!modEnabled(which, name)) continue;
const bool ok = stdfs::is_directory(p) ? mountGameDir(p.string()) : p.extension() == ".ymp" && mountPack(p.string());
if (ok) log::info("Mod: {}", name);
}
g_mountingMods = false;
}
void unmountMods() {
std::erase_if(g_mounts, [](const Mount& m) { return m.mod; });
}
bool modEnabled(const std::string& which, const std::string& name) {
return which == "all" || ("," + which + ",").find("," + name + ",") != std::string::npos;
}
namespace {
// mod.txt: `key "value"` (or `key value`) lines; // comments.
void parseModTxt(const std::string& text, ModInfo& m) {
size_t start = 0;
while (start < text.size()) {
size_t end = text.find('\n', start);
if (end == std::string::npos) end = text.size();
std::string line = text.substr(start, end - start);
start = end + 1;
if (!line.empty() && line.back() == '\r') line.pop_back();
const size_t k0 = line.find_first_not_of(" \t");
if (k0 == std::string::npos || line.compare(k0, 2, "//") == 0) continue;
const size_t k1 = line.find_first_of(" \t", k0);
if (k1 == std::string::npos) continue;
const std::string key = line.substr(k0, k1 - k0);
std::string value = line.substr(line.find_first_not_of(" \t", k1));
if (value.size() >= 2 && value.front() == '"') value = value.substr(1, value.rfind('"') - 1);
if (key == "title") m.title = value;
else if (key == "author") m.author = value;
else if (key == "version") m.version = value;
else if (key == "description") m.description = value;
}
}
bool isAsset(const std::string& f) { return f.starts_with("textures/") || f.starts_with("models/") || f.starts_with("materials/"); }
} // namespace
std::vector<ModInfo> listMods(const std::string& modsDir) {
std::vector<ModInfo> out;
std::error_code ec;
if (!stdfs::is_directory(modsDir, ec)) return out;
for (auto& e : stdfs::directory_iterator(modsDir, ec)) {
ModInfo m;
m.name = e.path().stem().string();
m.path = e.path().generic_string();
if (e.is_directory()) {
for (auto& f : stdfs::recursive_directory_iterator(e.path(), ec)) {
if (!f.is_regular_file()) continue;
++m.files;
if (isAsset(stdfs::relative(f.path(), e.path()).generic_string())) m.replacesAssets = true;
}
if (std::ifstream in(e.path() / "mod.txt"); in) parseModTxt(std::string((std::istreambuf_iterator<char>(in)), {}), m);
} else if (e.path().extension() == ".ymp") {
m.pack = true;
YmpArchive pack;
if (!pack.open(m.path)) continue;
for (const std::string& f : pack.files()) {
++m.files;
if (isAsset(f)) m.replacesAssets = true;
}
if (std::vector<uint8_t> txt; pack.read("mod.txt", txt)) parseModTxt(std::string(txt.begin(), txt.end()), m);
} else {
continue;
}
if (m.title.empty()) m.title = m.name;
out.push_back(std::move(m));
}
std::sort(out.begin(), out.end(), [](const ModInfo& a, const ModInfo& b) { return a.title < b.title; });
return out;
}
void unmountAll() { g_mounts.clear(); }
std::optional<std::vector<uint8_t>> read(std::string_view path) {
const std::string p = normalize(path);
if (!safe(p)) return std::nullopt;
std::vector<uint8_t> out;
for (auto it = g_mounts.rbegin(); it != g_mounts.rend(); ++it) {
if (it->pack) {
if (it->pack->read(p, out)) return out;
continue;
}
std::ifstream in(it->root + "/" + p, std::ios::binary);
if (in) return std::vector<uint8_t>((std::istreambuf_iterator<char>(in)), {});
}
return std::nullopt;
}
std::optional<std::string> readText(std::string_view path) {
auto data = read(path);
if (!data) return std::nullopt;
return std::string(data->begin(), data->end());
}
std::optional<int64_t> mtime(std::string_view path) {
const std::string p = normalize(path);
if (!safe(p)) return std::nullopt;
for (auto it = g_mounts.rbegin(); it != g_mounts.rend(); ++it) {
if (it->pack) {
if (it->pack->contains(p)) return std::nullopt;
continue;
}
std::error_code ec;
const auto t = stdfs::last_write_time(it->root + "/" + p, ec);
if (!ec) return int64_t(t.time_since_epoch().count());
}
return std::nullopt;
}
bool exists(std::string_view path) {
const std::string p = normalize(path);
if (!safe(p)) return false;
for (auto& m : g_mounts)
if (m.pack ? m.pack->contains(p) : stdfs::is_regular_file(m.root + "/" + p)) return true;
return false;
}
std::vector<std::string> list(std::string_view dir, std::string_view ext) {
const std::string d = normalize(dir);
std::set<std::string> found;
auto consider = [&](const std::string& f) {
if (f.starts_with(d) && (ext.empty() || f.ends_with(ext))) found.insert(f);
};
for (auto& m : g_mounts) {
if (m.pack) {
for (auto& f : m.pack->files()) consider(f);
} else if (stdfs::is_directory(m.root + "/" + d)) {
for (auto& e : stdfs::recursive_directory_iterator(m.root + "/" + d))
if (e.is_regular_file()) consider(stdfs::relative(e.path(), m.root).generic_string());
}
}
return {found.begin(), found.end()};
}
} // namespace keel::fs

52
engine/fs/vfs.h Normal file
View file

@ -0,0 +1,52 @@
#pragma once
// Virtual filesystem: everything the game loads goes through here. Directories
// and YMP packs are mounted in order; a file in a later mount overrides the
// same path in earlier ones (like Source's search paths), which is how patches
// and mods replace content without touching the base game.
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace keel::fs {
bool mountDir(const std::string& dir);
bool mountPack(const std::string& file);
// Mounts dir/*.ymp (in name order: pak0, pak1, ...) and then dir itself, so
// loose files beat packed ones during development.
bool mountGameDir(const std::string& dir);
// Mods: each folder or .ymp in `modsDir` named in `which` ("all" or "a,b,c"),
// mounted after the game so they override it.
void mountMods(const std::string& modsDir, const std::string& which);
// Takes the mods down again (the engine and game stay), e.g. before mounting a new selection.
void unmountMods();
void unmountAll();
// A mod in the mods folder: a folder or a .ymp, described by an optional mod.txt inside it:
// title "Low gravity"
// author "Someone"
// version "1.0"
// description "Moon gravity and higher jumps."
struct ModInfo {
std::string name; // folder / pack name: what fs_mods lists
std::string path;
bool pack = false;
std::string title, author, version, description;
size_t files = 0;
bool replacesAssets = false; // has textures or models (already-loaded ones need a restart)
};
std::vector<ModInfo> listMods(const std::string& modsDir);
// Whether `name` is on in an fs_mods value ("all" or "a,b,c").
bool modEnabled(const std::string& which, const std::string& name);
std::optional<std::vector<uint8_t>> read(std::string_view path);
std::optional<std::string> readText(std::string_view path);
bool exists(std::string_view path);
// When the file `path` resolves to was last written (loose files only: a pack's never changes
// under a running game); for hot reload.
std::optional<int64_t> mtime(std::string_view path);
// Files under `dir` (recursive) ending in `ext`, across all mounts, no duplicates.
std::vector<std::string> list(std::string_view dir, std::string_view ext = {});
} // namespace keel::fs

278
engine/fs/ymp.cpp Normal file
View file

@ -0,0 +1,278 @@
#include "fs/ymp.h"
#include "core/log.h"
#include <zstd.h>
#include <zstd_seekable.h>
#include <cinttypes>
#include <cstdio>
#include <cstring>
#include <fstream>
namespace keel {
namespace {
constexpr const char* kIndexName = ".ymp-index";
constexpr size_t kBlock = 512;
constexpr size_t kFrameTarget = 64 * 1024; // end a frame at the next file boundary after this
constexpr unsigned kMaxFrame = 1024 * 1024;
uint64_t roundUp(uint64_t n) { return (n + kBlock - 1) / kBlock * kBlock; }
// ustar header; names up to 255 characters via the prefix field.
bool writeHeader(uint8_t* h, const std::string& name, uint64_t size) {
std::memset(h, 0, kBlock);
std::string prefix, base = name;
if (name.size() > 100) {
size_t cut = name.rfind('/', 155);
if (cut == std::string::npos || name.size() - cut - 1 > 100) return false;
prefix = name.substr(0, cut);
base = name.substr(cut + 1);
}
std::memcpy(h, base.data(), base.size());
std::snprintf((char*)h + 100, 8, "%07o", 0644);
std::snprintf((char*)h + 108, 8, "%07o", 0);
std::snprintf((char*)h + 116, 8, "%07o", 0);
if (size >= (1ull << 33)) return false; // ustar size field limit: 8 GiB per file
char sizeField[24];
std::snprintf(sizeField, sizeof sizeField, "%011" PRIo64, size);
std::memcpy(h + 124, sizeField, 12);
std::snprintf((char*)h + 136, 12, "%011o", 0);
h[156] = '0';
std::memcpy(h + 257, "ustar", 6);
h[263] = h[264] = '0';
std::memcpy(h + 345, prefix.data(), prefix.size());
std::memset(h + 148, ' ', 8);
unsigned sum = 0;
for (size_t i = 0; i < kBlock; ++i) sum += h[i];
std::snprintf((char*)h + 148, 8, "%06o", sum);
h[155] = ' ';
return true;
}
bool readHeader(const uint8_t* h, std::string& name, uint64_t& size, char& type) {
if (h[0] == 0) return false; // end of archive
name.assign((const char*)h, strnlen((const char*)h, 100));
if (std::memcmp(h + 257, "ustar", 5) == 0 && h[345]) {
name = std::string((const char*)h + 345, strnlen((const char*)h + 345, 155)) + "/" + name;
}
char sizeField[13] = {};
std::memcpy(sizeField, h + 124, 12);
size = std::strtoull(sizeField, nullptr, 8);
type = char(h[156]);
return true;
}
struct Layout {
std::string index;
std::vector<uint64_t> dataOffsets;
};
// Offsets are written with fixed width, so the index size is known before the offsets are.
Layout layout(const std::vector<std::pair<std::string, uint64_t>>& files) {
size_t indexSize = 0;
for (auto& f : files) indexSize += 16 + 1 + 16 + 1 + f.first.size() + 1;
Layout l;
uint64_t pos = kBlock + roundUp(indexSize);
char line[48];
for (auto& f : files) {
l.dataOffsets.push_back(pos + kBlock);
std::snprintf(line, sizeof line, "%016" PRIx64 " %016" PRIx64 " ", pos + kBlock, f.second);
l.index += line + f.first + "\n";
pos += kBlock + roundUp(f.second);
}
return l;
}
} // namespace
std::vector<uint8_t> buildTar(const std::vector<std::pair<std::string, std::vector<uint8_t>>>& files) {
std::vector<std::pair<std::string, uint64_t>> sizes;
for (auto& f : files) sizes.emplace_back(f.first, f.second.size());
const Layout l = layout(sizes);
std::vector<uint8_t> out;
auto append = [&](const std::string& name, const void* data, size_t n) {
size_t at = out.size();
out.resize(at + kBlock + roundUp(n));
writeHeader(out.data() + at, name, n);
std::memcpy(out.data() + at + kBlock, data, n);
};
append(kIndexName, l.index.data(), l.index.size());
for (auto& f : files) append(f.first, f.second.data(), f.second.size());
out.resize(out.size() + 2 * kBlock);
return out;
}
bool writeYmp(const std::string& outPath, const std::vector<PackInput>& files, int level) {
std::vector<std::pair<std::string, uint64_t>> sizes;
for (auto& f : files) {
std::ifstream in(f.diskPath, std::ios::binary | std::ios::ate);
if (!in) return log::error("ympack: can't read {}", f.diskPath), false;
sizes.emplace_back(f.archivePath, uint64_t(in.tellg()));
}
const Layout l = layout(sizes);
FILE* out = std::fopen(outPath.c_str(), "wb");
if (!out) return log::error("ympack: can't write {}", outPath), false;
ZSTD_seekable_CStream* zcs = ZSTD_seekable_createCStream();
ZSTD_seekable_initCStream(zcs, level, 1, kMaxFrame);
std::vector<uint8_t> outBuf(ZSTD_CStreamOutSize());
size_t frameBytes = 0;
bool ok = true;
auto drain = [&](ZSTD_outBuffer& ob) {
ok &= std::fwrite(outBuf.data(), 1, ob.pos, out) == ob.pos;
ob.pos = 0;
};
auto feed = [&](const void* data, size_t n) {
ZSTD_inBuffer in{data, n, 0};
while (in.pos < in.size) {
ZSTD_outBuffer ob{outBuf.data(), outBuf.size(), 0};
if (ZSTD_isError(ZSTD_seekable_compressStream(zcs, &ob, &in))) ok = false;
drain(ob);
if (!ok) return;
}
frameBytes += n;
};
auto endFrameIfBig = [&] {
if (frameBytes < kFrameTarget) return;
size_t left;
do {
ZSTD_outBuffer ob{outBuf.data(), outBuf.size(), 0};
left = ZSTD_seekable_endFrame(zcs, &ob);
drain(ob);
} while (left > 0 && !ZSTD_isError(left));
frameBytes = 0;
};
auto member = [&](const std::string& name, const std::vector<uint8_t>& data) {
uint8_t header[kBlock];
if (!writeHeader(header, name, data.size())) {
log::error("ympack: path too long or file over 8 GiB: {}", name);
ok = false;
return;
}
static const uint8_t zeros[kBlock] = {};
feed(header, kBlock);
feed(data.data(), data.size());
feed(zeros, roundUp(data.size()) - data.size());
endFrameIfBig();
};
member(kIndexName, std::vector<uint8_t>(l.index.begin(), l.index.end()));
for (size_t i = 0; i < files.size() && ok; ++i) {
std::ifstream in(files[i].diskPath, std::ios::binary);
std::vector<uint8_t> data((std::istreambuf_iterator<char>(in)), {});
member(files[i].archivePath, data);
}
static const uint8_t endBlocks[2 * kBlock] = {};
feed(endBlocks, sizeof endBlocks);
size_t left;
do {
ZSTD_outBuffer ob{outBuf.data(), outBuf.size(), 0};
left = ZSTD_seekable_endStream(zcs, &ob);
drain(ob);
} while (left > 0 && !ZSTD_isError(left));
ZSTD_seekable_freeCStream(zcs);
ok &= std::fclose(out) == 0;
return ok;
}
YmpArchive::YmpArchive() = default;
YmpArchive::~YmpArchive() {
if (seekable_) ZSTD_seekable_free(seekable_);
if (file_) std::fclose(file_);
}
bool YmpArchive::readRange(uint64_t offset, size_t size, void* out) {
if (!memory_.empty() || !seekable_) {
if (offset + size > memory_.size()) return false;
std::memcpy(out, memory_.data() + offset, size);
return true;
}
size_t done = 0;
while (done < size) {
size_t r = ZSTD_seekable_decompress(seekable_, (uint8_t*)out + done, size - done, offset + done);
if (ZSTD_isError(r) || r == 0) return false;
done += r;
}
return true;
}
bool YmpArchive::open(const std::string& path) {
path_ = path;
file_ = std::fopen(path.c_str(), "rb");
if (!file_) return false;
seekable_ = ZSTD_seekable_create();
if (ZSTD_isError(ZSTD_seekable_initFile(seekable_, file_))) {
// Not seekable: plain .tar.zst. Decompress it all once.
ZSTD_seekable_free(seekable_);
seekable_ = nullptr;
std::fseek(file_, 0, SEEK_SET);
std::ifstream raw(path, std::ios::binary);
std::vector<uint8_t> packed((std::istreambuf_iterator<char>(raw)), {});
ZSTD_DStream* ds = ZSTD_createDStream();
ZSTD_inBuffer in{packed.data(), packed.size(), 0};
std::vector<uint8_t> chunk(ZSTD_DStreamOutSize());
while (in.pos < in.size) {
ZSTD_outBuffer ob{chunk.data(), chunk.size(), 0};
if (ZSTD_isError(ZSTD_decompressStream(ds, &ob, &in))) break;
memory_.insert(memory_.end(), chunk.data(), chunk.data() + ob.pos);
}
ZSTD_freeDStream(ds);
if (memory_.empty()) return log::warn("{} is not a zstd archive", path), false;
log::warn("{} is not seekable; loaded whole into memory (repack with ympack)", path);
return parseTar(memory_.size());
}
uint64_t total = 0;
const unsigned frames = ZSTD_seekable_getNumFrames(seekable_);
if (frames) total = ZSTD_seekable_getFrameDecompressedOffset(seekable_, frames - 1) + ZSTD_seekable_getFrameDecompressedSize(seekable_, frames - 1);
// Fast path: the index member.
uint8_t header[kBlock];
std::string name;
uint64_t size = 0;
char type = 0;
if (readRange(0, kBlock, header) && readHeader(header, name, size, type) && name == kIndexName) {
std::string index(size, '\0');
if (!readRange(kBlock, size, index.data())) return false;
size_t pos = 0;
while (pos < index.size()) {
size_t eol = index.find('\n', pos);
if (eol == std::string::npos || eol - pos < 35) break;
Entry e{std::strtoull(index.c_str() + pos, nullptr, 16), std::strtoull(index.c_str() + pos + 17, nullptr, 16)};
entries_[index.substr(pos + 34, eol - pos - 34)] = e;
pos = eol + 1;
}
return true;
}
return parseTar(total);
}
bool YmpArchive::parseTar(uint64_t totalSize) {
uint64_t pos = 0;
uint8_t header[kBlock];
std::string name;
uint64_t size;
char type;
while (pos + kBlock <= totalSize && readRange(pos, kBlock, header) && readHeader(header, name, size, type)) {
if ((type == '0' || type == 0) && name != kIndexName) entries_[name] = {pos + kBlock, size};
pos += kBlock + roundUp(size);
}
return true;
}
bool YmpArchive::read(std::string_view path, std::vector<uint8_t>& out) {
auto it = entries_.find(path);
if (it == entries_.end()) return false;
out.resize(it->second.size);
return readRange(it->second.offset, out.size(), out.data());
}
std::vector<std::string> YmpArchive::files() const {
std::vector<std::string> out;
for (auto& [name, _] : entries_) out.push_back(name);
return out;
}
} // namespace keel

57
engine/fs/ymp.h Normal file
View file

@ -0,0 +1,57 @@
#pragma once
// YMP ("Your Mom's Package") = a tar archive compressed with zstd's seekable
// format. It is a normal .tar.zst (`tar --zstd -xf pak0.ymp` works), and the
// engine can still read any file without decompressing the rest:
// - frames end at file boundaries, so a small file decompresses on its own
// - the first tar member, ".ymp-index", lists every file's offset and size
// Plain .tar.zst files (no index, not seekable) also load: they are fully
// decompressed into memory once.
#include <cstdint>
#include <map>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
struct ZSTD_seekable_s;
namespace keel {
struct PackInput {
std::string archivePath; // path inside the pack, forward slashes
std::string diskPath;
};
// Writes a pack. Level 19 is slow to build and fast to load; 3 is quick for iteration.
bool writeYmp(const std::string& outPath, const std::vector<PackInput>& files, int level = 19);
// Builds an uncompressed tar (with the YMP index as first member).
std::vector<uint8_t> buildTar(const std::vector<std::pair<std::string, std::vector<uint8_t>>>& files);
class YmpArchive {
public:
YmpArchive();
~YmpArchive();
YmpArchive(const YmpArchive&) = delete;
YmpArchive& operator=(const YmpArchive&) = delete;
bool open(const std::string& path);
bool read(std::string_view path, std::vector<uint8_t>& out);
bool contains(std::string_view path) const { return entries_.count(std::string(path)) != 0; }
std::vector<std::string> files() const;
private:
struct Entry {
uint64_t offset; // in the uncompressed tar
uint64_t size;
};
bool readRange(uint64_t offset, size_t size, void* out);
bool parseTar(uint64_t totalSize);
std::string path_;
FILE* file_ = nullptr;
::ZSTD_seekable_s* seekable_ = nullptr;
std::vector<uint8_t> memory_; // whole tar, when the archive isn't seekable
std::map<std::string, Entry, std::less<>> entries_;
};
} // namespace keel

171
engine/platform/input.cpp Normal file
View file

@ -0,0 +1,171 @@
#include "platform/input.h"
#include "platform/touch.h"
#include "core/cvar.h"
#include "core/log.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <map>
#include <set>
namespace keel::input {
namespace {
float g_dx = 0, g_dy = 0;
bool g_captured = false;
bool g_noCapture = false;
std::map<std::string, std::string> g_binds; // key name → command
std::set<std::string> g_held; // keys whose "+" command is active
void (*g_capture)(const char*) = nullptr;
std::string lower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return char(std::tolower(c)); });
return s;
}
std::string keyName(SDL_Scancode sc) {
switch (sc) {
case SDL_SCANCODE_LCTRL: case SDL_SCANCODE_RCTRL: return "ctrl";
case SDL_SCANCODE_LSHIFT: case SDL_SCANCODE_RSHIFT: return "shift";
case SDL_SCANCODE_LALT: case SDL_SCANCODE_RALT: return "alt";
default: return lower(SDL_GetScancodeName(sc));
}
}
void press(const std::string& key, bool down) {
if (down && g_capture) {
auto cb = g_capture;
g_capture = nullptr;
cb(key.c_str());
return;
}
auto it = g_binds.find(key);
if (it == g_binds.end()) return;
const std::string& cmd = it->second;
if (cmd.starts_with('+')) {
if (down && g_held.insert(key).second) execute(cmd);
else if (!down && g_held.erase(key)) execute("-" + cmd.substr(1));
} else if (down) {
execute(cmd);
}
}
Cmd cmd_bind("bind", [](const Args& a) {
if (a.size() < 2) return log::info("usage: bind <key> [command]");
if (a.size() == 2) {
auto it = g_binds.find(lower(a[1]));
return log::info("\"{}\" = \"{}\"", a[1], it == g_binds.end() ? "" : it->second);
}
std::string cmd;
for (size_t i = 2; i < a.size(); ++i) cmd += (i > 2 ? " " : "") + a[i];
bind(a[1].c_str(), cmd.c_str());
}, "Bind a key to a command");
Cmd cmd_unbind("unbind", [](const Args& a) {
if (a.size() > 1) g_binds.erase(lower(a[1]));
}, "Remove a key binding");
Cmd cmd_unbindall("unbindall", [](const Args&) { g_binds.clear(); }, "Remove all key bindings");
Cmd cmd_bindlist("key_listboundkeys", [](const Args&) {
for (auto& [k, v] : g_binds) log::info(" \"{}\" = \"{}\"", k, v);
log::info("{} keys bound", g_binds.size());
}, "List every key binding");
Cmd cmd_bindlist2("bindlist", [](const Args&) { execute("key_listboundkeys"); }, "List every key binding");
// Key names for the console's suggestions.
std::vector<std::string> keyNames() {
std::vector<std::string> v;
for (char c = 'a'; c <= 'z'; ++c) v.emplace_back(1, c);
for (char c = '0'; c <= '9'; ++c) v.emplace_back(1, c);
for (int f = 1; f <= 12; ++f) v.push_back("f" + std::to_string(f));
for (const char* k : {"space", "tab", "escape", "return", "backspace", "ctrl", "shift", "alt", "left ctrl", "left shift", "left alt", "up", "down",
"left", "right", "insert", "delete", "home", "end", "pageup", "pagedown", "mouse1", "mouse2", "mouse3", "mouse4", "mouse5",
"mwheelup", "mwheeldown", "`", "-", "=", "[", "]", ";", "'", ",", ".", "/"})
v.emplace_back(k);
return v;
}
[[maybe_unused]] const bool g_keyCompleters = [] {
cmd_bind.complete([](std::string_view p, size_t arg) { return matching(arg == 1 ? keyNames() : allNames(), p); });
cmd_unbind.complete([](std::string_view p, size_t) {
std::vector<std::string> v;
for (auto& [k, _] : g_binds) v.push_back(k);
return matching(v, p);
});
return true;
}();
} // namespace
void bind(const char* key, const char* command) {
if (!*command) g_binds.erase(lower(key));
else g_binds[lower(key)] = command;
}
std::string bindingsConfig() {
std::string out = "unbindall\n";
for (auto& [k, v] : g_binds) out += "bind \"" + k + "\" \"" + v + "\"\n";
return out;
}
std::string keyFor(const char* command) {
for (auto& [k, v] : g_binds)
if (v == command) return k;
return {};
}
void captureNextKey(void (*callback)(const char*)) { g_capture = callback; }
void releaseAll() {
for (const std::string& key : std::set<std::string>(g_held)) press(key, false);
}
bool keyDown(int scancode) {
if (!g_captured) return false;
int n = 0;
const bool* keys = SDL_GetKeyboardState(&n);
return scancode >= 0 && scancode < n && keys[scancode];
}
bool mouseDown(int button) { return g_captured && (SDL_GetMouseState(nullptr, nullptr) & SDL_BUTTON_MASK(button)); }
void takeMouseDelta(float& dx, float& dy) {
float tx, ty; // looking with a finger counts as the mouse
touch::takeLook(tx, ty);
dx = g_dx + tx;
dy = g_dy + ty;
g_dx = g_dy = 0;
}
bool captured() { return g_captured; }
void disableCapture() { g_noCapture = true; }
void setCaptured(bool on) {
if (on && g_noCapture) return;
if (on == g_captured) return;
g_captured = on;
if (!on) releaseAll(), touch::releaseAll();
if (SDL_Window* w = SDL_GetKeyboardFocus()) SDL_SetWindowRelativeMouseMode(w, on);
g_dx = g_dy = 0;
}
void handleEvent(const SDL_Event& ev) {
// While playing, touches are the touch controls (platform/touch.cpp), not a mouse.
if ((ev.type == SDL_EVENT_MOUSE_MOTION && ev.motion.which == SDL_TOUCH_MOUSEID) ||
((ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN || ev.type == SDL_EVENT_MOUSE_BUTTON_UP) && ev.button.which == SDL_TOUCH_MOUSEID)) {
if (g_captured) return;
}
if (ev.type == SDL_EVENT_MOUSE_MOTION && g_captured) {
g_dx += ev.motion.xrel;
g_dy += ev.motion.yrel;
}
if (!g_captured && !g_capture) return;
if ((ev.type == SDL_EVENT_KEY_DOWN || ev.type == SDL_EVENT_KEY_UP) && !ev.key.repeat)
press(keyName(ev.key.scancode), ev.type == SDL_EVENT_KEY_DOWN);
if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN || ev.type == SDL_EVENT_MOUSE_BUTTON_UP)
// Source's names: mouse1 left, mouse2 right, mouse3 middle (SDL has middle = 2, right = 3).
press("mouse" + std::to_string(ev.button.button == SDL_BUTTON_RIGHT ? 2 : ev.button.button == SDL_BUTTON_MIDDLE ? 3 : ev.button.button),
ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN);
if (ev.type == SDL_EVENT_MOUSE_WHEEL && ev.wheel.y != 0) {
const std::string key = ev.wheel.y > 0 ? "mwheelup" : "mwheeldown";
press(key, true);
press(key, false);
}
}
} // namespace keel::input

35
engine/platform/input.h Normal file
View file

@ -0,0 +1,35 @@
#pragma once
#include <string>
union SDL_Event;
namespace keel::input {
bool keyDown(int scancode); // SDL_SCANCODE_*
bool mouseDown(int button); // SDL_BUTTON_LEFT etc.
// Mouse movement since the last call, in pixels.
void takeMouseDelta(float& dx, float& dy);
// While captured the cursor is hidden and the game owns the mouse and keyboard.
bool captured();
// Key bindings, Quake/Source style: "bind w +forward" runs "+forward" on press
// and "-forward" on release; other commands run on press. Key names: letters,
// digits, "space", "ctrl", "shift", "escape", "f1".., "mouse1".."mouse5",
// "mwheelup", "mwheeldown".
void bind(const char* key, const char* command);
// Lines "unbindall" + "bind ..." for config.cfg.
std::string bindingsConfig();
// The key bound to `command` ("" if none), for menus.
std::string keyFor(const char* command);
// The next key pressed is reported here instead of running its binding (options menu).
void captureNextKey(void (*callback)(const char* keyName));
// Engine side.
void setCaptured(bool on);
// Never grab the mouse (test mode).
void disableCapture();
void handleEvent(const SDL_Event& ev);
// Releases every held "+" action (when a menu opens mid-press).
void releaseAll();
} // namespace keel::input

190
engine/platform/touch.cpp Normal file
View file

@ -0,0 +1,190 @@
#include "platform/touch.h"
#include "core/cvar.h"
#include <SDL3/SDL.h>
#include <imgui.h>
#include <algorithm>
#include <cmath>
#include <string>
#include <unordered_map>
namespace keel::touch {
namespace {
#if defined(__ANDROID__)
Cvar touch_controls("touch_controls", "1", CVAR_ARCHIVE, "On-screen touch controls (0 off, 1 on)");
#else
Cvar touch_controls("touch_controls", "1", CVAR_ARCHIVE, "On-screen touch controls once the screen is touched (0 never)");
#endif
Cvar touch_sensitivity("touch_sensitivity", "1.6", CVAR_ARCHIVE, "How far looking turns for a finger's drag");
Cvar touch_size("touch_size", "1", CVAR_ARCHIVE, "Size of the touch buttons and stick");
// A button: its command ("+x" held, else run once), label, and where (from the bottom-right
// corner, in shorter-screen-sides), how big.
struct Button {
const char* cmd;
const char* label;
float x, y, r;
};
constexpr Button kButtons[] = {
{"+attack", "FIRE", 0.15f, 0.34f, 0.085f}, {"+jump", "JUMP", 0.33f, 0.14f, 0.07f}, {"+duck", "DUCK", 0.13f, 0.13f, 0.06f},
{"+use", "USE", 0.34f, 0.36f, 0.055f}, {"+attack2", "ALT", 0.13f, 0.57f, 0.055f}, {"+sprint", "RUN", 0.50f, 0.12f, 0.055f},
{"flashlight", "LIGHT", 0.32f, 0.57f, 0.045f}, {"backpack", "BAG", 0.47f, 0.32f, 0.045f},
};
constexpr int kButtonCount = int(sizeof kButtons / sizeof kButtons[0]);
struct Finger {
enum Role { Look, Stick, ButtonHeld, Pause } role;
float x, y; // where it is now (pixels)
float ox, oy; // the stick's centre
int button = -1;
};
std::unordered_map<SDL_FingerID, Finger> g_fingers;
bool g_used = false, g_escape = false;
float g_lookX = 0, g_lookY = 0;
bool g_held[kButtonCount] = {};
#if defined(__ANDROID__)
constexpr bool kAlways = true;
#else
constexpr bool kAlways = false;
#endif
ImVec2 screen() {
const ImVec2 s = ImGui::GetIO().DisplaySize;
return s.x > 0 ? s : ImVec2(1280, 720);
}
float unit() { // the shorter side, scaled
const ImVec2 s = screen();
return std::min(s.x, s.y) * std::clamp(touch_size.f(), 0.5f, 2.0f);
}
ImVec2 buttonAt(const Button& b) {
const ImVec2 s = screen();
const float m = unit();
return ImVec2(s.x - b.x * m, s.y - b.y * m);
}
ImVec2 pauseAt() { return ImVec2(unit() * 0.07f, unit() * 0.07f); }
void press(int i, bool down) {
const Button& b = kButtons[i];
if (b.cmd[0] == '+') {
if (down == g_held[i]) return;
g_held[i] = down;
execute(down ? b.cmd : ("-" + std::string(b.cmd + 1)));
} else if (down) {
execute(b.cmd);
}
}
} // namespace
bool active() { return touch_controls.b() && (kAlways || g_used); }
void handleEvent(const SDL_Event& ev, bool inGame) {
if (ev.type != SDL_EVENT_FINGER_DOWN && ev.type != SDL_EVENT_FINGER_MOTION && ev.type != SDL_EVENT_FINGER_UP) return;
g_used = true;
const ImVec2 s = screen();
const float x = ev.tfinger.x * s.x, y = ev.tfinger.y * s.y;
if (!inGame || !active()) { // menus: SDL's mouse emulation does the taps
if (ev.type == SDL_EVENT_FINGER_UP) g_fingers.erase(ev.tfinger.fingerID);
return;
}
const float m = unit();
if (ev.type == SDL_EVENT_FINGER_DOWN) {
Finger f{Finger::Look, x, y, x, y};
const ImVec2 p = pauseAt();
if (std::hypot(x - p.x, y - p.y) < m * 0.06f) {
f.role = Finger::Pause;
g_escape = true;
} else {
for (int i = 0; i < kButtonCount; ++i) {
const ImVec2 c = buttonAt(kButtons[i]);
if (std::hypot(x - c.x, y - c.y) < kButtons[i].r * m * 1.25f) {
f.role = Finger::ButtonHeld, f.button = i;
press(i, true);
break;
}
}
if (f.role == Finger::Look && x < s.x * 0.4f && y > s.y * 0.3f) f.role = Finger::Stick; // the left thumb's area
}
g_fingers[ev.tfinger.fingerID] = f;
} else if (ev.type == SDL_EVENT_FINGER_MOTION) {
auto it = g_fingers.find(ev.tfinger.fingerID);
if (it == g_fingers.end()) return;
Finger& f = it->second;
if (f.role == Finger::Look) {
g_lookX += (x - f.x) * touch_sensitivity.f();
g_lookY += (y - f.y) * touch_sensitivity.f();
}
f.x = x, f.y = y;
if (f.role == Finger::Stick) { // the stick's centre follows a thumb that goes past its edge
const float r = m * 0.11f, dx = x - f.ox, dy = y - f.oy, d = std::hypot(dx, dy);
if (d > r) f.ox = x - dx / d * r, f.oy = y - dy / d * r;
}
} else {
auto it = g_fingers.find(ev.tfinger.fingerID);
if (it == g_fingers.end()) return;
if (it->second.role == Finger::ButtonHeld) press(it->second.button, false);
g_fingers.erase(it);
}
}
void move(float& forward, float& side) {
forward = side = 0.0f;
const float r = unit() * 0.11f;
for (const auto& [id, f] : g_fingers)
if (f.role == Finger::Stick) {
side = std::clamp((f.x - f.ox) / r, -1.0f, 1.0f);
forward = std::clamp(-(f.y - f.oy) / r, -1.0f, 1.0f);
}
}
void takeLook(float& dx, float& dy) {
dx = g_lookX, dy = g_lookY;
g_lookX = g_lookY = 0;
}
bool takeEscape() { return std::exchange(g_escape, false); }
void releaseAll() {
for (int i = 0; i < kButtonCount; ++i)
if (g_held[i]) press(i, false);
g_fingers.clear();
}
void draw(bool inGame) {
if (!inGame || !active()) return;
ImDrawList* dl = ImGui::GetForegroundDrawList();
const float m = unit();
const ImU32 ring = IM_COL32(255, 255, 255, 110), fill = IM_COL32(20, 20, 24, 70), hot = IM_COL32(220, 40, 40, 150), text = IM_COL32(255, 255, 255, 200);
auto label = [&](ImVec2 c, const char* t) {
const ImVec2 sz = ImGui::CalcTextSize(t);
dl->AddText(ImVec2(c.x - sz.x * 0.5f, c.y - sz.y * 0.5f), text, t);
};
for (int i = 0; i < kButtonCount; ++i) {
const ImVec2 c = buttonAt(kButtons[i]);
const float r = kButtons[i].r * m;
dl->AddCircleFilled(c, r, g_held[i] ? hot : fill, 40);
dl->AddCircle(c, r, ring, 40, 2.0f);
label(c, kButtons[i].label);
}
const ImVec2 p = pauseAt();
dl->AddCircleFilled(p, m * 0.045f, fill, 32);
dl->AddCircle(p, m * 0.045f, ring, 32, 2.0f);
dl->AddRectFilled(ImVec2(p.x - m * 0.016f, p.y - m * 0.02f), ImVec2(p.x - m * 0.006f, p.y + m * 0.02f), text);
dl->AddRectFilled(ImVec2(p.x + m * 0.006f, p.y - m * 0.02f), ImVec2(p.x + m * 0.016f, p.y + m * 0.02f), text);
// The stick, where the thumb is (or a hint of where it goes).
const ImVec2 s = screen();
bool stick = false;
for (const auto& [id, f] : g_fingers)
if (f.role == Finger::Stick) {
stick = true;
dl->AddCircleFilled(ImVec2(f.ox, f.oy), m * 0.11f, fill, 48);
dl->AddCircle(ImVec2(f.ox, f.oy), m * 0.11f, ring, 48, 2.0f);
const float dx = f.x - f.ox, dy = f.y - f.oy, d = std::hypot(dx, dy), k = d > m * 0.11f ? m * 0.11f / d : 1.0f;
dl->AddCircleFilled(ImVec2(f.ox + dx * k, f.oy + dy * k), m * 0.045f, hot, 32);
}
if (!stick) {
const ImVec2 c(m * 0.22f, s.y - m * 0.22f);
dl->AddCircle(c, m * 0.11f, IM_COL32(255, 255, 255, 50), 48, 2.0f);
}
}
} // namespace keel::touch

22
engine/platform/touch.h Normal file
View file

@ -0,0 +1,22 @@
#pragma once
// Touch controls (phones, tablets, touch screens): while you play, a stick under your left thumb
// moves, dragging on the right looks, and buttons on the right press the same "+jump",
// "+attack"... a key would. In menus, touches are taps (SDL turns them into mouse clicks).
// They show on Android, and anywhere once a finger touches the screen (touch_controls 0: never).
union SDL_Event;
namespace keel::touch {
bool active();
// inGame: playing (the mouse is captured), when touches are controls, not taps.
void handleEvent(const SDL_Event& ev, bool inGame);
// The stick: forward and sideways, -1..1.
void move(float& forward, float& side);
// Looking since the last call, in mouse pixels (so mouse sensitivity applies).
void takeLook(float& dx, float& dy);
// The pause button was pressed (once).
bool takeEscape();
// Draws the controls over the game (playing only).
void draw(bool inGame);
// Lets go of everything held (a menu opened).
void releaseAll();
} // namespace keel::touch

View file

@ -0,0 +1,98 @@
#include "platform/window.h"
#include "core/log.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cstdlib>
#include <filesystem>
#ifdef _WIN32
// Hybrid laptops: ask the NVIDIA / AMD drivers for the dedicated GPU (read from the executable).
extern "C" {
__declspec(dllexport) unsigned long NvOptimusEnablement = 1;
__declspec(dllexport) int AmdPowerXpressRequestHighPerformance = 1;
}
#endif
namespace keel {
namespace {
// Hybrid laptops on Linux: OpenGL goes to whatever GPU runs the desktop (often the integrated
// one) unless asked. Vulkan picks the discrete GPU itself; this makes OpenGL do the same. Set
// before SDL loads the GL library; anything the user already set wins. KEEL_IGPU=1 opts out.
void preferDedicatedGpu() {
#ifndef _WIN32
if (std::getenv("KEEL_IGPU")) return;
if (std::filesystem::exists("/proc/driver/nvidia")) { // NVIDIA's own driver: PRIME render offload
setenv("__NV_PRIME_RENDER_OFFLOAD", "1", 0);
setenv("__GLX_VENDOR_LIBRARY_NAME", "nvidia", 0);
setenv("__VK_LAYER_NV_optimus", "NVIDIA_only", 0);
} else {
setenv("DRI_PRIME", "1", 0); // Mesa: the other GPU if there is one, else the same
}
#endif
}
} // namespace
bool Window::init(const char* title, int width, int height, bool opengl) {
preferDedicatedGpu();
if (!SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS)) {
log::error("SDL_Init failed: {}", SDL_GetError());
return false;
}
window_ = SDL_CreateWindow(title, width, height, (opengl ? SDL_WINDOW_OPENGL : SDL_WINDOW_VULKAN) | SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY);
if (!window_) {
log::error("SDL_CreateWindow failed: {}", SDL_GetError());
return false;
}
return true;
}
void Window::shutdown() {
if (window_) SDL_DestroyWindow(window_);
window_ = nullptr;
SDL_Quit();
}
void Window::setIcon(const uint8_t* rgba, int width, int height) {
if (SDL_Surface* s = SDL_CreateSurfaceFrom(width, height, SDL_PIXELFORMAT_ABGR8888, const_cast<uint8_t*>(rgba), width * 4)) {
SDL_SetWindowIcon(window_, s);
SDL_DestroySurface(s);
}
}
void Window::setMode(int mode, int width, int height) {
if (!window_) return;
if (mode == 0) {
SDL_SetWindowFullscreen(window_, false);
SDL_SetWindowSize(window_, width, height);
SDL_SetWindowPosition(window_, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED);
return;
}
SDL_DisplayMode closest{};
const bool exclusive = mode == 2 && SDL_GetClosestFullscreenDisplayMode(SDL_GetDisplayForWindow(window_), width, height, 0.0f, true, &closest);
SDL_SetWindowFullscreenMode(window_, exclusive ? &closest : nullptr); // nullptr = borderless desktop
SDL_SetWindowFullscreen(window_, true);
}
std::vector<std::pair<int, int>> Window::resolutions() const {
std::vector<std::pair<int, int>> out;
int count = 0;
if (SDL_DisplayMode** modes = SDL_GetFullscreenDisplayModes(SDL_GetDisplayForWindow(window_), &count)) {
for (int i = 0; i < count; ++i) {
const std::pair<int, int> r{modes[i]->w, modes[i]->h};
if (std::find(out.begin(), out.end(), r) == out.end()) out.push_back(r);
}
SDL_free(modes);
}
return out;
}
void Window::pixelSize(uint32_t& w, uint32_t& h) const {
int iw = 0, ih = 0;
if (!(SDL_GetWindowFlags(window_) & SDL_WINDOW_MINIMIZED)) SDL_GetWindowSizeInPixels(window_, &iw, &ih);
w = uint32_t(iw);
h = uint32_t(ih);
}
} // namespace keel

31
engine/platform/window.h Normal file
View file

@ -0,0 +1,31 @@
#pragma once
#include <cstdint>
#include <cstdint>
#include <utility>
#include <vector>
struct SDL_Window;
namespace keel {
class Window {
public:
// opengl: a window for an OpenGL context (the backend creates it); otherwise Vulkan.
bool init(const char* title, int width, int height, bool opengl = false);
void shutdown();
SDL_Window* handle() const { return window_; }
// Size in pixels (not points); 0×0 while minimized.
void pixelSize(uint32_t& w, uint32_t& h) const;
// mode: 0 = windowed (width × height), 1 = borderless fullscreen (desktop size),
// 2 = exclusive fullscreen at the display mode closest to width × height.
void setMode(int mode, int width, int height);
void setIcon(const uint8_t* rgba, int width, int height);
// Fullscreen resolutions the display offers, largest first, no duplicates.
std::vector<std::pair<int, int>> resolutions() const;
private:
SDL_Window* window_ = nullptr;
};
} // namespace keel

240
engine/render/backend.h Normal file
View file

@ -0,0 +1,240 @@
#pragma once
// The line between the renderer and a graphics API. The Renderer (render/renderer.cpp) does
// everything that isn't API work — culling, lights and clusters, shadow cascades, sorting —
// and hands the result over each frame as a FramePacket; a RenderBackend (Vulkan or OpenGL)
// only owns GPU objects and records the passes. Both backends run the same shaders
// (engine/shaders), so the structures below match their layouts exactly.
#include "asset/kmdl.h"
#include "render/clusters.h"
#include <glm/glm.hpp>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
namespace keel {
class Window;
using TextureId = uint32_t;
// OpenGL40/OpenGL31: the cut-down fallback for old GPUs (rhi/gl/gl_legacy.cpp).
enum class RenderApi { Vulkan, OpenGL, OpenGL40, OpenGL31 };
// Texture slots. 0 and 1 are created by the renderer; 2-4 are the backend's render targets
// (sampled through the same table); loaded textures start at kFirstTextureSlot.
inline constexpr TextureId kWhiteTexture = 0, kMissingTexture = 1, kSceneSlot = 2, kSunShadowSlot = 3, kAtlasSlot = 4, kDepthSlot = 5;
// Post-processing targets: occlusion (raw, blurred), bloom levels (half size down to 1/64).
inline constexpr TextureId kAoSlot = 6, kAoBlurSlot = 7, kBloomSlot = 8;
inline constexpr uint32_t kBloomLevels = 6;
inline constexpr TextureId kTaaSlot = kBloomSlot + kBloomLevels; // two: this frame's and last frame's
// The mesh pass's G-buffer (view normal, roughness, reflection weight) and the reflections from it.
inline constexpr TextureId kGbufSlot = kTaaSlot + 2, kSsrSlot = kGbufSlot + 1;
inline constexpr TextureId kAdaptSlot = kSsrSlot + 1; // two (1×1): eye adaptation, this frame's and last frame's
// The occlusion grid and its pyramid (each level half the last, the farthest depth under each
// texel), for GPU culling: kHzbLevels slots from kHzbSlot.
inline constexpr uint32_t kHzbLevels = 8; // 256×128 down to 2×1
inline constexpr TextureId kHzbSlot = kAdaptSlot + 2;
inline constexpr TextureId kMirrorSlot = kHzbSlot + kHzbLevels; // what the nearest mirror shows
inline constexpr TextureId kFirstTextureSlot = kMirrorSlot + 1;
inline constexpr uint32_t kHzbWidth = 256, kHzbHeight = 128; // the occlusion grid read back to the CPU
inline constexpr uint32_t kMaxTextures = 4096;
inline constexpr uint32_t kShadowSize = 4096; // sun: 2×2 cascades (the default size; r_shadowres)
inline constexpr uint32_t kAtlasSize = 4096; // dynamic lights' shadow tiles
inline constexpr size_t kMaxDraws = 8192;
inline constexpr size_t kMaxJointsPerFrame = 16384;
inline constexpr uint32_t kMaxLights = 1024, kMaxShadowViews = 96, kMaxLightIndices = 1 << 16;
namespace gpu {
struct DrawData { // DRAW_FIELDS in model.glsl
glm::mat4 model;
glm::vec4 cube[6];
glm::vec4 params; // x = sun visibility, y = lit by probes, z = liquid cut, w = LiquidStyle
glm::vec4 plane; // liquid surface
glm::vec4 probe; // the nearest reflection probe: x = its texture (0 = none), y = its top level; z = time (s)
};
struct FrameData { // FRAME_FIELDS in model.glsl, then (Vulkan) four buffer addresses
glm::mat4 cascades[4];
glm::vec4 splits, cascadeTexel;
glm::vec4 sunDir, sunColor, shadow;
glm::vec4 volumetric; // x strength (0 = off), y max distance, z air density
glm::vec4 camPos, camFwd, screen;
glm::vec4 sky, fog; // sky texture / intensity / top level / yaw; fog density / falloff / max / base z
glm::vec4 skySH[9];
glm::vec4 froxel{0.0f}; // the fog grid: x 1 = on, y near, z far (froxel.glsl)
glm::vec4 gi{0.0f}, giOrigin{0.0f}; // GI probes: x 1 = on, y spacing; the grid's first cell (gi.glsl)
glm::vec4 mirror{0.0f}, clip{0.0f}; // mirrors: the picture's texture / its own pass; that pass's cut plane
glm::vec4 weather{0.0f}; // x wet, y snow cover, z night, w lightning flash (Renderer::setWeather)
uint64_t lights = 0, cells = 0, lightIndices = 0, shadowViews = 0, froxels = 0, giProbes = 0; // filled by the Vulkan backend
};
struct Light { // Light in model.glsl
glm::vec4 posRadius, color, spot, extra;
};
struct ShadowView { // ShadowView in model.glsl
glm::mat4 viewProj;
glm::vec4 rect;
};
struct SkyPush { // sky.frag's push block
glm::mat4 invViewProj;
glm::vec4 sunDir, sunColor, sky;
glm::vec4 weather; // x cloud cover, y the photo's sun height (sky.frag), z night (stars), w lightning flash
};
struct Sprite { // sprite.vert's push block (after viewProj)
glm::vec4 posSize, color, right, up; // right.w rotation, up.w texture index (as bits)
};
struct Box { // box.vert's push block (after viewProj)
glm::vec4 center, extents, color;
};
} // namespace gpu
// One indexed draw of a model's submesh.
struct MeshDraw {
uint32_t model; // backend model slot
uint32_t firstIndex, indexCount;
uint32_t draw; // into FramePacket::draws
uint32_t firstJoint; // into FramePacket::joints; UINT32_MAX = not skinned
uint32_t textures, tint, material;
bool liquid = false; // cut at its surface in the shader: not in the depth pre-pass
glm::vec3 lo{0.0f}, hi{0.0f}; // world box (opaque scene draws: GPU occlusion culling)
};
// Something rays can hit (real-time GI): a model's submesh where it stands, and its look.
struct RayInstance {
glm::mat4 transform;
uint32_t model, firstIndex, textures, tint;
};
// A shadow map render: a view and a rectangle of the target, in pixels from the top-left.
struct ShadowPass {
glm::mat4 viewProj;
glm::ivec4 rect;
const std::vector<MeshDraw>* draws;
};
// Volumetric smoke's grid (the fog grid reads it, Vulkan): kSmokeDims cells of kSmokeCell units.
inline constexpr glm::ivec3 kSmokeDims{160, 160, 48};
inline constexpr float kSmokeCell = 16.0f;
struct FramePacket {
uint32_t width = 0, height = 0; // the 3D scene's size (render scale applied)
uint32_t outWidth = 0, outHeight = 0; // the window's: the tonemap scales the scene up to it, the UI is drawn at it
bool prepass = true; // r_prepass: depth first, then each pixel shaded once
// GPU occlusion culling (r_gpuculling): the opaque draws are tested on the GPU against the
// occlusion grid, made from last frame (cullViewProj); cullOcclusion: the camera's close
// enough to that frame's for the test to hold.
bool gpuCull = false, cullOcclusion = false;
std::vector<RayInstance> rayInstances; // real-time GI: the opaque, still-shaped world (not skinned)
// Smoke: kSmokeDims cells from smokeOrigin (its world corner; w a clock that turns its
// billows), each density | shade << 8 (0..255); empty: none.
std::vector<uint16_t> smoke;
glm::vec4 smokeOrigin{0.0f};
// A mirror: the scene again from the eye reflected in its plane (into kMirrorSlot, the
// scene's size), before the scene pass, with its own frame data (mirrorFrame).
float skyCloud = 0.0f; // overcast: the sky greys and the sun's disc goes (sky.frag)
float skyPhotoSunZ = 2.0f; // how high the sky photo's own sun is (2: none), to hide it when the sun has moved
bool mirror = false;
glm::mat4 mirrorViewProj{1.0f}, mirrorInvViewProj{1.0f};
glm::ivec4 mirrorRect{0}; // where the mirror is on screen (x, y, w, h in pixels): only that's drawn
gpu::FrameData mirrorFrame{};
std::vector<MeshDraw> mirrorOpaque, mirrorTranslucent;
glm::mat4 cullViewProj{1.0f};
glm::mat4 viewProj{1.0f};
gpu::FrameData frame{};
std::vector<gpu::DrawData> draws;
std::vector<glm::mat4> joints;
std::vector<gpu::Light> lights;
std::vector<gpu::ShadowView> views;
const ClusterGrid* clusters = nullptr;
std::vector<ShadowPass> sunPasses; // into the sun's shadow map (kSunShadowSlot)
std::vector<ShadowPass> atlasPasses; // into the dynamic lights' atlas (kAtlasSlot)
std::vector<gpu::Box> boxes;
std::vector<gpu::Sprite> sprites, glowSprites; // particles: alpha-blended (back to front), additive
std::vector<MeshDraw> opaque;
std::vector<MeshDraw> translucent; // already sorted back to front
glm::vec3 clearColor{0.0f}; // linear, before exposure (when there's no sky)
glm::mat4 invViewProj{1.0f}; // for the sky pass (drawn when frame.sky.x != 0)
float exposure = 1.0f;
std::string screenshot; // save this frame as PNG here, if set
uint32_t shadowSize = kShadowSize; // the sun's shadow map (2×2 cascades); the backend resizes to it
uint64_t hzbTag = 0; // nonzero: build the occlusion grid and read it back with this tag
// Post-processing: ambient occlusion (0 = off), bloom (0 = off), grade.
struct Post {
float aoStrength = 1.0f, aoRadius = 24.0f;
float bloomStrength = 0.035f;
float saturation = 1.05f, vignette = 0.25f, sharpen = 0.3f;
glm::vec4 proj{1.0f, 1.0f, 2.0f, 16384.0f}; // tan(half fov x), tan(half fov y), near, far
bool taa = false, taaReset = true;
// Sun shafts (screen-space light scattering): the sun's screen position (uv) and
// strength (z, 0 = off), and its colour × intensity.
glm::vec4 shafts{0.0f}, sunColor{0.0f};
float ssr = 0.0f; // screen-space reflections' strength (0 = off)
// Eye adaptation: on, the frame's seconds, the key (mid grey's target), reset (no history).
bool adapt = false, adaptReset = true;
float adaptDt = 0.016f, adaptKey = 0.18f;
glm::mat4 reproject{1.0f}; // this frame's clip space (unjittered) → last frame's
} post;
};
// The occlusion grid of an earlier frame (depth, kHzbWidth × kHzbHeight, row 0 = clip y −1, as
// the depth buffer's rows), and the tag that frame was given.
struct HzbReadback {
uint64_t tag = 0;
std::vector<float> depth;
};
// How a texture's mips are laid out.
enum class TexFormat : uint8_t { RGBA8, BC7 };
// The GPU and its memory (the debug overlay's corner). VRAM: the device-local heaps; used is all
// processes' use where the driver says (VK_EXT_memory_budget), else just ours.
struct GpuInfo {
std::string name, driver;
uint64_t vramTotal = 0, vramUsed = 0, vramOurs = 0;
};
class RenderBackend {
public:
virtual ~RenderBackend() = default;
virtual const char* name() const = 0;
virtual GpuInfo gpuInfo() const { return {}; }
virtual bool init(const Window& window, bool validation) = 0;
virtual void shutdown() = 0;
// Dear ImGui: the backend's NewFrame (renderer + platform).
virtual void beginUi() = 0;
virtual void setVsync(bool on) = 0;
virtual void onResize() = 0;
// A texture in table slot `slot`, mips largest first: RGBA8 pixels, or BC7 blocks (YAPF 2's
// GPU payload) when blockCompression() says the GPU takes them.
virtual void createTexture(TextureId slot, uint32_t width, uint32_t height, bool srgb, const std::vector<std::vector<uint8_t>>& mips,
TexFormat format = TexFormat::RGBA8) = 0;
virtual bool blockCompression() const { return false; }
// Can draw the scene smaller than the window and scale it up (r_renderscale, r_dynres).
virtual bool renderScale() const { return false; }
// Model GPU data in slot `model`: created, or replaced (the old data lives until no frame uses it).
virtual void uploadModel(uint32_t model, const KmdlModel& kmdl) = 0;
// A texture as Dear ImGui's ImTextureID, for drawing it in the UI (0 = none).
virtual uint64_t uiTexture(TextureId slot) = 0;
// Starts a frame; false when nothing can be drawn (minimised). Sets the drawable size.
virtual bool beginFrame(uint32_t& width, uint32_t& height) = 0;
// Renders the packet, then ImGui's draw data, and presents.
virtual void render(FramePacket& packet) = 0;
// The newest occlusion grid that has come back from the GPU (a frame or two old); false if
// none newer than the last one taken.
virtual bool takeHzb(HzbReadback& out) = 0;
// How long each pass took on the GPU, a frame or two ago (r_profile); empty if not measured.
virtual void gpuTimes(std::vector<std::pair<const char*, float>>& out) const { out.clear(); }
// GPU occlusion culling: supported, and how many draws it hid (a frame or two ago).
virtual bool gpuCulling() const { return false; }
virtual bool froxelFog() const { return false; } // the fog grid (FrameData.froxel)
virtual bool rayTracing() const { return false; } // hardware ray queries: real-time GI
virtual bool mirrors() const { return false; } // a second scene pass for a mirror (FramePacket::mirror)
virtual uint32_t gpuCulled() const { return 0; }
};
std::unique_ptr<RenderBackend> createVulkanBackend();
std::unique_ptr<RenderBackend> createOpenGLBackend();
std::unique_ptr<RenderBackend> createLegacyOpenGLBackend(int major, int minor);
} // namespace keel

View file

@ -0,0 +1,72 @@
#include "render/clusters.h"
#include <algorithm>
#include <cmath>
namespace keel {
namespace {
const float kLogRatio = std::log(kClusterFar / kClusterNear);
float sliceStart(uint32_t k) { return k == 0 ? 0.0f : kClusterNear * std::exp(kLogRatio * float(k) / kClusterZ); }
float distSq(float v, float lo, float hi) {
const float d = v < lo ? lo - v : v > hi ? v - hi : 0.0f;
return d * d;
}
} // namespace
uint32_t clusterSlice(float depth) {
if (depth <= kClusterNear) return 0;
return std::min(uint32_t(std::log(depth / kClusterNear) / kLogRatio * kClusterZ), kClusterZ - 1);
}
void buildClusters(const ClusterView& v, const std::vector<DynamicLight>& lights, size_t maxIndices, ClusterGrid& out) {
const uint32_t cellCount = kClusterX * kClusterY * kClusterZ;
out.cells.assign(cellCount, glm::uvec2(0));
out.indices.clear();
const float tanY = v.tanHalfY, tanX = v.tanHalfY * v.aspect;
std::vector<uint32_t> pairs; // cell << 12 | light
for (uint32_t li = 0; li < lights.size() && li < 4096; ++li) {
const DynamicLight& l = lights[li];
const glm::vec3 d = l.origin - v.origin;
const glm::vec3 c(glm::dot(d, v.right), glm::dot(d, v.up), glm::dot(d, v.forward)); // view space, z forward
const float r = l.radius;
if (c.z + r <= 0.0f) continue;
const uint32_t z0 = clusterSlice(std::max(c.z - r, 0.0f)), z1 = clusterSlice(c.z + r);
for (uint32_t z = z0; z <= z1; ++z) {
const float dn = std::max(sliceStart(z), 0.05f), df = z + 1 == kClusterZ ? kClusterFar : sliceStart(z + 1);
// Conservative tile range from the sphere's extent at both ends of the slice.
auto tileU = [&](float x, float depth) { return (x / (depth * tanX) + 1.0f) * 0.5f * kClusterX; };
auto tileV = [&](float y, float depth) { return (1.0f - y / (depth * tanY)) * 0.5f * kClusterY; };
float u0 = 1e9f, u1 = -1e9f, v0 = 1e9f, v1 = -1e9f;
for (float depth : {dn, df}) {
for (float x : {c.x - r, c.x + r}) u0 = std::min(u0, tileU(x, depth)), u1 = std::max(u1, tileU(x, depth));
for (float y : {c.y - r, c.y + r}) v0 = std::min(v0, tileV(y, depth)), v1 = std::max(v1, tileV(y, depth));
}
const int x0 = std::max(int(std::floor(u0)), 0), x1 = std::min(int(std::floor(u1)), int(kClusterX) - 1);
const int y0 = std::max(int(std::floor(v0)), 0), y1 = std::min(int(std::floor(v1)), int(kClusterY) - 1);
for (int y = y0; y <= y1; ++y) {
// Cell bounds in view space: y shrinks downwards (v grows), x grows rightwards.
const float yt = 1.0f - 2.0f * y / kClusterY, yb = 1.0f - 2.0f * (y + 1) / kClusterY;
const float ylo = std::min({yb * dn, yb * df}) * tanY, yhi = std::max({yt * dn, yt * df}) * tanY;
for (int x = x0; x <= x1; ++x) {
const float xl = 2.0f * x / kClusterX - 1.0f, xr = 2.0f * (x + 1) / kClusterX - 1.0f;
const float xlo = std::min({xl * dn, xl * df}) * tanX, xhi = std::max({xr * dn, xr * df}) * tanX;
if (distSq(c.x, xlo, xhi) + distSq(c.y, ylo, yhi) + distSq(c.z, dn, df) > r * r) continue;
pairs.push_back(((z * kClusterY + y) * kClusterX + x) << 12 | li);
}
}
}
}
std::sort(pairs.begin(), pairs.end());
if (pairs.size() > maxIndices) pairs.resize(maxIndices);
out.indices.reserve(pairs.size());
for (uint32_t p : pairs) {
glm::uvec2& cell = out.cells[p >> 12];
if (cell.y == 0) cell.x = uint32_t(out.indices.size());
++cell.y;
out.indices.push_back(p & 0xFFF);
}
}
} // namespace keel

45
engine/render/clusters.h Normal file
View file

@ -0,0 +1,45 @@
#pragma once
// Clustered lighting: the view frustum is cut into a 3D grid (screen tiles × depth
// slices) and each cell lists the lights that can reach it, so a pixel only shades
// the few lights near it. Built on the CPU each frame; no GPU code here, so it is
// shared by every render backend and testable headless.
#include <glm/glm.hpp>
#include <cstdint>
#include <vector>
namespace keel {
struct DynamicLight {
glm::vec3 origin{0.0f};
float radius = 300.0f; // light reaches zero here
glm::vec3 color{1.0f}; // linear, already scaled by intensity
glm::vec3 direction{0, 0, -1}; // spot lights
float innerCone = 0.0f; // spot half-angles in degrees; outerCone 0 = point light
float outerCone = 0.0f;
bool shadows = false;
float size = 0.0f; // the light's own radius (a bulb, a lamp, a window): softer, wider highlights
bool reflector = false; // a flashlight's beam (spotShape in model.glsl): hot centre, a ring, a dim spill
bool spot() const { return outerCone > 0.0f; }
};
// Must match lights.glsl.
inline constexpr uint32_t kClusterX = 16, kClusterY = 9, kClusterZ = 24;
inline constexpr float kClusterNear = 8.0f, kClusterFar = 16384.0f;
struct ClusterView {
glm::vec3 origin, forward, right, up; // up is the screen's up (after roll)
float tanHalfY = 1.0f, aspect = 1.0f;
};
struct ClusterGrid {
std::vector<glm::uvec2> cells; // per cell: first index, light count; cell = (z * Y + y) * X + x
std::vector<uint32_t> indices; // light indices, grouped by cell
};
// Depth slice for a distance along the view direction.
uint32_t clusterSlice(float depth);
// Lights past maxIndices total cell entries are dropped (farthest cells last).
void buildClusters(const ClusterView& view, const std::vector<DynamicLight>& lights, size_t maxIndices, ClusterGrid& out);
} // namespace keel

804
engine/render/renderer.cpp Normal file
View file

@ -0,0 +1,804 @@
// The renderer's API-independent half: collects draws, lights and the camera each frame,
// works out shadow cascades, light clusters, culling and sorting, and hands the result to
// the backend (Vulkan or OpenGL) as a FramePacket.
#include <optional>
#include "render/renderer.h"
#include "core/time.h"
#include "asset/ksky.h"
#include "core/cvar.h"
#include "fs/vfs.h"
#include "core/log.h"
#include <glm/gtc/matrix_transform.hpp>
#include <imgui.h>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
namespace keel {
namespace {
// Planes (xyz·p + w ≥ 0 inside) of a view-projection's frustum. `sides` only: no near/far
// (sun cascades keep casters between the sun and the slice they shadow).
struct Frustum {
glm::vec4 planes[6];
int count = 0;
Frustum(const glm::mat4& m, bool sidesOnly) {
const glm::mat4 t = glm::transpose(m);
planes[count++] = t[3] + t[0];
planes[count++] = t[3] - t[0];
planes[count++] = t[3] + t[1];
planes[count++] = t[3] - t[1];
if (!sidesOnly) planes[count++] = t[2]; // near (depth 0..1); no far: the sky is there
}
// The world box of a transformed model-space box, tested against every plane.
bool sees(const glm::mat4& xf, const glm::vec3& lo, const glm::vec3& hi) const {
const glm::vec3 c = glm::vec3(xf * glm::vec4((lo + hi) * 0.5f, 1.0f));
const glm::vec3 h = (hi - lo) * 0.5f;
const glm::mat3 a(glm::abs(glm::vec3(xf[0])), glm::abs(glm::vec3(xf[1])), glm::abs(glm::vec3(xf[2])));
const glm::vec3 e = a * h;
for (int i = 0; i < count; ++i) {
const glm::vec3 n(planes[i]);
if (glm::dot(n, c) + planes[i].w + glm::dot(glm::abs(n), e) < 0.0f) return false;
}
return true;
}
};
Cvar r_vsync("r_vsync", "1", CVAR_ARCHIVE, "Wait for vertical sync (0 = uncapped)");
Cvar r_shadowres("r_shadowres", "4096", CVAR_ARCHIVE, "Sun shadow map size: 2048, 4096 or 8192 (2×2 cascades)");
Cvar r_cull("r_cull", "1", CVAR_NONE, "Skip what the camera (or a shadow) can't see");
Cvar r_occlusion("r_occlusion", "1", CVAR_ARCHIVE, "Skip what's hidden behind other things (hierarchical Z of a frame ago)");
Cvar r_skyintensity("r_skyintensity", "1", CVAR_ARCHIVE, "Sky brightness (times the map's)");
Cvar r_fog("r_fog", "1", CVAR_ARCHIVE, "Height fog / aerial perspective");
Cvar r_gi("r_gi", "1", CVAR_ARCHIVE, "Real-time GI (Vulkan, ray-tracing GPUs): light from flashlights, fires and lamps bounces off what's around (2: show only that)");
Cvar r_mirrors("r_mirrors", "1", CVAR_ARCHIVE, "Mirrors show the room (the nearest one in view; Vulkan)");
Cvar r_volumetricfog("r_volumetricfog", "1", CVAR_ARCHIVE, "Fog in a 3D grid (Vulkan): lit by the sun, its shadows and the lamps");
Cvar r_fogscale("r_fogscale", "1", CVAR_CHEAT, "Multiplies the map's fog density (testing)");
Cvar r_skycolor("r_skycolor", "0.45 0.6 0.8", CVAR_ARCHIVE, "Sky colour, \"r g b\" in sRGB");
Cvar r_exposure("r_exposure", "1.4", CVAR_ARCHIVE, "Scene brightness before tonemapping");
Cvar r_ssao("r_ssao", "1", CVAR_ARCHIVE, "Ambient occlusion: soft shadow in corners and under things (0 off .. 1 full)");
Cvar r_ssao_radius("r_ssao_radius", "24", CVAR_ARCHIVE, "Ambient occlusion reach (units)");
Cvar r_bloom("r_bloom", "0.035", CVAR_ARCHIVE, "Bloom: how much bright light glows over its surroundings (0 off)");
Cvar r_saturation("r_saturation", "1.05", CVAR_ARCHIVE, "Colour saturation after tonemapping");
Cvar r_taa("r_taa", "1", CVAR_ARCHIVE, "Temporal anti-aliasing: smooth edges using the last frames");
Cvar r_sharpen("r_sharpen", "0.3", CVAR_ARCHIVE, "Sharpening after TAA (0 off)");
Cvar r_shadowsoft("r_shadowsoft", "1", CVAR_ARCHIVE, "Soft sun shadows (PCSS): sharp where they touch, softer further away; the sun's size (0 = a fixed small blur)");
Cvar r_volumetric("r_volumetric", "0", CVAR_ARCHIVE, "Volumetric sunlight: shafts of sun in the air, shadowed by what's in the way (0 off)");
Cvar r_autoexposure("r_autoexposure", "1", CVAR_ARCHIVE, "Eye adaptation: brighter in the dark, darker in the sun, over a second or two (0 off)");
Cvar r_renderscale("r_renderscale", "1", CVAR_ARCHIVE, "The 3D scene's resolution as a fraction of the window's (0.5 to 1; TAA scales it up)");
Cvar r_dynres("r_dynres", "0", CVAR_ARCHIVE, "Dynamic resolution: lower the render scale as needed to hold this frame rate (0 off)");
Cvar r_gpuculling("r_gpuculling", "1", CVAR_ARCHIVE, "Occlusion culling on the GPU (Vulkan): hidden things aren't drawn, tested a frame sooner than on the CPU");
Cvar r_prepass("r_prepass", "1", CVAR_ARCHIVE, "Depth pre-pass: the scene's depth first, so each pixel is lit once (faster when things overlap)");
Cvar r_ssr("r_ssr", "0", CVAR_ARCHIVE, "Screen-space reflections: what's on screen mirrored in glossy floors, metal and water (0 off)");
Cvar r_shafts("r_shafts", "0.5", CVAR_ARCHIVE, "Sun shafts: light scattering in the air around the sun where the sky shows through (0 off)");
Cvar r_vignette("r_vignette", "0.25", CVAR_ARCHIVE, "Darkening towards the screen's corners");
Cvar r_dynamic("r_dynamic", "1", CVAR_ARCHIVE, "Real-time lights (flashlights, map light_dynamic/light_spot, scripts)");
Cvar r_maxlights("r_maxlights", "256", CVAR_ARCHIVE, "Most dynamic lights drawn per frame (nearest win)");
Cvar r_lightshadows("r_lightshadows", "4", CVAR_ARCHIVE, "Most dynamic lights casting shadows per frame (0 = none)");
Cvar r_lightshadowres("r_lightshadowres", "512", CVAR_ARCHIVE, "Shadow resolution per light face: 256, 512, 1024 or 2048");
Cvar r_lightdebug("r_lightdebug", "0", 0, "1 = tint the scene by how many lights touch each cluster");
Cvar r_shadowdist("r_shadowdist", "4000", CVAR_ARCHIVE, "How far sun shadows reach (units)");
Cvar r_drawlights("r_drawlights", "0", 0, "Draw a small box at every dynamic light");
float srgbToLinear(float c) { return c <= 0.04045f ? c / 12.92f : std::pow((c + 0.055f) / 1.055f, 2.4f); }
uint32_t packColor(const glm::vec4& c) {
auto b = [](float v) { return uint32_t(std::lround(std::clamp(v, 0.0f, 1.0f) * 255.0f)); };
return b(c.r) | b(c.g) << 8 | b(c.b) << 16 | b(c.a) << 24;
}
glm::vec3 forwardOf(const Camera& c) {
const float yaw = glm::radians(c.yaw), pitch = glm::radians(c.pitch);
return {std::cos(pitch) * std::cos(yaw), std::cos(pitch) * std::sin(yaw), -std::sin(pitch)};
}
glm::mat4 viewProjection(const Camera& c, float aspect) {
const glm::vec3 forward = forwardOf(c);
const glm::vec3 up = glm::vec3(glm::rotate(glm::mat4(1), glm::radians(c.roll), forward) * glm::vec4(0, 0, 1, 0));
glm::mat4 view = glm::lookAt(c.origin, c.origin + forward, up);
glm::mat4 proj = glm::perspective(glm::radians(c.fovY), aspect, 2.0f, 16384.0f);
proj[1][1] *= -1.0f; // clip-space Y points down (Vulkan; OpenGL is set up to match)
proj[2][0] -= c.shift.x, proj[2][1] -= c.shift.y;
return proj * view;
}
} // namespace
glm::mat4 Renderer::cameraViewProjection(float aspect) const { return viewProjection(camera_, aspect); }
bool Renderer::init(const Window& window, bool validation, RenderApi api) {
switch (api) {
case RenderApi::OpenGL: backend_ = createOpenGLBackend(); break;
case RenderApi::OpenGL40: backend_ = createLegacyOpenGLBackend(4, 0); break;
case RenderApi::OpenGL31: backend_ = createLegacyOpenGLBackend(3, 1); break;
default: backend_ = createVulkanBackend(); break;
}
if (!backend_->init(window, validation)) {
backend_->shutdown();
backend_.reset();
return false;
}
log::info("Renderer: {}", backend_->name());
// Fixed slots: 0 white, 1 a "missing" checker. (2-4 are the backend's render targets.)
const std::vector<uint8_t> white(4, 255);
backend_->createTexture(kWhiteTexture, 1, 1, false, {white});
std::vector<uint8_t> missing(64 * 64 * 4);
for (int y = 0; y < 64; ++y)
for (int x = 0; x < 64; ++x) {
const bool on = ((x / 8) ^ (y / 8)) & 1;
uint8_t* p = &missing[(y * 64 + x) * 4];
p[0] = on ? 255 : 0; p[1] = 0; p[2] = on ? 255 : 0; p[3] = 255;
}
backend_->createTexture(kMissingTexture, 64, 64, false, {missing});
return true;
}
void Renderer::shutdown() {
if (backend_) backend_->shutdown();
backend_.reset();
models_.clear();
modelCache_.clear();
textureCache_.clear();
nextTexture_ = kFirstTextureSlot;
}
void Renderer::beginUi() { backend_->beginUi(); }
void Renderer::drawBox(const glm::vec3& mins, const glm::vec3& maxs, const glm::vec4& color, TextureId texture) {
packet_.boxes.push_back({glm::vec4((mins + maxs) * 0.5f, float(texture)), glm::vec4((maxs - mins) * 0.5f, 0), color});
}
const Renderer::Sky* Renderer::loadSky(const std::string& path) {
if (auto it = skies_.find(path); it != skies_.end()) return &it->second;
const auto data = fs::read(path);
KskyHeader h{};
if (!data || data->size() < sizeof h) {
log::warn("Sky {} not found", path);
return nullptr;
}
std::memcpy(&h, data->data(), sizeof h);
if (std::memcmp(h.magic, "KSKY", 4) != 0 || h.version != kKskyVersion) {
log::warn("{}: not a sky (version {})", path, h.version);
return nullptr;
}
std::vector<std::vector<uint8_t>> mips;
size_t at = sizeof h;
for (uint32_t k = 0; k < h.levels; ++k) {
const size_t bytes = size_t(h.width >> k) * size_t(h.height >> k) * 4;
if (at + bytes > data->size()) return nullptr;
mips.emplace_back(data->begin() + ptrdiff_t(at), data->begin() + ptrdiff_t(at + bytes));
at += bytes;
}
Sky sky;
sky.texture = newTexture(h.width, h.height, false, mips);
sky.levels = h.levels;
sky.sunDir = glm::vec3(h.sunDir[0], h.sunDir[1], h.sunDir[2]);
for (int i = 0; i < 9; ++i) sky.sh[i] = glm::vec3(h.sh[i * 3], h.sh[i * 3 + 1], h.sh[i * 3 + 2]);
return &(skies_[path] = sky);
}
void Renderer::setAtmosphere(const Atmosphere& a) {
atmosphere_ = a;
sky_ = {};
if (const Sky* s = a.sky.empty() ? nullptr : loadSky(a.sky)) sky_ = *s;
}
void Renderer::setProbes(const std::vector<std::pair<glm::vec3, std::string>>& probes) {
if (probes == probeList_) return;
probeList_ = probes;
probes_.clear();
for (const auto& [at, path] : probes)
if (const Sky* s = loadSky(path)) probes_.push_back({at, s->texture, float(s->levels - 1)});
}
void Renderer::setSun(bool enabled, const glm::vec3& dir, const glm::vec3& color, bool realtime) {
sunEnabled_ = enabled;
sunRealtime_ = enabled && realtime;
sunDir_ = glm::normalize(dir);
sunColor_ = color;
}
void Renderer::drawSprite(const glm::vec3& at, float size, const glm::vec4& color, TextureId texture, float rotation, bool additive) {
auto& list = additive ? packet_.glowSprites : packet_.sprites;
if (list.size() >= 8192) return;
uint32_t bits = texture;
float tex;
std::memcpy(&tex, &bits, 4);
list.push_back({glm::vec4(at, size * 0.5f), color, glm::vec4(0, 0, 0, rotation), glm::vec4(0, 0, 0, tex)});
}
void Renderer::drawStreak(const glm::vec3& from, const glm::vec3& to, float width, const glm::vec4& color, TextureId texture, bool additive) {
auto& list = additive ? packet_.glowSprites : packet_.sprites;
if (list.size() >= 8192) return;
uint32_t bits = texture;
float tex;
std::memcpy(&tex, &bits, 4);
// up.xyz: half its length along it (non-zero marks a streak; its side is found per frame).
list.push_back({glm::vec4((from + to) * 0.5f, width * 0.5f), color, glm::vec4(0.0f), glm::vec4((to - from) * 0.5f, tex)});
}
void Renderer::drawModel(ModelId model, const glm::mat4& transform, const glm::vec4& tint, const std::vector<glm::mat4>* skin,
const ModelLighting* lighting, bool castShadow, const glm::vec4* liquidPlane, LiquidStyle liquid) {
if (!model.valid() || packet_.draws.size() >= kMaxDraws) return;
ModelDraw d{model.index, transform, tint};
d.castShadow = castShadow || shadowOnly_;
d.shadowOnly = shadowOnly_;
d.viewModel = viewModel_;
d.texture = textureOverride_;
d.liquid = liquid != LiquidStyle::None;
const Model& mdl = models_[model.index];
d.boundsMin = glm::vec3(1e30f);
d.boundsMax = glm::vec3(-1e30f);
for (int i = 0; i < 8; ++i) {
const glm::vec3 corner(i & 1 ? mdl.maxs.x : mdl.mins.x, i & 2 ? mdl.maxs.y : mdl.mins.y, i & 4 ? mdl.maxs.z : mdl.mins.z);
const glm::vec3 w = glm::vec3(transform * glm::vec4(corner, 1.0f));
d.boundsMin = glm::min(d.boundsMin, w);
d.boundsMax = glm::max(d.boundsMax, w);
}
if (skin) d.boundsMin -= glm::vec3(32.0f), d.boundsMax += glm::vec3(32.0f); // animation leaves the bind pose
d.drawIndex = uint32_t(packet_.draws.size());
gpu::DrawData dd{};
dd.model = transform;
if (lighting) {
for (int i = 0; i < 6; ++i) dd.cube[i] = glm::vec4(lighting->cube[i], 0);
dd.params = glm::vec4(lighting->sunVisibility, 1, 0, 0);
}
if (liquidPlane) {
dd.params.z = 1.0f;
dd.plane = *liquidPlane;
}
dd.params.w = float(int(liquid));
if (!probes_.empty()) { // the nearest reflection probe to the middle of it
const glm::vec3 mid = (d.boundsMin + d.boundsMax) * 0.5f;
const Probe* best = &probes_[0];
for (const Probe& pr : probes_)
if (glm::distance(pr.at, mid) < glm::distance(best->at, mid)) best = &pr;
dd.probe = glm::vec4(float(best->texture), best->top, 0, 0);
}
static const auto t0 = std::chrono::steady_clock::now(); // water's waves move with this
dd.probe.z = hasClock_ ? float(std::fmod(clock_, 3600.0)) : std::fmod(std::chrono::duration<float>(std::chrono::steady_clock::now() - t0).count(), 3600.0f);
packet_.draws.push_back(dd);
if (skin && mdl.skinned && packet_.joints.size() + skin->size() <= kMaxJointsPerFrame) {
d.firstJoint = uint32_t(packet_.joints.size());
for (const glm::mat4& m : *skin) packet_.joints.push_back(transform * m);
}
modelDraws_.push_back(d);
}
void Renderer::clearFrame() {
modelDraws_.clear();
packet_.smoke.clear();
lights_.clear();
packet_.draws.clear();
packet_.joints.clear();
packet_.boxes.clear();
packet_.sprites.clear();
packet_.glowSprites.clear();
}
void Renderer::drawFrame() {
ImGui::Render();
if (r_vsync.consumeModified()) backend_->setVsync(r_vsync.b());
uint32_t width = 0, height = 0;
using clock = std::chrono::steady_clock;
auto ms = [](clock::time_point a, clock::time_point b) { return std::chrono::duration<float, std::milli>(b - a).count(); };
const auto t0 = clock::now();
if (!backend_->beginFrame(width, height)) return clearFrame();
const auto t1 = clock::now();
buildPacket(width, height);
const auto t2 = clock::now();
backend_->render(packet_);
const auto t3 = clock::now();
timings_.wait = ms(t0, t1), timings_.build = ms(t1, t2), timings_.record = ms(t2, t3);
backend_->gpuTimes(timings_.gpu);
screenshotPath_.clear();
clearFrame();
}
void Renderer::takeOcclusion() {
HzbReadback back;
if (!backend_->takeHzb(back) || back.depth.size() != size_t(kHzbWidth) * kHzbHeight) return;
auto it = std::find_if(hzbPending_.begin(), hzbPending_.end(), [&](const auto& f) { return f.first == back.tag; });
if (it == hzbPending_.end()) return;
hzbFrame_ = it->second;
std::erase_if(hzbPending_, [&](const auto& f) { return f.first <= back.tag; });
// The pyramid: each level the farthest of the 2×2 below it (odd edges keep their last row).
hzbLevels_.clear();
hzbLevels_.push_back(std::move(back.depth));
uint32_t w = kHzbWidth, h = kHzbHeight;
while (w > 1 || h > 1) {
const uint32_t nw = std::max(1u, (w + 1) / 2), nh = std::max(1u, (h + 1) / 2);
const std::vector<float>& src = hzbLevels_.back();
std::vector<float> dst(size_t(nw) * nh);
for (uint32_t y = 0; y < nh; ++y)
for (uint32_t x = 0; x < nw; ++x) {
float m = 0.0f;
for (uint32_t dy = 0; dy < 2; ++dy)
for (uint32_t dx = 0; dx < 2; ++dx)
m = std::max(m, src[size_t(std::min(2 * y + dy, h - 1)) * w + std::min(2 * x + dx, w - 1)]);
dst[size_t(y) * nw + x] = m;
}
hzbLevels_.push_back(std::move(dst));
w = nw, h = nh;
}
hzbValid_ = true;
}
// Hidden: the box, seen from the grid's camera, is farther than everything already drawn over
// the whole patch of screen it covers. Anything touching the near plane or the screen's edge
// counts as seen.
bool Renderer::occluded(const glm::mat4& xf, const glm::vec3& lo, const glm::vec3& hi) const {
glm::vec2 a(1e9f), b(-1e9f);
float nearest = 1.0f;
for (int i = 0; i < 8; ++i) {
const glm::vec3 corner(i & 1 ? hi.x : lo.x, i & 2 ? hi.y : lo.y, i & 4 ? hi.z : lo.z);
const glm::vec4 c = hzbFrame_.viewProj * (xf * glm::vec4(corner, 1.0f));
if (c.w < 2.0f) return false; // at or behind the camera
const glm::vec3 ndc = glm::vec3(c) / c.w;
a = glm::min(a, glm::vec2(ndc)), b = glm::max(b, glm::vec2(ndc));
nearest = std::min(nearest, ndc.z);
}
if (a.x < -1.0f || a.y < -1.0f || b.x > 1.0f || b.y > 1.0f) return false;
// Texels covered at level 0, one extra round (the camera moved a little since).
int x0 = int((a.x * 0.5f + 0.5f) * kHzbWidth) - 1, x1 = int((b.x * 0.5f + 0.5f) * kHzbWidth) + 1;
int y0 = int((a.y * 0.5f + 0.5f) * kHzbHeight) - 1, y1 = int((b.y * 0.5f + 0.5f) * kHzbHeight) + 1;
size_t level = 0;
while (level + 1 < hzbLevels_.size() && (x1 - x0 > 3 || y1 - y0 > 3)) x0 >>= 1, x1 >>= 1, y0 >>= 1, y1 >>= 1, ++level;
const int w = std::max(1, int(kHzbWidth >> level)), h = std::max(1, int(kHzbHeight >> level));
const std::vector<float>& d = hzbLevels_[level];
for (int y = std::max(y0, 0); y <= std::min(y1, h - 1); ++y)
for (int x = std::max(x0, 0); x <= std::min(x1, w - 1); ++x)
if (d[size_t(y) * size_t(w) + size_t(x)] >= nearest) return false;
return true;
}
void Renderer::buildPacket(uint32_t width, uint32_t height) {
FramePacket& p = packet_;
p.outWidth = width;
p.outHeight = height;
// Render scale: the 3D scene at a fraction of the window (TAA and the tonemap scale it up).
// r_dynres <fps> moves it to hold that frame rate, a step at a time with a pause between.
float scale = 1.0f;
if (backend_->renderScale()) {
scale = std::clamp(r_renderscale.f(), 0.5f, 1.0f);
if (r_dynres.f() > 0.0f) {
static double last = keel::now(), lastChange = 0;
const double t = keel::now(), frame = t - last;
last = t;
dynFrame_ = dynFrame_ * 0.9 + frame * 0.1; // smoothed
const double target = 1.0 / r_dynres.f();
if (t - lastChange > 0.5) {
if (dynFrame_ > target * 1.08 && dynScale_ > 0.5f) dynScale_ = std::max(0.5f, dynScale_ - 0.05f), lastChange = t;
else if (dynFrame_ < target * 0.8 && dynScale_ < scale) dynScale_ = std::min(scale, dynScale_ + 0.05f), lastChange = t;
}
scale = std::min(scale, dynScale_);
} else {
dynScale_ = scale;
}
}
width = std::max(1u, uint32_t(std::lround(float(width) * scale)));
height = std::max(1u, uint32_t(std::lround(float(height) * scale)));
p.width = width;
p.height = height;
p.viewProj = viewProjection(camera_, float(width) / float(height));
p.shadowSize = r_shadowres.i() >= 8192 ? 8192u : r_shadowres.i() <= 2048 ? 2048u : 4096u;
// TAA: a different sub-pixel offset each frame (Halton 2,3), and how to find last frame's
// pixels. History is dropped on resize, toggling, or a jump (teleport, respawn).
{
const glm::mat4 unjittered = p.viewProj;
const bool taa = r_taa.b();
p.post.taa = taa;
p.post.taaReset = !taa || !taaPrevValid_ || width != taaPrevW_ || height != taaPrevH_ || glm::length(camera_.origin - taaPrevOrigin_) > 128.0f;
p.post.reproject = taaPrevViewProj_ * glm::inverse(unjittered);
if (taa) {
auto halton = [](uint32_t i, uint32_t b) {
float f = 1.0f, r = 0.0f;
for (; i > 0; i /= b) f /= float(b), r += f * float(i % b);
return r;
};
const uint32_t i = (taaFrame_++ % 8) + 1;
const glm::vec2 jitter((halton(i, 2) - 0.5f) * 2.0f / float(width), (halton(i, 3) - 0.5f) * 2.0f / float(height));
glm::mat4 shift(1.0f); // clip space: x' = x + jx·w, y' = y + jy·w
shift[3][0] = jitter.x;
shift[3][1] = jitter.y;
p.viewProj = shift * unjittered;
}
taaPrevViewProj_ = unjittered;
taaPrevValid_ = taa;
taaPrevW_ = width, taaPrevH_ = height;
taaPrevOrigin_ = camera_.origin;
}
p.screenshot = screenshotPath_;
// ── Sun cascades: each fits a slice of the view frustum in a bounding sphere (so its size
// never changes as the camera turns) and snaps to whole texels (so edges don't shimmer).
gpu::FrameData& frame = p.frame;
frame = {};
const glm::vec3 camFwd = forwardOf(camera_);
{
const float maxDist = std::clamp(r_shadowdist.f(), 500.0f, 16000.0f);
// "Practical" splits (Zhang et al.): mostly logarithmic, so the nearest cascade stays small
// (sharp) however far the shadows reach; the far ones take the distance.
float splits[4];
for (int c = 0; c < 4; ++c) {
const float k = float(c + 1) / 4.0f, n = 8.0f;
splits[c] = 0.92f * n * std::pow(maxDist / n, k) + 0.08f * (n + (maxDist - n) * k);
}
splits[3] = maxDist;
const glm::vec3 right = glm::normalize(glm::cross(camFwd, glm::vec3(0, 0, 1) + glm::vec3(0.0001f, 0, 0))), upv = glm::cross(right, camFwd);
const float tanY = std::tan(glm::radians(camera_.fovY) * 0.5f), tanX = tanY * float(width) / float(height);
const glm::vec3 up = std::abs(sunDir_.z) > 0.95f ? glm::vec3(1, 0, 0) : glm::vec3(0, 0, 1);
const float cascadeSize = float(p.shadowSize) / 2.0f;
for (int c = 0; c < 4; ++c) {
const float n = c == 0 ? 2.0f : splits[c - 1], f = splits[c];
glm::vec3 corners[8], center(0.0f);
for (int k = 0; k < 8; ++k) {
const float d = k < 4 ? n : f;
corners[k] = camera_.origin + camFwd * d + right * (k & 1 ? tanX : -tanX) * d + upv * (k & 2 ? tanY : -tanY) * d;
center += corners[k] / 8.0f;
}
float radius = 0.0f;
for (const glm::vec3& q : corners) radius = std::max(radius, glm::length(q - center));
radius = std::ceil(radius / 16.0f) * 16.0f;
const float texel = 2.0f * radius / cascadeSize;
glm::mat4 lightView = glm::lookAt(glm::vec3(0), -sunDir_, up);
glm::vec4 lc = lightView * glm::vec4(center, 1);
lc.x = std::floor(lc.x / texel) * texel;
lc.y = std::floor(lc.y / texel) * texel;
center = glm::vec3(glm::inverse(lightView) * lc);
lightView = glm::lookAt(center + sunDir_ * 8000.0f, center, up);
frame.cascades[c] = glm::ortho(-radius, radius, -radius, radius, 1.0f, 16000.0f) * lightView;
frame.splits[c] = f;
frame.cascadeTexel[c] = texel;
}
}
frame.sunDir = glm::vec4(sunDir_, float(kSunShadowSlot));
frame.sunColor = glm::vec4(sunColor_, sunEnabled_ ? 1.0f : 0.0f);
frame.shadow = glm::vec4(1.0f / float(p.shadowSize), 0.55f, sunRealtime_ ? 1.0f : 0.0f, std::max(r_shadowsoft.f(), 0.0f));
// Strength, reach, air density; w: the sun takes part (a real-time sun; lamps always do).
frame.volumetric = glm::vec4(std::max(r_volumetric.f(), 0.0f), 2500.0f, 0.00015f, sunRealtime_ ? 1.0f : 0.0f);
// ── Dynamic lights: cull, pick shadow casters, cluster ──
const glm::vec3 camUp0 = glm::vec3(glm::rotate(glm::mat4(1), glm::radians(camera_.roll), camFwd) * glm::vec4(0, 0, 1, 0));
const glm::vec3 camRight = glm::normalize(glm::cross(camFwd, camUp0)), camUp = glm::cross(camRight, camFwd);
if (!r_dynamic.b()) lights_.clear();
std::erase_if(lights_, [&](const DynamicLight& l) { return l.radius <= 0 || glm::dot(l.origin - camera_.origin, camFwd) < -l.radius; });
auto nearness = [&](const DynamicLight& l) { return glm::length(l.origin - camera_.origin) - l.radius; };
std::stable_sort(lights_.begin(), lights_.end(), [&](const DynamicLight& a, const DynamicLight& b) { return nearness(a) < nearness(b); });
lights_.resize(std::min<size_t>(lights_.size(), std::min<size_t>(std::max(r_maxlights.i(), 0), kMaxLights)));
const uint32_t tile = std::clamp(uint32_t(r_lightshadowres.i()), 128u, 2048u);
const uint32_t tilesPerRow = kAtlasSize / tile, tileBudget = std::min(tilesPerRow * tilesPerRow, kMaxShadowViews);
p.lights.assign(lights_.size(), {});
p.views.clear();
struct ViewSphere { glm::vec3 center; float radius; };
std::vector<ViewSphere> viewSpheres;
int shadowLights = 0;
for (size_t i = 0; i < lights_.size(); ++i) {
const DynamicLight& l = lights_[i];
gpu::Light& g = p.lights[i];
g.posRadius = glm::vec4(l.origin, l.radius);
const glm::vec3 dir = glm::length(l.direction) > 0 ? glm::normalize(l.direction) : glm::vec3(0, 0, -1);
const float outer = std::clamp(l.outerCone, 1.0f, 89.0f), inner = std::clamp(l.innerCone, 0.0f, outer - 0.5f);
g.spot = glm::vec4(dir, l.spot() ? std::cos(glm::radians(outer)) : -2.0f);
g.extra = glm::vec4(std::cos(glm::radians(inner)), std::max(l.size, 0.0f), l.reflector && l.spot() ? 1.0f : 0.0f, 0);
g.color = glm::vec4(l.color, -1.0f);
const uint32_t need = l.spot() ? 1 : 6;
if (!l.shadows || shadowLights >= r_lightshadows.i() || p.views.size() + need > tileBudget) continue;
++shadowLights;
g.color.w = float(p.views.size());
auto addView = [&](const glm::vec3& fwd, float fovDeg) {
const glm::vec3 up = std::abs(fwd.z) > 0.99f ? glm::vec3(1, 0, 0) : glm::vec3(0, 0, 1);
const uint32_t t = uint32_t(p.views.size());
const glm::vec4 rect(float(t % tilesPerRow * tile) / kAtlasSize, float(t / tilesPerRow * tile) / kAtlasSize, float(tile) / kAtlasSize,
float(tile) / kAtlasSize);
p.views.push_back({glm::perspective(glm::radians(fovDeg), 1.0f, 1.0f, l.radius) * glm::lookAt(l.origin, l.origin + fwd, up), rect});
viewSpheres.push_back({l.origin, l.radius});
};
if (l.spot()) addView(dir, std::min(2.0f * outer + 4.0f, 170.0f));
else
for (const glm::vec3& f : {glm::vec3(1, 0, 0), glm::vec3(-1, 0, 0), glm::vec3(0, 1, 0), glm::vec3(0, -1, 0), glm::vec3(0, 0, 1), glm::vec3(0, 0, -1)})
addView(f, 95.0f); // a little over 90° so PCF near face edges stays inside the frustum
}
ClusterView cv{camera_.origin, camFwd, camRight, camUp, std::tan(glm::radians(camera_.fovY) * 0.5f), float(width) / float(height)};
buildClusters(cv, lights_, kMaxLightIndices, clusters_);
p.clusters = &clusters_;
lightStats_ = {uint32_t(lights_.size()), uint32_t(shadowLights), uint32_t(p.views.size()), uint32_t(clusters_.indices.size())};
frame.camPos = glm::vec4(camera_.origin, float(lights_.size()));
frame.weather = weather_;
p.skyCloud = cloud_;
weather_ = glm::vec4(0.0f), cloud_ = 0.0f; // (set again next frame by whoever has weather)
frame.camFwd = glm::vec4(camFwd, r_lightdebug.f());
frame.screen = glm::vec4(float(width), float(height), float(kAtlasSlot), float(kAtlasSize));
if (r_drawlights.b())
for (const DynamicLight& l : lights_) drawBox(l.origin - glm::vec3(4), l.origin + glm::vec3(4), glm::vec4(glm::min(l.color, glm::vec3(1)), 1));
// ── Draw lists ──
auto translucent = [](const ModelDraw& d, const Submesh& s) { return d.tint.a * s.color.a < 0.99f; };
auto mesh = [&](const ModelDraw& d, const Submesh& s, bool shadow) {
return MeshDraw{d.model, s.firstIndex, s.indexCount, d.drawIndex, d.firstJoint, (d.texture ? d.texture : s.texture) | (s.lightmap << 16),
packColor(d.tint * s.color), shadow ? 0u : s.material | (s.skyVisibility << 16), d.liquid};
};
p.opaque.clear();
p.translucent.clear();
for (auto& list : sunCasters_) list.clear();
drawStats_ = {};
const bool cull = r_cull.b();
// Occlusion: ask for this frame's grid, take back an older one, and use it only while the
// camera is still close to where it was drawn from (else things could pop in late).
takeOcclusion();
p.hzbTag = 0;
if (r_occlusion.b()) {
p.hzbTag = ++hzbTag_;
hzbPending_.push_back({p.hzbTag, {p.viewProj, camera_.origin, camFwd}});
if (hzbPending_.size() > 8) hzbPending_.erase(hzbPending_.begin());
}
// On the GPU when it can (a frame fresher, no read-back); else here, from an older grid.
p.gpuCull = cull && r_occlusion.b() && r_gpuculling.b() && backend_->gpuCulling();
p.cullOcclusion = p.gpuCull && lastCull_.valid && glm::length(camera_.origin - lastCull_.origin) < 48.0f && glm::dot(camFwd, lastCull_.forward) > 0.985f;
p.cullViewProj = lastCull_.viewProj;
lastCull_ = {true, p.viewProj, camera_.origin, camFwd};
const uint32_t gpuHidden = p.gpuCull ? backend_->gpuCulled() : 0;
// (The CPU's test still covers translucent things when the GPU does the opaque ones.)
const bool cpuOcclusion = cull && r_occlusion.b() && hzbValid_ && glm::length(camera_.origin - hzbFrame_.origin) < 48.0f &&
glm::dot(camFwd, hzbFrame_.forward) > 0.985f;
const Frustum view(p.viewProj, false);
const Frustum cascade[4] = {Frustum(frame.cascades[0], true), Frustum(frame.cascades[1], true), Frustum(frame.cascades[2], true),
Frustum(frame.cascades[3], true)};
atlasCasters_.resize(p.views.size());
for (auto& list : atlasCasters_) list.clear();
std::vector<std::pair<float, MeshDraw>> sorted;
drawStats_.occluded += gpuHidden; // the GPU's (a frame or two ago)
// Real-time GI: everything solid and still-shaped, in view or not, for the rays to hit.
const bool rays = r_gi.b() && backend_->rayTracing();
p.rayInstances.clear();
p.frame.gi = rays ? glm::vec4(1.0f, 64.0f, r_gi.i() == 2 ? 1.0f : 0.0f, 0.0f) : glm::vec4(0.0f); // z: r_gi 2 shows only the bounced light
// Mirrors: the nearest one in view that faces you gets the room drawn again, reflected in it.
const ModelDraw* mirrorDraw = nullptr;
const Submesh* mirrorSub = nullptr;
glm::vec4 mirrorPlane(0.0f);
if (r_mirrors.b() && backend_->mirrors()) {
float best = 1e30f;
for (const ModelDraw& d : modelDraws_) {
if (d.shadowOnly) continue;
for (const Submesh& s : models_[d.model].submeshes) {
if (!s.mirror || !view.sees(d.transform, s.lo, s.hi)) continue;
const glm::vec3 n = glm::normalize(glm::transpose(glm::inverse(glm::mat3(d.transform))) * glm::vec3(s.plane));
const glm::vec3 on = glm::vec3(d.transform * glm::vec4(glm::vec3(s.plane) * s.plane.w, 1.0f));
const float dist = glm::dot(n, on);
if (glm::dot(n, camera_.origin) - dist < 0.5f) continue; // behind it
const glm::vec3 centre = glm::vec3(d.transform * glm::vec4((s.lo + s.hi) * 0.5f, 1.0f));
if (const float far = glm::length(centre - camera_.origin); far < best) best = far, mirrorDraw = &d, mirrorSub = &s, mirrorPlane = glm::vec4(n, dist);
}
}
}
p.mirror = mirrorDraw != nullptr;
p.mirrorOpaque.clear();
p.mirrorTranslucent.clear();
p.frame.mirror = p.frame.clip = glm::vec4(0.0f);
std::optional<Frustum> mirrorView;
if (p.mirror) {
// Reflection in the plane n·x = d: x − 2(n·x − d)n.
const glm::vec3 n(mirrorPlane);
glm::mat4 reflect(1.0f);
for (int c = 0; c < 3; ++c)
for (int r = 0; r < 3; ++r) reflect[c][r] -= 2.0f * n[c] * n[r];
reflect[3] = glm::vec4(2.0f * mirrorPlane.w * n, 1.0f);
p.mirrorViewProj = p.viewProj * reflect;
p.mirrorInvViewProj = glm::inverse(p.mirrorViewProj);
p.frame.mirror = glm::vec4(float(kMirrorSlot), 0, 0, 0);
mirrorView.emplace(p.mirrorViewProj, false);
// Only where the mirror is on screen gets drawn (its box's corners; all of it when the
// box reaches behind the eye).
glm::vec2 lo(1.0f), hi(-1.0f);
bool behind = false;
for (int k = 0; k < 8; ++k) {
const glm::vec3 c(k & 1 ? mirrorSub->hi.x : mirrorSub->lo.x, k & 2 ? mirrorSub->hi.y : mirrorSub->lo.y, k & 4 ? mirrorSub->hi.z : mirrorSub->lo.z);
const glm::vec4 q = p.viewProj * mirrorDraw->transform * glm::vec4(c, 1.0f);
if (q.w < 0.1f) { behind = true; break; }
lo = glm::min(lo, glm::vec2(q) / q.w), hi = glm::max(hi, glm::vec2(q) / q.w);
}
if (behind) lo = glm::vec2(-1.0f), hi = glm::vec2(1.0f);
lo = glm::clamp(lo, -1.0f, 1.0f), hi = glm::clamp(hi, -1.0f, 1.0f);
// NDC to pixels (y down, as rows run), a pixel of margin.
const glm::vec2 size(float(p.width), float(p.height));
const glm::ivec2 a = glm::max(glm::ivec2(glm::floor((lo * 0.5f + 0.5f) * size)) - 2, glm::ivec2(0));
const glm::ivec2 b = glm::min(glm::ivec2(glm::ceil((hi * 0.5f + 0.5f) * size)) + 2, glm::ivec2(size));
p.mirrorRect = glm::ivec4(a, glm::max(b - a, glm::ivec2(1)));
mirrorReflect_ = reflect;
mirrorClip_ = glm::vec4(n, mirrorPlane.w - 0.25f); // (the wall it hangs on is cut too)
}
for (const ModelDraw& d : modelDraws_) {
const Model& m = models_[d.model];
for (const Submesh& s : m.submeshes) {
// In the mirror: everything in front of it (your own body too), but not your hands.
if (mirrorView && !d.viewModel && !s.mirror && mirrorView->sees(d.transform, s.lo, s.hi))
(translucent(d, s) ? p.mirrorTranslucent : p.mirrorOpaque).push_back(mesh(d, s, false));
if (rays && !d.liquid && d.firstJoint == UINT32_MAX && !translucent(d, s))
p.rayInstances.push_back({d.transform, d.model, s.firstIndex, s.texture | (s.lightmap << 16), packColor(d.tint * s.color)});
bool seen = !cull || view.sees(d.transform, s.lo, s.hi);
const bool occlusion = cpuOcclusion && (!p.gpuCull || translucent(d, s));
if (seen && occlusion && !d.shadowOnly && occluded(d.transform, s.lo, s.hi)) {
seen = false;
++drawStats_.occluded;
}
if (translucent(d, s)) {
if (!seen) continue;
++drawStats_.translucent;
if (!d.shadowOnly) sorted.emplace_back(glm::length((d.boundsMin + d.boundsMax) * 0.5f - camera_.origin) + (d.liquid ? 2.0f : 0.0f),
mesh(d, s, false));
continue;
}
if (!d.shadowOnly && seen) {
p.opaque.push_back(mesh(d, s, false));
if (&d == mirrorDraw && &s == mirrorSub) p.opaque.back().textures = kMirrorSlot | (s.lightmap << 16); // shows its picture
if (p.gpuCull) { // its world box, for the GPU's test
glm::vec3 lo(1e30f), hi(-1e30f);
for (int k = 0; k < 8; ++k) {
const glm::vec3 c(k & 1 ? s.hi.x : s.lo.x, k & 2 ? s.hi.y : s.lo.y, k & 4 ? s.hi.z : s.lo.z);
const glm::vec3 w = glm::vec3(d.transform * glm::vec4(c, 1.0f));
lo = glm::min(lo, w), hi = glm::max(hi, w);
}
p.opaque.back().lo = lo, p.opaque.back().hi = hi;
}
++drawStats_.drawn;
drawStats_.triangles += s.indexCount / 3;
} else if (!d.shadowOnly) {
++drawStats_.culled;
}
if (d.castShadow && sunEnabled_)
for (int c = 0; c < 4; ++c)
if (!cull || cascade[c].sees(d.transform, s.lo, s.hi)) sunCasters_[c].push_back(mesh(d, s, true)), ++drawStats_.sunShadow;
// Light shadows: only things inside the light's sphere (and everything casts, the world too,
// but not first person's arms and tool: they're drawn where the camera is, not in the world).
for (size_t v = 0; v < viewSpheres.size() && !d.viewModel; ++v) {
const glm::vec3 q = glm::clamp(viewSpheres[v].center, d.boundsMin, d.boundsMax);
if (glm::dot(q - viewSpheres[v].center, q - viewSpheres[v].center) <= viewSpheres[v].radius * viewSpheres[v].radius)
atlasCasters_[v].push_back(mesh(d, s, true)), ++drawStats_.lightShadow;
}
}
}
// Shadow passes only write depth (no materials, no alpha test), so neighbouring submeshes of
// one model drawn the same way merge into one draw when their indices follow on.
auto merge = [](std::vector<MeshDraw>& list) {
size_t out = 0;
for (size_t i = 0; i < list.size(); ++i) {
if (out > 0) {
MeshDraw& last = list[out - 1];
const MeshDraw& d = list[i];
if (d.model == last.model && d.draw == last.draw && d.firstJoint == last.firstJoint && d.firstIndex == last.firstIndex + last.indexCount) {
last.indexCount += d.indexCount;
continue;
}
}
list[out++] = list[i];
}
list.resize(out);
};
drawStats_.sunShadow = drawStats_.lightShadow = 0;
for (auto& list : sunCasters_) merge(list), drawStats_.sunShadow += uint32_t(list.size());
for (auto& list : atlasCasters_) merge(list), drawStats_.lightShadow += uint32_t(list.size());
std::sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.first > b.first; }); // back to front
for (auto& [dist, m] : sorted) p.translucent.push_back(m);
p.sunPasses.clear();
if (sunEnabled_) {
const int half = int(p.shadowSize / 2);
for (int c = 0; c < 4; ++c) p.sunPasses.push_back({frame.cascades[c], glm::ivec4((c & 1) * half, (c >> 1) * half, half, half), &sunCasters_[c]});
}
p.atlasPasses.clear();
for (size_t v = 0; v < p.views.size(); ++v) {
const glm::vec4 r = p.views[v].rect * float(kAtlasSize);
p.atlasPasses.push_back({p.views[v].viewProj, glm::ivec4(r), &atlasCasters_[v]});
}
// ── Sky and air ──
// The sky only needs directions: build its matrix with the camera at the origin, so it
// stays exact however far from the map the camera is (a full one loses all precision there).
{
Camera at0 = camera_;
at0.origin = glm::vec3(0.0f);
p.invViewProj = glm::inverse(viewProjection(at0, float(width) / float(height)));
}
p.skyPhotoSunZ = 2.0f;
if (sky_.texture) {
const float intensity = atmosphere_.skyIntensity * std::max(r_skyintensity.f(), 0.0f);
// Turn the photo so its sun stands where the map's does.
float yaw = 0.0f;
if (sunEnabled_ && glm::length(glm::vec2(sunDir_)) > 1e-3f && glm::length(glm::vec2(sky_.sunDir)) > 1e-3f)
yaw = std::atan2(sunDir_.y, sunDir_.x) - std::atan2(sky_.sunDir.y, sky_.sunDir.x);
frame.sky = glm::vec4(float(sky_.texture), intensity, float(sky_.levels - 1), yaw);
p.skyPhotoSunZ = glm::length(sky_.sunDir) > 0.5f ? sky_.sunDir.z : 2.0f;
// The SH rotate with the photo: bands 0/2 (z) stay, x/y pairs turn by yaw (and 2·yaw).
const float c = std::cos(yaw), sn = std::sin(yaw), c2 = std::cos(2 * yaw), s2 = std::sin(2 * yaw);
glm::vec3 sh[9];
for (int i = 0; i < 9; ++i) sh[i] = sky_.sh[i] * intensity;
// Band 1: (y, z, x) — rotating the world by yaw about z: x' = c·x − s·y, y' = s·x + c·y.
const glm::vec3 y1 = sh[1], x1 = sh[3];
sh[1] = c * y1 + sn * x1;
sh[3] = c * x1 - sn * y1;
// Band 2: (xy, yz, z², xz, x²−y²): (xz, yz) turn by yaw, (x²−y², xy) by 2·yaw.
const glm::vec3 yz = sh[5], xz = sh[7], xy = sh[4], xx = sh[8];
sh[5] = c * yz + sn * xz;
sh[7] = c * xz - sn * yz;
sh[4] = c2 * xy + s2 * xx;
sh[8] = c2 * xx - s2 * xy;
for (int i = 0; i < 9; ++i) frame.skySH[i] = glm::vec4(sh[i], 0.0f);
if (r_fog.b()) frame.fog = glm::vec4(atmosphere_.fogDensity * std::max(r_fogscale.f(), 0.0f), atmosphere_.fogFalloff, atmosphere_.fogMax, atmosphere_.fogBaseZ);
}
// The fog grid, when there's fog or light in the air to fill it with.
const bool airLit = frame.fog.x > 0.0f || frame.volumetric.x > 0.0f || !p.smoke.empty();
frame.froxel = airLit && r_volumetricfog.b() && backend_->froxelFog() ? glm::vec4(1.0f, 16.0f, 16384.0f, 0.0f) : glm::vec4(0.0f);
// Sky is given in display colours; undo exposure so it shows as typed.
glm::vec3 sky(0.5f);
std::sscanf(r_skycolor.str().c_str(), "%f %f %f", &sky.r, &sky.g, &sky.b);
p.exposure = std::max(r_exposure.f(), 0.01f);
// Night stays night: the eye adapts, but not all the way back to day.
p.exposure *= glm::mix(1.0f, 0.3f, std::clamp(p.frame.weather.z, 0.0f, 1.0f));
const float tanY = std::tan(glm::radians(camera_.fovY) * 0.5f);
p.post.aoStrength = std::clamp(r_ssao.f(), 0.0f, 1.0f); // (the TAA fields were set with the camera above)
p.post.aoRadius = std::max(r_ssao_radius.f(), 1.0f);
p.post.bloomStrength = std::clamp(r_bloom.f(), 0.0f, 1.0f);
p.post.saturation = r_saturation.f();
p.post.vignette = r_vignette.f();
p.post.sharpen = std::max(r_sharpen.f(), 0.0f);
p.post.ssr = std::max(r_ssr.f(), 0.0f);
p.prepass = r_prepass.b();
{ // Particles face the camera; blended ones are drawn back to front.
const glm::vec3 f = forwardOf(camera_), right = glm::normalize(glm::cross(f, glm::vec3(0, 0, 1) + glm::vec3(0.0001f, 0, 0))),
up = glm::cross(right, f);
for (auto* list : {&p.sprites, &p.glowSprites})
for (gpu::Sprite& s : *list) {
if (const glm::vec3 half(s.up); glm::dot(half, half) > 0.0f) { // a streak: its side faces the eye
glm::vec3 side = glm::cross(half, camera_.origin - glm::vec3(s.posSize));
side = glm::length(side) > 1e-6f ? glm::normalize(side) : right;
s.right = glm::vec4(side * s.posSize.w, 0.0f);
s.posSize.w = 1.0f;
continue;
}
s.right = glm::vec4(right, s.right.w), s.up = glm::vec4(up, s.up.w);
}
std::sort(p.sprites.begin(), p.sprites.end(), [&](const gpu::Sprite& a, const gpu::Sprite& b) {
return glm::dot(glm::vec3(a.posSize) - camera_.origin, f) > glm::dot(glm::vec3(b.posSize) - camera_.origin, f);
});
}
{
static double lastTime = keel::now();
const double t = keel::now();
p.post.adaptDt = float(std::clamp(t - lastTime, 0.0, 0.25));
lastTime = t;
const bool on = r_autoexposure.b() && p.post.bloomStrength > 0.0f; // (it reads the bloom chain)
p.post.adaptReset = on && !adaptOn_;
p.post.adapt = adaptOn_ = on;
}
p.post.shafts = glm::vec4(0.0f);
if (sunEnabled_ && sunDir_.z > -0.05f && r_shafts.f() > 0.0f) { // the sun on screen, or just off it
const glm::vec4 c = p.viewProj * glm::vec4(camera_.origin + sunDir_ * 10000.0f, 1.0f);
if (c.w > 0.0f) {
const glm::vec2 uv = glm::vec2(c) / c.w * 0.5f + 0.5f;
const float onScreen = 1.0f - glm::smoothstep(0.6f, 1.4f, glm::length((uv - 0.5f) * 2.0f) - 0.4f);
p.post.shafts = glm::vec4(uv, r_shafts.f() * onScreen * glm::smoothstep(-0.05f, 0.15f, sunDir_.z) * (1.0f - p.skyCloud), 0.0f); // (none through cloud)
p.post.sunColor = glm::vec4(sunColor_, 0.0f);
}
}
p.post.proj = glm::vec4(tanY * float(width) / float(height), tanY, 2.0f, 16384.0f);
p.clearColor = glm::vec3(srgbToLinear(sky.r), srgbToLinear(sky.g), srgbToLinear(sky.b)) / p.exposure;
// The mirror's pass: this frame's data, but seen from the reflected eye, cut at the mirror,
// lit by every light (the clusters are this view's), and no fog grid (that's this view's too).
if (p.mirror) {
p.mirrorFrame = p.frame;
const float lights = p.frame.camPos.w, debug = p.frame.camFwd.w;
p.mirrorFrame.camPos = glm::vec4(glm::vec3(mirrorReflect_ * glm::vec4(glm::vec3(p.frame.camPos), 1.0f)), lights);
p.mirrorFrame.camFwd = glm::vec4(glm::normalize(glm::vec3(mirrorReflect_ * glm::vec4(glm::vec3(p.frame.camFwd), 0.0f))), debug);
p.mirrorFrame.mirror = glm::vec4(0.0f, 1.0f, 0.0f, 0.0f);
p.mirrorFrame.froxel = glm::vec4(0.0f);
p.mirrorFrame.clip = mirrorClip_;
}
}
} // namespace keel

260
engine/render/renderer.h Normal file
View file

@ -0,0 +1,260 @@
#pragma once
#include "render/backend.h"
#include "render/clusters.h"
#include <glm/glm.hpp>
#include <memory>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace keel {
class Window;
struct KmdlModel;
struct Camera {
glm::vec3 origin{0.0f};
float yaw = 0.0f; // degrees, 0 = +X, counter-clockwise
float pitch = 0.0f; // degrees, positive looks down (Quake convention)
float roll = 0.0f; // degrees, positive tilts right
float fovY = 74.0f; // degrees
glm::vec2 shift{0.0f}; // where the picture's middle sits on the screen (-1..1 across, down): a view beside panels
};
// Baked light for a moving model (from the map's probes): ambient cube + sun visibility.
struct ModelLighting {
glm::vec3 cube[6]; // light arriving from +X -X +Y -Y +Z -Z
float sunVisibility = 1.0f;
};
// Liquid shading: a thin film on a surface (puddles, splats; thicker towards the middle of the
// model's UV v = 0 end, with an uneven edge), or a glossy body (drops, what's in a bottle).
enum class LiquidStyle { None = 0, Film = 1, Body = 2, Water = 3, Decal = 4 }; // Water: open water's surface (waves, deep colour)
struct ModelId {
uint32_t index = UINT32_MAX;
bool valid() const { return index != UINT32_MAX; }
};
// World space is Quake-like: Z up, units roughly inches. The scene renders in HDR and is
// tonemapped to the screen; UI draws on top. The graphics API sits behind a RenderBackend
// (Vulkan or OpenGL 4.6), picked at startup.
class Renderer {
public:
bool init(const Window& window, bool validation, RenderApi api);
void shutdown();
const char* apiName() const { return backend_ ? backend_->name() : "none"; }
GpuInfo gpuInfo() const { return backend_ ? backend_->gpuInfo() : GpuInfo{}; }
// Starts a Dear ImGui frame; UI code runs between this and drawFrame().
void beginUi();
// Loaded through the virtual filesystem and cached by path.
TextureId loadTexture(std::string_view path); // .yapf
// A picture made in memory (8-bit RGBA, sRGB), cached under `name` like a loaded file, so
// models whose material names it use it; made again in place if the name's taken. (Fry3DX:
// reference images.)
TextureId createTexture(std::string_view name, uint32_t w, uint32_t h, const uint8_t* rgba);
// Hot reload: textures and models whose files changed since they were loaded are loaded
// again in place (same ids). Returns their paths.
std::vector<std::string> reloadChanged();
ModelId loadModel(std::string_view path); // .kmdl
// A model built in memory (e.g. map geometry), cached under `name`.
ModelId createModel(std::string_view name, const KmdlModel& model);
// Replaces (or creates) the model cached under `name`; the old GPU buffers are freed
// once no frame in flight can still use them. For meshes that change (breaking glass).
ModelId updateModel(std::string_view name, const KmdlModel& model);
glm::vec3 modelMins(ModelId m) const { return m.valid() ? models_[m.index].mins : glm::vec3(0); }
glm::vec3 modelMaxs(ModelId m) const { return m.valid() ? models_[m.index].maxs : glm::vec3(0); }
// A loaded texture for ImGui::Image (ImTextureID); 0 if it isn't one.
uint64_t uiTexture(TextureId texture) { return backend_ ? backend_->uiTexture(texture) : 0; }
// Pixel size of a loaded texture.
glm::ivec2 textureSize(TextureId texture) const {
const auto it = textureSizes_.find(texture);
return it == textureSizes_.end() ? glm::ivec2(0) : it->second;
}
void setCamera(const Camera& cam) { camera_ = cam; }
const Camera& camera() const { return camera_; }
// Volumetric smoke this frame (SmokeField::bake over kSmokeDims cells from `corner`); `clock`
// turns its billows. Nothing set: none.
void setSmoke(const glm::vec3& corner, std::vector<uint16_t> cells, float clock) {
packet_.smoke = std::move(cells);
packet_.smokeOrigin = glm::vec4(corner, clock);
}
// The camera's view-projection (clip space: Vulkan's, Y down), for placing 2D overlays on the world.
glm::mat4 cameraViewProjection(float aspect) const;
void drawBox(const glm::vec3& mins, const glm::vec3& maxs, const glm::vec4& color, TextureId texture = kWhiteTexture);
// A particle: a camera-facing square `size` across, unlit (color can be HDR-bright).
// additive: light added on (fire, sparks, flashes); else blended (smoke, dust).
void drawSprite(const glm::vec3& at, float size, const glm::vec4& color, TextureId texture, float rotation = 0.0f, bool additive = true);
// A sprite stretched from `from` to `to`, `width` across, turned to face the eye as much as
// it can (rain).
void drawStreak(const glm::vec3& from, const glm::vec3& to, float width, const glm::vec4& color, TextureId texture, bool additive = false);
// skin: one matrix per joint (anim::skinMatrices) for skinned models; ignored otherwise.
// lighting: probe light for moving things (nullptr = lightmap or simple sun shading).
void drawModel(ModelId model, const glm::mat4& transform, const glm::vec4& tint = glm::vec4(1.0f),
const std::vector<glm::mat4>* skin = nullptr, const ModelLighting* lighting = nullptr, bool castShadow = true,
const glm::vec4* liquidPlane = nullptr, LiquidStyle liquid = LiquidStyle::None);
// liquidPlane (world space: xyz normal, w distance): draws a closed mesh as liquid filling it
// up to the plane — above it is cut away, and its back faces show as the flat surface.
// Alpha below 1 (tint × material) makes a submesh translucent: sorted, blended, after opaque.
// The map's sun; dir points towards it. realtime: the lightmap has no direct sun, so it is
// drawn here for every surface with cascaded shadows (the world casts too). Otherwise
// it only lights probe-lit models and their shadows darken the baked light.
void setSun(bool enabled, const glm::vec3& dir, const glm::vec3& color, bool realtime = false);
// The sky (a .ksky; "" = none, the flat r_skycolor) and the air: its brightness and height
// fog (density per unit at baseZ, thinning out by `falloff` per unit up).
struct Atmosphere {
std::string sky;
float skyIntensity = 0.35f;
float fogDensity = 0.00005f, fogFalloff = 0.0005f, fogMax = 0.9f, fogBaseZ = 0.0f;
};
void setAtmosphere(const Atmosphere& a);
// This frame's weather (FrameData::weather): x wet, y snow cover, z night, w lightning flash.
// Per frame, like draws: not set, it's clear.
void setWeather(const glm::vec4& w, float cloud = 0.0f) { weather_ = w, cloud_ = cloud; }
// The game's clock (seconds): water's swells move with it, so floating things match them.
void setClock(double t) { clock_ = t, hasClock_ = true; }
// Reflection probes (env_cubemap, baked by assetc as .ksky): glossy surfaces reflect the
// nearest one. Call with the map's list every frame (it's only reloaded when it changes).
void setProbes(const std::vector<std::pair<glm::vec3, std::string>>& probes);
// Draws made while this is on only cast shadows (sun, dynamic lights) and aren't seen:
// the first-person player's own body.
void setShadowOnly(bool on) { shadowOnly_ = on; }
// What's drawn next is the first-person view model (mirrors show your body, not these).
void setViewModel(bool on) { viewModel_ = on; }
// Models drawn next wear this picture instead of their own (0: their own): one
// model, many pictures.
void setTextureOverride(TextureId texture) { textureOverride_ = texture; }
// A real-time light for this frame only (call every frame, like drawModel).
void addLight(const DynamicLight& light) { lights_.push_back(light); }
// Stats from the last frame, for r_lightinfo / tools.
struct LightStats { uint32_t lights = 0, shadowed = 0, shadowViews = 0, indices = 0; };
const LightStats& lightStats() const { return lightStats_; }
// What the last frame drew (r_speeds): submesh draws in the view, culled away, into the sun's
// cascades and lights' shadows.
// Where the last frame's time went (r_profile): CPU (ms) and each GPU pass.
struct Timings {
float wait = 0, build = 0, record = 0; // waiting for a free frame (the GPU), building the draw list, recording + submitting
std::vector<std::pair<const char*, float>> gpu;
};
const Timings& timings() const { return timings_; }
struct DrawStats { uint32_t drawn = 0, culled = 0, occluded = 0, translucent = 0, sunShadow = 0, lightShadow = 0, triangles = 0; };
const DrawStats& drawStats() const { return drawStats_; }
void drawFrame();
// Saves the next presented frame (UI included) as a PNG.
void requestScreenshot(std::string path) { screenshotPath_ = std::move(path); }
void onResize() {
if (backend_) backend_->onResize();
}
private:
struct Submesh {
uint32_t firstIndex, indexCount;
TextureId texture, lightmap;
glm::vec4 color;
TextureId material = 0; // "<texture>_n": relief (tangent-space normal) + gloss in alpha; 0 = none
TextureId skyVisibility = 0; // "<lightmap>_sky": how much sky each luxel sees; 0 = none
glm::vec3 lo{0.0f}, hi{0.0f}; // model-space bounds, for culling
bool mirror = false; // its texture's a mirror ("mirror" in its path): shows what's in front
glm::vec4 plane{0.0f}; // a mirror's plane (model space): normal out of it, distance
};
struct Model {
std::vector<Submesh> submeshes;
glm::vec3 mins{0.0f}, maxs{0.0f};
bool skinned = false;
};
struct ModelDraw {
uint32_t model;
glm::mat4 transform;
glm::vec4 tint;
uint32_t firstJoint = UINT32_MAX; // into the packet's joints, skinned draws only
uint32_t drawIndex = 0; // into the packet's draws
bool castShadow = true; // sun shadow map (the world is baked, so it doesn't)
bool shadowOnly = false; // not drawn in the view
bool liquid = false; // sorted just behind what holds it (liquid in a glass bottle)
bool viewModel = false; // your hands and tool in first person (not seen in mirrors)
TextureId texture = 0; // worn instead of the model's own pictures (0: its own)
glm::vec3 boundsMin, boundsMax; // world space, for culling shadow casters per light
};
Model describeModel(const KmdlModel& kmdl);
TextureId newTexture(uint32_t w, uint32_t h, bool srgb, const std::vector<std::vector<uint8_t>>& mips, TexFormat format = TexFormat::RGBA8);
void buildPacket(uint32_t width, uint32_t height);
void clearFrame();
std::unique_ptr<RenderBackend> backend_;
TextureId nextTexture_ = kFirstTextureSlot;
std::unordered_map<std::string, TextureId> textureCache_;
std::unordered_map<TextureId, glm::ivec2> textureSizes_;
std::vector<Model> models_;
std::unordered_map<std::string, ModelId> modelCache_;
std::unordered_map<std::string, int64_t> fileTimes_; // loaded files' times (hot reload)
bool decodeTexture(const std::string& path, uint32_t& w, uint32_t& h, bool& srgb, std::vector<std::vector<uint8_t>>& mips, TexFormat& format);
DrawStats drawStats_;
// TAA: last frame's camera.
glm::mat4 taaPrevViewProj_{1.0f};
glm::vec3 taaPrevOrigin_{0.0f};
uint32_t taaPrevW_ = 0, taaPrevH_ = 0, taaFrame_ = 0;
bool taaPrevValid_ = false;
// Occlusion (hierarchical Z): the newest grid back from the GPU as a max-depth pyramid,
// with the camera it was drawn from. Frames are tagged; their cameras are remembered until
// their grid returns.
struct HzbFrame { glm::mat4 viewProj; glm::vec3 origin, forward; };
std::vector<std::vector<float>> hzbLevels_; // level 0 = kHzbWidth × kHzbHeight
HzbFrame hzbFrame_{};
struct { bool valid = false; glm::mat4 viewProj{1.0f}; glm::vec3 origin{0.0f}, forward{1, 0, 0}; } lastCull_; // GPU culling: last frame's camera
bool hzbValid_ = false;
uint64_t hzbTag_ = 0;
std::vector<std::pair<uint64_t, HzbFrame>> hzbPending_;
void takeOcclusion();
bool occluded(const glm::mat4& transform, const glm::vec3& lo, const glm::vec3& hi) const;
Camera camera_;
bool sunEnabled_ = false, sunRealtime_ = false;
struct Sky {
TextureId texture = 0;
uint32_t levels = 0;
glm::vec3 sunDir{0, 0, 1};
glm::vec3 sh[9]{};
};
std::unordered_map<std::string, Sky> skies_;
const Sky* loadSky(const std::string& path);
struct Probe {
glm::vec3 at;
TextureId texture;
float top;
};
std::vector<Probe> probes_;
bool adaptOn_ = false;
float dynScale_ = 1.0f; // dynamic resolution's current scale
double dynFrame_ = 0.016; // smoothed frame time
std::vector<std::pair<glm::vec3, std::string>> probeList_;
Sky sky_;
Atmosphere atmosphere_;
glm::vec3 sunDir_{0, 0, 1}, sunColor_{1.0f};
std::vector<ModelDraw> modelDraws_;
std::vector<DynamicLight> lights_;
ClusterGrid clusters_;
LightStats lightStats_;
Timings timings_;
std::string screenshotPath_;
bool shadowOnly_ = false, viewModel_ = false;
TextureId textureOverride_ = 0;
glm::mat4 mirrorReflect_{1.0f}; // this frame's mirror: its reflection, and its pass's cut plane
glm::vec4 mirrorClip_{0.0f};
glm::vec4 weather_{0.0f};
float cloud_ = 0.0f;
double clock_ = 0.0;
bool hasClock_ = false;
FramePacket packet_; // reused every frame
std::vector<MeshDraw> sunCasters_[4]; // per cascade, culled to it
std::vector<std::vector<MeshDraw>> atlasCasters_;
};
} // namespace keel

293
engine/render/resources.cpp Normal file
View file

@ -0,0 +1,293 @@
// Textures and models: loaded through the VFS, described here, uploaded by the backend.
#include "asset/kmdl.h"
#include "core/log.h"
#include "fs/vfs.h"
#include "render/renderer.h"
#include <yapf.h>
#include <algorithm>
#include <cmath>
#include <cstring>
namespace keel {
TextureId Renderer::newTexture(uint32_t w, uint32_t h, bool srgb, const std::vector<std::vector<uint8_t>>& mips, TexFormat format) {
if (nextTexture_ >= kMaxTextures) return kMissingTexture;
const TextureId id = nextTexture_++;
backend_->createTexture(id, w, h, srgb, mips, format);
textureSizes_[id] = glm::ivec2(int(w), int(h));
return id;
}
// Why YAPF couldn't read a file, in words.
static const char* yapfReason(int err) {
switch (err) {
case YAPF_ERR_INVALID: return "not a YAPF file";
case YAPF_ERR_IO: return "couldn't be read";
case YAPF_ERR_OOM: return "out of memory";
case YAPF_ERR_CORRUPT: return "damaged";
case YAPF_ERR_UNSUPPORTED: return "uses a feature this YAPF can't decode (a newer version?)";
case YAPF_ERR_CHECKSUM: return "damaged (checksum mismatch)";
case YAPF_ERR_TRUNCATED: return "truncated";
default: return "unknown error";
}
}
// A .yapf file (version 1 or 2) as mips for the GPU: BC7 blocks go up as they are when the GPU
// takes them; anything else (other block formats, 16-bit, half, float, gray) is decoded to RGBA8
// by YAPF (yapf_tex_to_rgba8).
bool Renderer::decodeTexture(const std::string& key, uint32_t& w, uint32_t& h, bool& srgb, std::vector<std::vector<uint8_t>>& mips,
TexFormat& format) {
auto data = fs::read(key);
if (!data) {
log::warn("Texture not found: {}", key);
return false;
}
int err = 0;
yapf_texture_t* tex = yapf_tex_load_memory(data->data(), data->size(), 0, 0, &err);
if (!tex) {
log::warn("{}: can't load: {} (YAPF error {})", key, yapfReason(err), err);
return false;
}
w = tex->width, h = tex->height, srgb = tex->flags & YAPF_FLAG_SRGB;
// Float and half textures are linear: yapf_tex_to_rgba8 sRGB-encodes them.
if (tex->payload != YAPF_PAYLOAD_BLOCKS && (tex->sample == YAPF_SAMPLE_F16 || tex->sample == YAPF_SAMPLE_F32)) srgb = true;
const bool upload = tex->payload == YAPF_PAYLOAD_BLOCKS && tex->format == YAPF_GPU_BC7 && backend_->blockCompression();
format = upload ? TexFormat::BC7 : TexFormat::RGBA8;
mips.clear();
for (uint32_t level = 0; level < tex->mip_levels; ++level) {
uint32_t lw = 0, lh = 0, ld = 0;
yapf_tex_mip_size(tex, level, &lw, &lh, &ld);
const uint32_t surface = yapf_tex_surface(tex, 0, 0, 0, level);
if (upload) {
const uint8_t* src = static_cast<const uint8_t*>(tex->data[surface]);
mips.emplace_back(src, src + yapf_tex_surface_bytes(tex, level));
continue;
}
std::vector<uint8_t> rgba(size_t(lw) * lh * 4);
if (const int e = yapf_tex_to_rgba8(tex, surface, 0, rgba.data()); e != YAPF_OK) {
log::warn("{}: mip {} can't be decoded: {}", key, level, yapfReason(e));
yapf_tex_free(tex);
return false;
}
mips.push_back(std::move(rgba));
}
yapf_tex_free(tex);
return true;
}
TextureId Renderer::createTexture(std::string_view name, uint32_t w, uint32_t h, const uint8_t* rgba) {
// A full mip chain (box filtered), so a big picture seen small doesn't shimmer.
std::vector<std::vector<uint8_t>> mips(1, std::vector<uint8_t>(rgba, rgba + size_t(w) * h * 4));
for (uint32_t mw = w, mh = h; mw > 1 || mh > 1;) {
const uint32_t nw = std::max(mw / 2, 1u), nh = std::max(mh / 2, 1u);
const std::vector<uint8_t>& src = mips.back();
std::vector<uint8_t> dst(size_t(nw) * nh * 4);
for (uint32_t y = 0; y < nh; ++y)
for (uint32_t x = 0; x < nw; ++x)
for (int c = 0; c < 4; ++c) {
const uint32_t x0 = std::min(x * 2, mw - 1), x1 = std::min(x * 2 + 1, mw - 1), y0 = std::min(y * 2, mh - 1), y1 = std::min(y * 2 + 1, mh - 1);
dst[(size_t(y) * nw + x) * 4 + c] = uint8_t((src[(size_t(y0) * mw + x0) * 4 + c] + src[(size_t(y0) * mw + x1) * 4 + c] +
src[(size_t(y1) * mw + x0) * 4 + c] + src[(size_t(y1) * mw + x1) * 4 + c] + 2) / 4);
}
mips.push_back(std::move(dst));
mw = nw, mh = nh;
}
const std::string key(name);
if (auto it = textureCache_.find(key); it != textureCache_.end() && it->second >= kFirstTextureSlot) {
backend_->createTexture(it->second, w, h, true, mips, TexFormat::RGBA8);
textureSizes_[it->second] = glm::ivec2(int(w), int(h));
return it->second;
}
return textureCache_[key] = newTexture(w, h, true, mips, TexFormat::RGBA8);
}
TextureId Renderer::loadTexture(std::string_view path) {
if (path.empty()) return kWhiteTexture;
const std::string key(path);
if (auto it = textureCache_.find(key); it != textureCache_.end()) return it->second;
TextureId id = kMissingTexture;
uint32_t w = 0, h = 0;
bool srgb = false;
std::vector<std::vector<uint8_t>> mips;
TexFormat format;
if (decodeTexture(key, w, h, srgb, mips, format)) id = newTexture(w, h, srgb, mips, format);
if (auto t = fs::mtime(key)) fileTimes_[key] = *t;
textureCache_[key] = id;
return id;
}
std::vector<std::string> Renderer::reloadChanged() {
std::vector<std::string> changed;
auto stale = [&](const std::string& path) {
const auto t = fs::mtime(path);
if (!t) return false;
auto [it, isNew] = fileTimes_.try_emplace(path, *t);
if (isNew || it->second == *t) return false;
it->second = *t;
return true;
};
for (auto& [path, id] : textureCache_) {
if (id < kFirstTextureSlot || !stale(path)) continue;
uint32_t w = 0, h = 0;
bool srgb = false;
std::vector<std::vector<uint8_t>> mips;
TexFormat format;
if (!decodeTexture(path, w, h, srgb, mips, format)) continue;
backend_->createTexture(id, w, h, srgb, mips, format); // the same slot: everything using it updates
textureSizes_[id] = glm::ivec2(int(w), int(h));
changed.push_back(path);
}
std::vector<std::pair<std::string, ModelId>> models(modelCache_.begin(), modelCache_.end());
for (auto& [path, id] : models) {
if (!id.valid() || path.starts_with("map:") || !stale(path)) continue;
auto data = fs::read(path);
auto kmdl = data ? readKmdl(*data) : std::nullopt;
if (!kmdl) continue;
updateModel(path, *kmdl);
changed.push_back(path);
}
return changed;
}
Renderer::Model Renderer::describeModel(const KmdlModel& kmdl) {
Model m;
if (kmdl.vertices.empty() || kmdl.indices.empty()) return m;
m.mins = glm::vec3(kmdl.header.boundsMin[0], kmdl.header.boundsMin[1], kmdl.header.boundsMin[2]);
m.maxs = glm::vec3(kmdl.header.boundsMax[0], kmdl.header.boundsMax[1], kmdl.header.boundsMax[2]);
m.skinned = !kmdl.skin.empty();
for (const KmdlSubmesh& s : kmdl.submeshes) {
const KmdlMaterial& mat = kmdl.materials[s.material];
const std::string tex(mat.texture, strnlen(mat.texture, sizeof mat.texture));
const std::string lm(mat.lightmap, strnlen(mat.lightmap, sizeof mat.lightmap));
// A material map next to the texture ("wall.yapf" → "wall_n.yapf"), when there is one.
TextureId material = 0;
if (const size_t dot = tex.rfind('.'); !tex.empty() && dot != std::string::npos)
if (const std::string n = tex.substr(0, dot) + "_n" + tex.substr(dot); fs::exists(n)) material = loadTexture(n);
TextureId skyVis = 0;
if (const size_t dot = lm.rfind('.'); !lm.empty() && dot != std::string::npos)
if (const std::string v = lm.substr(0, dot) + "_sky" + lm.substr(dot); fs::exists(v)) skyVis = loadTexture(v);
glm::vec3 lo(1e30f), hi(-1e30f);
for (uint32_t i = s.firstIndex; i < s.firstIndex + s.indexCount && i < kmdl.indices.size(); ++i) {
const float* p = kmdl.vertices[kmdl.indices[i]].position;
lo = glm::min(lo, glm::vec3(p[0], p[1], p[2]));
hi = glm::max(hi, glm::vec3(p[0], p[1], p[2]));
}
if (m.skinned) lo = m.mins - (m.maxs - m.mins) * 0.5f, hi = m.maxs + (m.maxs - m.mins) * 0.5f; // animated: room to move
m.submeshes.push_back({s.firstIndex, s.indexCount, loadTexture(tex), lm.empty() ? 0u : loadTexture(lm),
glm::vec4(mat.baseColor[0], mat.baseColor[1], mat.baseColor[2], mat.baseColor[3]), material, skyVis, lo, hi});
// A mirror: its plane from its first triangle, facing the way its vertices' normals do.
if (tex.find("mirror") != std::string::npos && s.indexCount >= 3 && !m.skinned) {
const KmdlVertex& va = kmdl.vertices[kmdl.indices[s.firstIndex]];
const glm::vec3 a(va.position[0], va.position[1], va.position[2]);
const float* pb = kmdl.vertices[kmdl.indices[s.firstIndex + 1]].position;
const float* pc = kmdl.vertices[kmdl.indices[s.firstIndex + 2]].position;
glm::vec3 n = glm::cross(glm::vec3(pb[0], pb[1], pb[2]) - a, glm::vec3(pc[0], pc[1], pc[2]) - a);
if (glm::length(n) > 1e-6f) {
n = glm::normalize(n);
if (glm::dot(n, glm::vec3(va.normal[0], va.normal[1], va.normal[2])) < 0.0f) n = -n;
m.submeshes.back().mirror = true;
m.submeshes.back().plane = glm::vec4(n, glm::dot(n, a));
}
}
}
return m;
}
namespace {
// Mirrors reflect in one plane each, but a model keeps all the faces of a texture in one
// submesh (several mirrors, or a mirror brush's faces). Each mirror submesh with more than one
// plane is split into one per plane (its triangles sorted so each runs on). False: nothing to do.
bool splitMirrors(const KmdlModel& in, KmdlModel& out) {
auto isMirror = [&](const KmdlSubmesh& s) {
const KmdlMaterial& m = in.materials[s.material];
return std::string(m.texture, strnlen(m.texture, sizeof m.texture)).find("mirror") != std::string::npos;
};
auto planeOf = [&](uint32_t first) {
const float* a = in.vertices[in.indices[first]].position;
const float* b = in.vertices[in.indices[first + 1]].position;
const float* c = in.vertices[in.indices[first + 2]].position;
const glm::vec3 pa(a[0], a[1], a[2]);
glm::vec3 n = glm::cross(glm::vec3(b[0], b[1], b[2]) - pa, glm::vec3(c[0], c[1], c[2]) - pa);
if (glm::length(n) < 1e-6f) return glm::ivec4(0);
n = glm::normalize(n);
const float* vn = in.vertices[in.indices[first]].normal;
if (glm::dot(n, glm::vec3(vn[0], vn[1], vn[2])) < 0.0f) n = -n;
return glm::ivec4(glm::round(n * 1000.0f), int(std::round(glm::dot(n, pa) * 2.0f))); // within 0.001 / half a unit
};
bool any = false;
for (const KmdlSubmesh& s : in.submeshes) {
if (!isMirror(s) || s.indexCount <= 3) continue;
const glm::ivec4 first = planeOf(s.firstIndex);
for (uint32_t t = s.firstIndex + 3; t + 2 < s.firstIndex + s.indexCount && !any; t += 3) any = planeOf(t) != first;
if (any) break;
}
if (!any) return false;
out = in;
out.submeshes.clear();
for (const KmdlSubmesh& s : in.submeshes) {
if (!isMirror(s)) {
out.submeshes.push_back(s);
continue;
}
std::vector<std::pair<glm::ivec4, std::vector<uint32_t>>> groups; // plane → its triangles' indices
for (uint32_t t = s.firstIndex; t + 2 < s.firstIndex + s.indexCount; t += 3) {
const glm::ivec4 pl = planeOf(t);
auto it = std::find_if(groups.begin(), groups.end(), [&](const auto& g) { return g.first == pl; });
if (it == groups.end()) it = groups.insert(groups.end(), {pl, {}});
it->second.insert(it->second.end(), {in.indices[t], in.indices[t + 1], in.indices[t + 2]});
}
uint32_t at = s.firstIndex;
for (const auto& [pl, idx] : groups) {
std::copy(idx.begin(), idx.end(), out.indices.begin() + at);
out.submeshes.push_back({at, uint32_t(idx.size()), s.material});
at += uint32_t(idx.size());
}
}
return true;
}
} // namespace
ModelId Renderer::createModel(std::string_view name, const KmdlModel& source) {
KmdlModel split;
const KmdlModel& kmdl = splitMirrors(source, split) ? split : source;
const std::string key(name);
if (auto it = modelCache_.find(key); it != modelCache_.end()) return it->second;
ModelId id;
if (!kmdl.vertices.empty() && !kmdl.indices.empty()) {
id.index = uint32_t(models_.size());
models_.push_back(describeModel(kmdl));
backend_->uploadModel(id.index, kmdl);
}
modelCache_[key] = id;
return id;
}
ModelId Renderer::updateModel(std::string_view name, const KmdlModel& source) {
KmdlModel split;
const KmdlModel& kmdl = splitMirrors(source, split) ? split : source;
const auto it = modelCache_.find(std::string(name));
if (it == modelCache_.end() || !it->second.valid()) {
if (it != modelCache_.end()) modelCache_.erase(it);
return createModel(name, kmdl);
}
models_[it->second.index] = describeModel(kmdl); // may be empty: draws nothing
backend_->uploadModel(it->second.index, kmdl);
return it->second;
}
ModelId Renderer::loadModel(std::string_view path) {
const std::string key(path);
if (auto it = modelCache_.find(key); it != modelCache_.end()) return it->second;
if (auto t = fs::mtime(key)) fileTimes_[key] = *t;
auto data = fs::read(path);
auto kmdl = data ? readKmdl(*data) : std::nullopt;
if (!kmdl) {
log::warn("Model {} {}", key, data ? "is not a valid KMDL file" : "not found");
return modelCache_[key] = ModelId{};
}
return createModel(key, *kmdl);
}
} // namespace keel

View file

@ -0,0 +1,38 @@
#include "render/screenshot.h"
#include "core/log.h"
#define STB_IMAGE_WRITE_IMPLEMENTATION
#define STB_IMAGE_IMPLEMENTATION // (stb_image for the whole program: the window icon, and the app's pictures)
#include <stb_image.h>
#include <stb_image_write.h>
#include <atomic>
#include <chrono>
#include <filesystem>
#include <thread>
namespace keel {
namespace {
std::atomic<int> g_pending{0};
}
void saveScreenshot(std::string path, std::vector<uint8_t> rgba, uint32_t width, uint32_t height) {
// PNG compression is slow (very slow in debug builds); keep it off the render thread.
++g_pending;
std::thread([px = std::move(rgba), path = std::move(path), width, height]() mutable {
for (size_t i = 0; i < size_t(width) * height; ++i) px[i * 4 + 3] = 255;
std::error_code ec;
std::filesystem::create_directories(std::filesystem::path(path).parent_path(), ec);
stbi_write_png_compression_level = 4;
if (stbi_write_png(path.c_str(), int(width), int(height), 4, px.data(), int(width * 4))) log::info("Saved {}", path);
else log::warn("Couldn't write {}", path);
--g_pending;
}).detach();
}
void waitForScreenshots() {
// Quitting right after a screenshot would cut it off half-written.
for (int i = 0; g_pending > 0 && i < 1000; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
} // namespace keel

View file

@ -0,0 +1,11 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace keel {
// Writes RGBA8 pixels (row 0 at the top) to a PNG on a background thread; alpha is forced opaque.
void saveScreenshot(std::string path, std::vector<uint8_t> rgba, uint32_t width, uint32_t height);
// Blocks until screenshots still being written are done (before the process exits).
void waitForScreenshots();
} // namespace keel

View file

@ -0,0 +1,858 @@
// The OpenGL 4.6 backend. Same shaders and passes as Vulkan:
// - textures are ARB_bindless_texture handles in a storage-buffer table, indexed exactly like
// Vulkan's descriptor array; shadow maps also get comparison handles in a second table
// - per-draw / per-frame data are storage buffers at fixed bindings (model.glsl); the push
// constants become a small uniform block, rewritten per draw
// - glClipControl(LOWER_LEFT, ZERO_TO_ONE): with GL's own origin, clip-space y = -1 lands in
// memory row 0 exactly as in Vulkan (whose projection we share, y flipped), so images,
// shadow-map tiles and gl_FragCoord all match; depth is 0..1 like Vulkan. The y flip reverses
// window-space winding, so front faces are clockwise. Only the final blit to the window
// (which shows row 0 at the bottom) flips.
// Requires ARB_bindless_texture (NVIDIA, AMD); without it init fails and the engine uses Vulkan.
#include "render/backend.h"
#include "render/screenshot.h"
#include "rhi/gl/gl_loader.h"
#include "core/log.h"
#include "platform/window.h"
#include <SDL3/SDL.h>
#include <imgui.h>
#include <imgui_impl_opengl3.h>
#include <imgui_impl_sdl3.h>
#include <cstring>
#include <map>
#include <string>
#include <string_view>
namespace keel {
namespace {
// Every shader's source and the shared headers, embedded by the build (cmake/EmbedText.cmake).
const std::map<std::string_view, std::string_view>& shaderSources() {
static const std::map<std::string_view, std::string_view> sources = [] {
struct Entry { const char* name; const char* text; };
static const Entry entries[] = {
#include "gl_sources.inc"
};
std::map<std::string_view, std::string_view> m;
for (const Entry& e : entries) m[e.name] = e.text;
return m;
}();
return sources;
}
// The source with #includes expanded and the GL preamble in place of its #version line.
std::string expand(std::string_view name, int depth = 0) {
const auto& sources = shaderSources();
const auto it = sources.find(name);
if (it == sources.end() || depth > 8) {
log::error("OpenGL: shader source {} not found", name);
return {};
}
std::string out;
std::string_view text = it->second;
while (!text.empty()) {
const size_t eol = text.find('\n');
const std::string_view line = text.substr(0, eol);
text = eol == std::string_view::npos ? std::string_view{} : text.substr(eol + 1);
if (line.starts_with("#version")) {
out += "#version 460 core\n#extension GL_ARB_bindless_texture : require\n#define KEEL_GL 1\n";
} else if (line.starts_with("#include \"")) {
const size_t a = line.find('"') + 1, b = line.find('"', a);
out += expand(line.substr(a, b - a), depth + 1);
} else {
out += line;
out += '\n';
}
}
return out;
}
GLuint compile(GLenum type, std::string_view name) {
const std::string src = expand(name);
const char* text = src.c_str();
const GLuint s = gl::CreateShader(type);
gl::ShaderSource(s, 1, &text, nullptr);
gl::CompileShader(s);
GLint ok = 0;
gl::GetShaderiv(s, GL_COMPILE_STATUS, &ok);
if (!ok) {
char logText[4096];
gl::GetShaderInfoLog(s, sizeof logText, nullptr, logText);
log::error("OpenGL: {} failed to compile:\n{}", name, logText);
gl::DeleteShader(s);
return 0;
}
return s;
}
GLuint program(std::string_view vs, std::string_view fs) {
const GLuint v = compile(GL_VERTEX_SHADER, vs), f = fs.empty() ? 0 : compile(GL_FRAGMENT_SHADER, fs);
if (!v || (!fs.empty() && !f)) return 0;
const GLuint p = gl::CreateProgram();
gl::AttachShader(p, v);
if (f) gl::AttachShader(p, f);
gl::LinkProgram(p);
gl::DeleteShader(v);
if (f) gl::DeleteShader(f);
GLint ok = 0;
gl::GetProgramiv(p, GL_LINK_STATUS, &ok);
if (!ok) {
char logText[4096];
gl::GetProgramInfoLog(p, sizeof logText, nullptr, logText);
log::error("OpenGL: {} + {} failed to link:\n{}", vs, fs, logText);
gl::DeleteProgram(p);
return 0;
}
return p;
}
void APIENTRY debugMessage(GLenum, GLenum type, GLuint, GLenum severity, GLsizei, const GLchar* message, const void*) {
if (severity == GL_DEBUG_SEVERITY_NOTIFICATION) return;
if (type == GL_DEBUG_TYPE_ERROR || severity == GL_DEBUG_SEVERITY_HIGH) log::error("[gl] {}", message);
else log::warn("[gl] {}", message);
}
// Uniform blocks (std140) standing in for the push constants.
struct ModelPush {
glm::mat4 viewProj;
uint32_t textures, tint, material, drawIndex, firstJoint, pad[3];
};
struct BoxPush {
glm::mat4 viewProj;
gpu::Box box;
};
struct HzbPush {
uint32_t depth, srcW, srcH, dstW, dstH, pad[3];
};
struct TonemapPush {
uint32_t scene;
float exposure;
uint32_t ao, bloom;
float bloomStrength, saturation, vignette, sharpen;
uint32_t depth, pad[3];
glm::vec4 shafts, sunColor;
};
struct SsaoPush {
uint32_t depth, w, h, outW, outH;
float radius, strength, pad;
glm::vec4 proj;
};
struct SsaoBlurPush {
uint32_t ao, depth, w, h, outW, outH;
float pad0, pad1;
glm::vec4 proj;
};
struct TaaPush {
glm::mat4 reproject;
uint32_t scene, history, depth, w, h, reset, ssr;
float pad1;
};
struct AdaptPush {
uint32_t src, prev;
float dt, speed, key, minE, maxE, pad;
};
struct SpritePush {
glm::mat4 viewProj;
gpu::Sprite sprite;
};
struct VolumetricPush {
uint32_t depth, w, h, outW, outH;
float pad[3];
glm::vec4 proj;
};
struct SsrPush {
uint32_t depth, gbuf, scene, w, h, outW, outH;
float strength;
glm::vec4 proj;
};
struct BloomPush {
uint32_t src, srcW, srcH, outW, outH;
uint32_t extra; // down: first level; up: radius (float bits)
uint32_t pad[2];
};
// Storage buffer bindings (model.glsl, backend_gl.glsl).
enum Binding : GLuint {
B_VERTICES = 0, B_SKIN = 1, B_JOINTS = 2, B_DRAWS = 3, B_FRAME = 4, B_LIGHTS = 5, B_CELLS = 6, B_INDICES = 7, B_VIEWS = 8,
B_TEXTURES = 9, B_SHADOW_TEXTURES = 10,
};
class OpenGLBackend final : public RenderBackend {
public:
const char* name() const override { return "OpenGL 4.6"; }
GpuInfo gpuInfo() const override { return gpu_; }
GpuInfo gpu_;
bool init(const Window& window, bool validation) override {
window_ = window.handle();
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 4);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 6);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, validation ? SDL_GL_CONTEXT_DEBUG_FLAG : 0);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 0); // we render into our own targets
context_ = SDL_GL_CreateContext(window_);
if (!context_) {
log::warn("OpenGL 4.6 context: {}", SDL_GetError());
return false;
}
SDL_GL_MakeCurrent(window_, context_);
if (!gl::load()) return false;
bool bindless = false;
GLint extensions = 0;
gl::GetIntegerv(GL_NUM_EXTENSIONS, &extensions);
for (GLint i = 0; i < extensions; ++i)
bindless |= std::string_view(reinterpret_cast<const char*>(gl::GetStringi(GL_EXTENSIONS, GLuint(i)))) == "GL_ARB_bindless_texture";
log::info("GPU: {} ({})", reinterpret_cast<const char*>(gl::GetString(GL_RENDERER)), reinterpret_cast<const char*>(gl::GetString(GL_VERSION)));
gpu_.name = reinterpret_cast<const char*>(gl::GetString(GL_RENDERER)), gpu_.driver = reinterpret_cast<const char*>(gl::GetString(GL_VERSION));
if (!bindless) {
log::warn("OpenGL: GL_ARB_bindless_texture isn't supported by this driver");
return false;
}
if (validation) {
gl::Enable(GL_DEBUG_OUTPUT);
gl::Enable(GL_DEBUG_OUTPUT_SYNCHRONOUS);
gl::DebugMessageCallback(debugMessage, nullptr);
}
gl::ClipControl(GL_LOWER_LEFT, GL_ZERO_TO_ONE);
gl::FrontFace(GL_CW);
gl::PixelStorei(GL_UNPACK_ALIGNMENT, 1);
gl::PixelStorei(GL_PACK_ALIGNMENT, 1);
gl::CreateSamplers(1, &repeatSampler_);
gl::SamplerParameteri(repeatSampler_, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
gl::SamplerParameteri(repeatSampler_, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
gl::SamplerParameteri(repeatSampler_, GL_TEXTURE_WRAP_S, GL_REPEAT);
gl::SamplerParameteri(repeatSampler_, GL_TEXTURE_WRAP_T, GL_REPEAT);
gl::SamplerParameterf(repeatSampler_, GL_TEXTURE_MAX_ANISOTROPY, 16.0f);
gl::CreateSamplers(1, &clampSampler_);
gl::SamplerParameteri(clampSampler_, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
gl::SamplerParameteri(clampSampler_, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
gl::SamplerParameteri(clampSampler_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
gl::SamplerParameteri(clampSampler_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
gl::CreateSamplers(1, &compareSampler_);
gl::SamplerParameteri(compareSampler_, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
gl::SamplerParameteri(compareSampler_, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
gl::SamplerParameteri(compareSampler_, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
gl::SamplerParameteri(compareSampler_, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
gl::SamplerParameteri(compareSampler_, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE);
gl::SamplerParameteri(compareSampler_, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);
auto buffer = [](GLsizeiptr size) {
GLuint b;
gl::CreateBuffers(1, &b);
gl::NamedBufferStorage(b, size, nullptr, GL_DYNAMIC_STORAGE_BIT);
return b;
};
textureTable_ = buffer(kMaxTextures * sizeof(GLuint64));
shadowTable_ = buffer(kMaxTextures * sizeof(GLuint64));
draws_ = buffer(kMaxDraws * sizeof(gpu::DrawData));
joints_ = buffer(kMaxJointsPerFrame * sizeof(glm::mat4));
frame_ = buffer(sizeof(gpu::FrameData));
mirrorFrame_ = buffer(sizeof(gpu::FrameData));
lights_ = buffer(kMaxLights * sizeof(gpu::Light));
views_ = buffer(kMaxShadowViews * sizeof(gpu::ShadowView));
cells_ = buffer(kClusterX * kClusterY * kClusterZ * sizeof(glm::uvec2));
indices_ = buffer(kMaxLightIndices * sizeof(uint32_t));
push_ = buffer(256);
empty_ = buffer(16); // bound where a shader declares a buffer the draw doesn't use
gl::CreateVertexArrays(1, &vao_);
meshProgram_ = program("mesh.vert", "mesh.frag");
skinnedProgram_ = program("skinned.vert", "mesh.frag");
shadowMeshProgram_ = program("mesh.vert", "");
shadowSkinnedProgram_ = program("skinned.vert", "");
boxProgram_ = program("box.vert", "box.frag");
tonemapProgram_ = program("fullscreen.vert", "tonemap.frag");
skyProgram_ = program("sky.vert", "sky.frag");
hzbProgram_ = program("fullscreen.vert", "hzb.frag");
ssaoProgram_ = program("fullscreen.vert", "ssao.frag");
ssaoBlurProgram_ = program("fullscreen.vert", "ssao_blur.frag");
bloomDownProgram_ = program("fullscreen.vert", "bloom_down.frag");
bloomUpProgram_ = program("fullscreen.vert", "bloom_up.frag");
taaProgram_ = program("fullscreen.vert", "taa.frag");
ssrProgram_ = program("fullscreen.vert", "ssr.frag");
adaptProgram_ = program("fullscreen.vert", "adapt.frag");
volumetricProgram_ = program("fullscreen.vert", "volumetric.frag");
spriteProgram_ = program("sprite.vert", "sprite.frag");
gl::CreateTextures(GL_TEXTURE_2D, 1, &hzbTex_);
gl::TextureStorage2D(hzbTex_, 1, GL_R32F, GLsizei(kHzbWidth), GLsizei(kHzbHeight));
gl::CreateFramebuffers(1, &hzbFbo_);
gl::NamedFramebufferTexture(hzbFbo_, GL_COLOR_ATTACHMENT0, hzbTex_, 0);
gl::CreateBuffers(kHzbRing, hzbPbo_);
for (GLuint b : hzbPbo_) gl::NamedBufferStorage(b, kHzbWidth * kHzbHeight * 4, nullptr, GL_MAP_READ_BIT | GL_CLIENT_STORAGE_BIT);
if (!meshProgram_ || !skinnedProgram_ || !shadowMeshProgram_ || !shadowSkinnedProgram_ || !boxProgram_ || !tonemapProgram_ || !skyProgram_)
return false;
sunShadow_ = depthTarget(kShadowSize, sunShadowFbo_);
atlas_ = depthTarget(kAtlasSize, atlasFbo_);
setHandles(kSunShadowSlot, sunShadow_, clampSampler_, true);
setHandles(kAtlasSlot, atlas_, clampSampler_, true);
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGui::GetIO().IniFilename = nullptr;
ImGui::StyleColorsDark();
imgui_ = ImGui_ImplSDL3_InitForOpenGL(window_, context_) && ImGui_ImplOpenGL3_Init("#version 460");
return imgui_;
}
void shutdown() override {
if (!context_) return;
if (imgui_) {
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplSDL3_Shutdown();
ImGui::DestroyContext();
imgui_ = false;
}
if (gl::DeleteBuffers) {
for (auto& [slot, t] : textures_) releaseTexture(t);
for (Model& m : models_) releaseModel(m);
releaseTargets();
for (GLuint* t : {&sunShadow_.texture, &atlas_.texture}) gl::DeleteTextures(1, t);
for (GLuint* f : {&sunShadowFbo_, &atlasFbo_, &hzbFbo_}) gl::DeleteFramebuffers(1, f);
gl::DeleteTextures(1, &hzbTex_);
for (int i = 0; i < kHzbRing; ++i) {
if (hzbFence_[i]) gl::DeleteSync(hzbFence_[i]);
gl::DeleteBuffers(1, &hzbPbo_[i]);
}
for (GLuint b : {textureTable_, shadowTable_, draws_, joints_, frame_, mirrorFrame_, lights_, views_, cells_, indices_, push_, empty_})
gl::DeleteBuffers(1, &b);
for (GLuint s : {repeatSampler_, clampSampler_, compareSampler_}) gl::DeleteSamplers(1, &s);
for (GLuint p : {meshProgram_, skinnedProgram_, shadowMeshProgram_, shadowSkinnedProgram_, boxProgram_, tonemapProgram_, skyProgram_, hzbProgram_, ssaoProgram_, ssaoBlurProgram_, ssrProgram_, adaptProgram_, volumetricProgram_, spriteProgram_,
bloomDownProgram_, bloomUpProgram_, taaProgram_})
gl::DeleteProgram(p);
gl::DeleteVertexArrays(1, &vao_);
}
SDL_GL_DestroyContext(context_);
context_ = nullptr;
}
void beginUi() override {
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplSDL3_NewFrame();
ImGui::NewFrame();
}
void setVsync(bool on) override { SDL_GL_SetSwapInterval(on ? 1 : 0); }
void onResize() override {}
bool blockCompression() const override { return true; } // BPTC (BC7) is core since 4.2
bool renderScale() const override { return true; }
void createTexture(TextureId slot, uint32_t w, uint32_t h, bool srgb, const std::vector<std::vector<uint8_t>>& mips,
TexFormat format = TexFormat::RGBA8) override {
Texture t;
gl::CreateTextures(GL_TEXTURE_2D, 1, &t.texture);
if (format == TexFormat::BC7) {
const GLenum f = srgb ? 0x8E8D /* GL_COMPRESSED_SRGB_ALPHA_BPTC_UNORM */ : 0x8E8C /* GL_COMPRESSED_RGBA_BPTC_UNORM */;
gl::TextureStorage2D(t.texture, GLsizei(mips.size()), f, GLsizei(w), GLsizei(h));
for (size_t level = 0; level < mips.size(); ++level)
gl::CompressedTextureSubImage2D(t.texture, GLint(level), 0, 0, GLsizei(std::max(1u, w >> level)), GLsizei(std::max(1u, h >> level)), f,
GLsizei(mips[level].size()), mips[level].data());
} else {
gl::TextureStorage2D(t.texture, GLsizei(mips.size()), srgb ? GL_SRGB8_ALPHA8 : GL_RGBA8, GLsizei(w), GLsizei(h));
for (size_t level = 0; level < mips.size(); ++level)
gl::TextureSubImage2D(t.texture, GLint(level), 0, 0, GLsizei(std::max(1u, w >> level)), GLsizei(std::max(1u, h >> level)), GL_RGBA,
GL_UNSIGNED_BYTE, mips[level].data());
}
if (auto it = textures_.find(slot); it != textures_.end()) retiredTextures_.emplace_back(frameNumber_, it->second); // queued draws may use it
setHandles(slot, t, repeatSampler_, false); // like Vulkan: one repeating sampler for every texture
textures_[slot] = t;
}
void uploadModel(uint32_t index, const KmdlModel& kmdl) override {
if (models_.size() <= index) models_.resize(index + 1);
releaseModel(models_[index]); // GL keeps it alive while queued draws still use it
Model m;
auto upload = [](const void* data, size_t size) {
GLuint b;
gl::CreateBuffers(1, &b);
gl::NamedBufferStorage(b, GLsizeiptr(size), data, 0);
return b;
};
if (!kmdl.vertices.empty() && !kmdl.indices.empty()) {
m.vertices = upload(kmdl.vertices.data(), kmdl.vertices.size() * sizeof(KmdlVertex));
m.indices = upload(kmdl.indices.data(), kmdl.indices.size() * sizeof(uint32_t));
if (!kmdl.skin.empty()) m.skin = upload(kmdl.skin.data(), kmdl.skin.size() * sizeof(KmdlSkin));
}
models_[index] = m;
}
uint64_t uiTexture(TextureId slot) override {
const auto it = textures_.find(slot);
return it == textures_.end() ? 0 : uint64_t(it->second.texture);
}
bool beginFrame(uint32_t& width, uint32_t& height) override {
int w = 0, h = 0;
if (SDL_GetWindowFlags(window_) & SDL_WINDOW_MINIMIZED) return false;
SDL_GetWindowSizeInPixels(window_, &w, &h);
if (w <= 0 || h <= 0) return false;
width = uint32_t(w); // (targets are sized to the scene in render)
height = uint32_t(h);
return true;
}
void render(FramePacket& p) override;
// Reads ring slot i's grid if the GPU is done with it (or, wait: before reusing the slot).
void collectHzb(int i, bool wait) {
if (!hzbFence_[i]) return;
const GLenum r = gl::ClientWaitSync(hzbFence_[i], wait ? GL_SYNC_FLUSH_COMMANDS_BIT : 0, wait ? 1'000'000'000 : 0);
if (r != GL_ALREADY_SIGNALED && r != GL_CONDITION_SATISFIED) return;
gl::DeleteSync(hzbFence_[i]);
hzbFence_[i] = nullptr;
if (hzbTags_[i] <= hzbLatest_.tag) return;
if (const float* d = static_cast<const float*>(gl::MapNamedBufferRange(hzbPbo_[i], 0, kHzbWidth * kHzbHeight * 4, GL_MAP_READ_BIT))) {
hzbLatest_ = {hzbTags_[i], std::vector<float>(d, d + kHzbWidth * kHzbHeight)};
gl::UnmapNamedBuffer(hzbPbo_[i]);
}
}
bool takeHzb(HzbReadback& out) override {
for (int i = 0; i < kHzbRing; ++i) collectHzb(i, false);
if (hzbLatest_.tag <= hzbTaken_) return false;
out = hzbLatest_;
hzbTaken_ = hzbLatest_.tag;
return true;
}
private:
struct Texture {
GLuint texture = 0;
GLuint64 handle = 0, shadowHandle = 0;
};
struct Model {
GLuint vertices = 0, indices = 0, skin = 0;
};
void setHandles(TextureId slot, Texture& t, GLuint sampler, bool depth) {
t.handle = gl::GetTextureSamplerHandleARB(t.texture, sampler);
gl::MakeTextureHandleResidentARB(t.handle);
gl::NamedBufferSubData(textureTable_, GLintptr(slot) * 8, 8, &t.handle);
if (depth) {
t.shadowHandle = gl::GetTextureSamplerHandleARB(t.texture, compareSampler_);
gl::MakeTextureHandleResidentARB(t.shadowHandle);
gl::NamedBufferSubData(shadowTable_, GLintptr(slot) * 8, 8, &t.shadowHandle);
}
}
void releaseTexture(Texture& t) {
if (t.handle) gl::MakeTextureHandleNonResidentARB(t.handle);
if (t.shadowHandle) gl::MakeTextureHandleNonResidentARB(t.shadowHandle);
if (t.texture) gl::DeleteTextures(1, &t.texture);
t = {};
}
void releaseModel(Model& m) {
for (GLuint* b : {&m.vertices, &m.indices, &m.skin})
if (*b) gl::DeleteBuffers(1, b);
m = {};
}
Texture depthTarget(uint32_t size, GLuint& fbo) {
Texture t;
gl::CreateTextures(GL_TEXTURE_2D, 1, &t.texture);
gl::TextureStorage2D(t.texture, 1, GL_DEPTH_COMPONENT32F, GLsizei(size), GLsizei(size));
gl::CreateFramebuffers(1, &fbo);
gl::NamedFramebufferTexture(fbo, GL_DEPTH_ATTACHMENT, t.texture, 0);
gl::NamedFramebufferDrawBuffer(fbo, GL_NONE);
return t;
}
void releaseTargets() {
releaseTexture(scene_);
releaseTexture(gbuf_);
releaseTexture(mirror_);
releaseTexture(mirrorGbuf_);
if (mirrorDepth_) gl::DeleteTextures(1, &mirrorDepth_);
if (mirrorFbo_) gl::DeleteFramebuffers(1, &mirrorFbo_);
mirrorDepth_ = mirrorFbo_ = 0;
releasePostTarget(ssr_);
for (PostTarget& t : adapt_) releasePostTarget(t);
releasePostTarget(ao_);
releasePostTarget(aoBlur_);
for (PostTarget& t : bloom_) releasePostTarget(t);
for (PostTarget& t : taa_) releasePostTarget(t);
if (depthTex_.handle) gl::MakeTextureHandleNonResidentARB(depthTex_.handle);
depthTex_ = {};
if (sceneDepth_) gl::DeleteTextures(1, &sceneDepth_);
if (sceneFbo_) gl::DeleteFramebuffers(1, &sceneFbo_);
sceneDepth_ = sceneFbo_ = 0;
}
void ensureTargets(uint32_t w, uint32_t h) {
if (scene_.texture && w == targetW_ && h == targetH_) return;
releaseTargets();
gl::CreateTextures(GL_TEXTURE_2D, 1, &scene_.texture);
gl::TextureStorage2D(scene_.texture, 1, GL_RGBA16F, GLsizei(w), GLsizei(h));
gl::CreateTextures(GL_TEXTURE_2D, 1, &sceneDepth_);
gl::TextureStorage2D(sceneDepth_, 1, GL_DEPTH_COMPONENT32F, GLsizei(w), GLsizei(h));
gl::CreateFramebuffers(1, &sceneFbo_);
gl::NamedFramebufferTexture(sceneFbo_, GL_COLOR_ATTACHMENT0, scene_.texture, 0);
gl::NamedFramebufferTexture(sceneFbo_, GL_DEPTH_ATTACHMENT, sceneDepth_, 0);
gl::CreateTextures(GL_TEXTURE_2D, 1, &gbuf_.texture); // the G-buffer: view normal, roughness, reflection weight
gl::TextureStorage2D(gbuf_.texture, 1, GL_RGBA16F, GLsizei(w), GLsizei(h));
gl::NamedFramebufferTexture(sceneFbo_, GL_COLOR_ATTACHMENT1, gbuf_.texture, 0);
const GLenum bufs[2] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1};
gl::NamedFramebufferDrawBuffers(sceneFbo_, 2, bufs);
setHandles(kGbufSlot, gbuf_, clampSampler_, false);
if (gl::CheckNamedFramebufferStatus(sceneFbo_, GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) log::error("OpenGL: scene framebuffer incomplete");
setHandles(kSceneSlot, scene_, clampSampler_, false);
gl::CreateTextures(GL_TEXTURE_2D, 1, &mirror_.texture);
gl::TextureStorage2D(mirror_.texture, 1, GL_RGBA16F, GLsizei(w), GLsizei(h));
gl::CreateTextures(GL_TEXTURE_2D, 1, &mirrorGbuf_.texture);
gl::TextureStorage2D(mirrorGbuf_.texture, 1, GL_RGBA16F, GLsizei(w), GLsizei(h));
gl::CreateTextures(GL_TEXTURE_2D, 1, &mirrorDepth_);
gl::TextureStorage2D(mirrorDepth_, 1, GL_DEPTH_COMPONENT32F, GLsizei(w), GLsizei(h));
gl::CreateFramebuffers(1, &mirrorFbo_);
gl::NamedFramebufferTexture(mirrorFbo_, GL_COLOR_ATTACHMENT0, mirror_.texture, 0);
gl::NamedFramebufferTexture(mirrorFbo_, GL_COLOR_ATTACHMENT1, mirrorGbuf_.texture, 0);
gl::NamedFramebufferTexture(mirrorFbo_, GL_DEPTH_ATTACHMENT, mirrorDepth_, 0);
gl::NamedFramebufferDrawBuffers(mirrorFbo_, 2, bufs);
setHandles(kMirrorSlot, mirror_, clampSampler_, false);
depthTex_.texture = sceneDepth_;
setHandles(kDepthSlot, depthTex_, clampSampler_, false);
ao_ = makePostTarget(kAoSlot, GL_R8, std::max(1u, w / 2), std::max(1u, h / 2));
aoBlur_ = makePostTarget(kAoBlurSlot, GL_R8, std::max(1u, w / 2), std::max(1u, h / 2));
ssr_ = makePostTarget(kSsrSlot, GL_RGBA16F, std::max(1u, w / 2), std::max(1u, h / 2));
for (uint32_t i = 0; i < 2; ++i) adapt_[i] = makePostTarget(kAdaptSlot + i, GL_RGBA16F, 1, 1);
adaptValid_ = false;
for (uint32_t i = 0; i < 2; ++i) taa_[i] = makePostTarget(kTaaSlot + i, GL_RGBA16F, w, h);
for (uint32_t i = 0; i < kBloomLevels; ++i)
bloom_[i] = makePostTarget(kBloomSlot + i, GL_RGBA16F, std::max(1u, w >> (i + 1)), std::max(1u, h >> (i + 1)));
targetW_ = w;
targetH_ = h;
}
template <typename T> void push(const T& data) { gl::NamedBufferSubData(push_, 0, sizeof data, &data); }
void drawMeshes(const std::vector<MeshDraw>& draws, const glm::mat4& viewProj, bool shadow, GLuint mesh, GLuint skinned);
void shadowPasses(GLuint fbo, uint32_t size, const std::vector<ShadowPass>& passes);
SDL_Window* window_ = nullptr;
SDL_GLContext context_ = nullptr;
bool imgui_ = false;
GLuint repeatSampler_ = 0, clampSampler_ = 0, compareSampler_ = 0;
GLuint mirrorFrame_ = 0; // the mirror's pass's frame data
GLuint textureTable_ = 0, shadowTable_ = 0, draws_ = 0, joints_ = 0, frame_ = 0, lights_ = 0, views_ = 0, cells_ = 0, indices_ = 0;
GLuint push_ = 0, empty_ = 0, vao_ = 0;
GLuint meshProgram_ = 0, skinnedProgram_ = 0, shadowMeshProgram_ = 0, shadowSkinnedProgram_ = 0, boxProgram_ = 0, tonemapProgram_ = 0, skyProgram_ = 0, hzbProgram_ = 0;
GLuint ssaoProgram_ = 0, ssaoBlurProgram_ = 0, bloomDownProgram_ = 0, bloomUpProgram_ = 0, taaProgram_ = 0, ssrProgram_ = 0, adaptProgram_ = 0, volumetricProgram_ = 0, spriteProgram_ = 0;
uint32_t adaptCur_ = 0;
bool adaptValid_ = false;
uint32_t taaCur_ = 0;
uint64_t frameNumber_ = 0;
std::vector<std::pair<uint64_t, Texture>> retiredTextures_;
uint32_t shadowMapSize_ = kShadowSize;
// Post-processing targets: a texture in the table and a framebuffer to draw into it.
struct PostTarget {
Texture tex;
GLuint fbo = 0;
uint32_t w = 0, h = 0;
};
PostTarget ao_, aoBlur_, bloom_[kBloomLevels], taa_[2], ssr_, adapt_[2];
PostTarget makePostTarget(TextureId slot, GLenum format, uint32_t w, uint32_t h) {
PostTarget t;
t.w = w, t.h = h;
gl::CreateTextures(GL_TEXTURE_2D, 1, &t.tex.texture);
gl::TextureStorage2D(t.tex.texture, 1, format, GLsizei(w), GLsizei(h));
gl::CreateFramebuffers(1, &t.fbo);
gl::NamedFramebufferTexture(t.fbo, GL_COLOR_ATTACHMENT0, t.tex.texture, 0);
setHandles(slot, t.tex, clampSampler_, false);
return t;
}
void releasePostTarget(PostTarget& t) {
releaseTexture(t.tex);
if (t.fbo) gl::DeleteFramebuffers(1, &t.fbo);
t = {};
}
// Occlusion grid: drawn, read into a pixel-pack buffer, picked up once its fence passes.
static constexpr int kHzbRing = 3;
GLuint hzbTex_ = 0, hzbFbo_ = 0, hzbPbo_[kHzbRing] = {};
GLsync hzbFence_[kHzbRing] = {};
uint64_t hzbTags_[kHzbRing] = {};
int hzbNext_ = 0;
uint64_t hzbTaken_ = 0;
HzbReadback hzbLatest_;
Texture depthTex_; // the scene depth's handle (texture owned by sceneDepth_)
Texture sunShadow_, atlas_, scene_, gbuf_;
// Mirrors: the reflected scene (and its G-buffer and depth), the scene's size.
Texture mirror_, mirrorGbuf_;
GLuint mirrorDepth_ = 0, mirrorFbo_ = 0;
bool mirrors() const override { return true; }
GLuint sunShadowFbo_ = 0, atlasFbo_ = 0, sceneFbo_ = 0, sceneDepth_ = 0;
uint32_t targetW_ = 0, targetH_ = 0;
std::map<TextureId, Texture> textures_;
std::vector<Model> models_;
};
void OpenGLBackend::drawMeshes(const std::vector<MeshDraw>& draws, const glm::mat4& viewProj, bool shadow, GLuint mesh, GLuint skinned) {
GLuint bound = 0;
uint32_t boundModel = UINT32_MAX;
ModelPush mp{};
mp.viewProj = viewProj;
for (const MeshDraw& d : draws) {
const Model& m = models_[d.model];
if (!m.indices) continue;
const bool isSkinned = d.firstJoint != UINT32_MAX;
const GLuint want = isSkinned ? skinned : mesh;
if (want != bound) {
gl::UseProgram(want);
bound = want;
}
if (d.model != boundModel) {
gl::VertexArrayElementBuffer(vao_, m.indices);
gl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, B_VERTICES, m.vertices);
gl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, B_SKIN, m.skin ? m.skin : empty_);
boundModel = d.model;
}
mp.textures = d.textures;
mp.tint = d.tint;
mp.material = shadow ? 0 : d.material;
mp.drawIndex = d.draw;
mp.firstJoint = isSkinned ? d.firstJoint : 0;
push(mp);
gl::DrawElements(GL_TRIANGLES, GLsizei(d.indexCount), GL_UNSIGNED_INT, reinterpret_cast<const void*>(uintptr_t(d.firstIndex) * 4));
}
}
void OpenGLBackend::shadowPasses(GLuint fbo, uint32_t size, const std::vector<ShadowPass>& passes) {
gl::BindFramebuffer(GL_FRAMEBUFFER, fbo);
gl::DepthMask(GL_TRUE);
gl::Disable(GL_SCISSOR_TEST);
const GLfloat one = 1.0f;
gl::ClearNamedFramebufferfv(fbo, GL_DEPTH, 0, &one);
gl::Enable(GL_SCISSOR_TEST);
gl::Enable(GL_POLYGON_OFFSET_FILL);
gl::PolygonOffset(2.5f, 2.0f); // same as Vulkan's depth bias (slope, constant)
for (const ShadowPass& pass : passes) {
gl::Viewport(pass.rect.x, pass.rect.y, pass.rect.z, pass.rect.w);
gl::Scissor(pass.rect.x, pass.rect.y, pass.rect.z, pass.rect.w);
drawMeshes(*pass.draws, pass.viewProj, true, shadowMeshProgram_, shadowSkinnedProgram_);
}
gl::Disable(GL_POLYGON_OFFSET_FILL);
gl::Disable(GL_SCISSOR_TEST);
(void)size;
}
void OpenGLBackend::render(FramePacket& p) {
++frameNumber_;
ensureTargets(p.width, p.height); // the scene's size (render scale), not the window's
std::erase_if(retiredTextures_, [&](auto& r) {
if (frameNumber_ - r.first <= 3) return false;
releaseTexture(r.second);
return true;
});
gl::ClipControl(GL_LOWER_LEFT, GL_ZERO_TO_ONE); // ImGui may have touched state; ours is set fresh each frame
gl::FrontFace(GL_CW);
auto upload = [](GLuint buffer, const void* data, size_t size) {
if (size) gl::NamedBufferSubData(buffer, 0, GLsizeiptr(size), data);
};
upload(draws_, p.draws.data(), p.draws.size() * sizeof(gpu::DrawData));
upload(joints_, p.joints.data(), p.joints.size() * sizeof(glm::mat4));
upload(frame_, &p.frame, sizeof p.frame);
upload(lights_, p.lights.data(), p.lights.size() * sizeof(gpu::Light));
upload(views_, p.views.data(), p.views.size() * sizeof(gpu::ShadowView));
upload(cells_, p.clusters->cells.data(), p.clusters->cells.size() * sizeof(glm::uvec2));
upload(indices_, p.clusters->indices.data(), p.clusters->indices.size() * sizeof(uint32_t));
const std::pair<Binding, GLuint> bindings[] = {{B_DRAWS, draws_}, {B_JOINTS, joints_}, {B_FRAME, frame_}, {B_LIGHTS, lights_},
{B_CELLS, cells_}, {B_INDICES, indices_}, {B_VIEWS, views_},
{B_TEXTURES, textureTable_}, {B_SHADOW_TEXTURES, shadowTable_}};
for (auto [binding, buffer] : bindings) gl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, binding, buffer);
gl::BindBufferBase(GL_UNIFORM_BUFFER, 0, push_);
gl::BindVertexArray(vao_);
gl::Disable(GL_CULL_FACE);
gl::Disable(GL_BLEND);
gl::Enable(GL_DEPTH_TEST);
gl::DepthFunc(GL_LESS);
if (p.shadowSize != shadowMapSize_) { // r_shadowres changed
releaseTexture(sunShadow_);
gl::DeleteFramebuffers(1, &sunShadowFbo_);
sunShadow_ = depthTarget(p.shadowSize, sunShadowFbo_);
setHandles(kSunShadowSlot, sunShadow_, clampSampler_, true);
shadowMapSize_ = p.shadowSize;
}
shadowPasses(sunShadowFbo_, p.shadowSize, p.sunPasses); // always: clears it when there's no sun
if (!p.atlasPasses.empty()) shadowPasses(atlasFbo_, kAtlasSize, p.atlasPasses);
// ── Mirror: the scene from the reflected eye, cut at the mirror, with its own frame data ──
if (p.mirror) {
gpu::FrameData mf = p.mirrorFrame;
mf.gi = mf.giOrigin = glm::vec4(0.0f); // (no GI probes here)
upload(mirrorFrame_, &mf, sizeof mf);
gl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, B_FRAME, mirrorFrame_);
gl::BindFramebuffer(GL_FRAMEBUFFER, mirrorFbo_);
gl::Viewport(0, 0, GLsizei(p.width), GLsizei(p.height));
gl::DepthMask(GL_TRUE);
const GLfloat mclear[4] = {p.clearColor.r, p.clearColor.g, p.clearColor.b, 1.0f}, mone = 1.0f;
gl::ClearNamedFramebufferfv(mirrorFbo_, GL_COLOR, 0, mclear);
gl::ClearNamedFramebufferfv(mirrorFbo_, GL_DEPTH, 0, &mone);
gl::Enable(GL_SCISSOR_TEST); // only where the mirror is on screen
gl::Scissor(p.mirrorRect.x, p.mirrorRect.y, p.mirrorRect.z, p.mirrorRect.w);
drawMeshes(p.mirrorOpaque, p.mirrorViewProj, false, meshProgram_, skinnedProgram_);
if (p.frame.sky.x > 0.5f) {
gl::UseProgram(skyProgram_);
push(gpu::SkyPush{p.mirrorInvViewProj, p.frame.sunDir, p.frame.sunColor, p.frame.sky, glm::vec4(p.skyCloud, p.skyPhotoSunZ, p.frame.weather.z, p.frame.weather.w)});
gl::DepthFunc(GL_LEQUAL);
gl::DepthMask(GL_FALSE);
gl::DrawArrays(GL_TRIANGLES, 0, 3);
gl::DepthMask(GL_TRUE);
gl::DepthFunc(GL_LESS);
}
if (!p.mirrorTranslucent.empty()) {
gl::Enable(GL_BLEND);
gl::BlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
gl::DepthMask(GL_FALSE);
drawMeshes(p.mirrorTranslucent, p.mirrorViewProj, false, meshProgram_, skinnedProgram_);
gl::DepthMask(GL_TRUE);
gl::Disable(GL_BLEND);
}
gl::Disable(GL_SCISSOR_TEST);
gl::BindBufferBase(GL_SHADER_STORAGE_BUFFER, B_FRAME, frame_);
}
// ── Scene pass (HDR) ──
gl::BindFramebuffer(GL_FRAMEBUFFER, sceneFbo_);
gl::Viewport(0, 0, GLsizei(p.width), GLsizei(p.height));
gl::DepthMask(GL_TRUE);
const GLfloat clear[4] = {p.clearColor.r, p.clearColor.g, p.clearColor.b, 1.0f}, one = 1.0f;
const GLfloat noReflection[4] = {0.5f, 0.5f, 1.0f, 0.0f};
gl::ClearNamedFramebufferfv(sceneFbo_, GL_COLOR, 0, clear);
gl::ClearNamedFramebufferfv(sceneFbo_, GL_COLOR, 1, noReflection);
gl::ClearNamedFramebufferfv(sceneFbo_, GL_DEPTH, 0, &one);
// Only opaque meshes write the G-buffer (attachment 1).
auto gbufWrites = [](bool on) { gl::ColorMaski(1, on, on, on, on); };
gbufWrites(false);
gl::UseProgram(boxProgram_);
for (const gpu::Box& b : p.boxes) {
push(BoxPush{p.viewProj, b});
gl::DrawArrays(GL_TRIANGLES, 0, 36);
}
gbufWrites(true);
drawMeshes(p.opaque, p.viewProj, false, meshProgram_, skinnedProgram_);
gbufWrites(false);
if (p.frame.sky.x > 0.5f) { // where nothing was drawn
gl::UseProgram(skyProgram_);
push(gpu::SkyPush{p.invViewProj, p.frame.sunDir, p.frame.sunColor, p.frame.sky, glm::vec4(p.skyCloud, p.skyPhotoSunZ, p.frame.weather.z, p.frame.weather.w)});
gl::DepthFunc(GL_LEQUAL);
gl::DepthMask(GL_FALSE);
gl::DrawArrays(GL_TRIANGLES, 0, 3);
gl::DepthMask(GL_TRUE);
gl::DepthFunc(GL_LESS);
}
if (!p.translucent.empty()) { // back to front, blended, no depth writes
gl::Enable(GL_BLEND);
gl::BlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
gl::DepthMask(GL_FALSE);
drawMeshes(p.translucent, p.viewProj, false, meshProgram_, skinnedProgram_);
gl::DepthMask(GL_TRUE);
gl::Disable(GL_BLEND);
}
if (!p.sprites.empty() || !p.glowSprites.empty()) { // particles: depth-tested, not written; blended, then added on
gbufWrites(false);
gl::UseProgram(spriteProgram_);
gl::Enable(GL_BLEND);
gl::DepthMask(GL_FALSE);
gl::BlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
for (const gpu::Sprite& sp : p.sprites) push(SpritePush{p.viewProj, sp}), gl::DrawArrays(GL_TRIANGLES, 0, 6);
gl::BlendFuncSeparate(GL_SRC_ALPHA, GL_ONE, GL_ONE, GL_ONE);
for (const gpu::Sprite& sp : p.glowSprites) push(SpritePush{p.viewProj, sp}), gl::DrawArrays(GL_TRIANGLES, 0, 6);
gl::DepthMask(GL_TRUE);
gl::Disable(GL_BLEND);
gbufWrites(true);
}
gbufWrites(true);
// ── Occlusion grid: the depth (only opaque things write it) shrunk and read back ──
if (p.hzbTag) {
gl::BindFramebuffer(GL_FRAMEBUFFER, hzbFbo_);
gl::Viewport(0, 0, GLsizei(kHzbWidth), GLsizei(kHzbHeight));
gl::Disable(GL_DEPTH_TEST);
gl::UseProgram(hzbProgram_);
push(HzbPush{kDepthSlot, p.width, p.height, kHzbWidth, kHzbHeight, {}});
gl::DrawArrays(GL_TRIANGLES, 0, 3);
const int i = hzbNext_;
hzbNext_ = (hzbNext_ + 1) % kHzbRing;
collectHzb(i, true); // the CPU ran ahead (no vsync): take that grid before the slot is reused
gl::BindBuffer(GL_PIXEL_PACK_BUFFER, hzbPbo_[i]);
gl::ReadBuffer(GL_COLOR_ATTACHMENT0);
gl::ReadPixels(0, 0, GLsizei(kHzbWidth), GLsizei(kHzbHeight), GL_RED, GL_FLOAT, nullptr);
gl::BindBuffer(GL_PIXEL_PACK_BUFFER, 0);
hzbFence_[i] = gl::FenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
hzbTags_[i] = p.hzbTag;
gl::Enable(GL_DEPTH_TEST);
}
// ── Post: ambient occlusion, bloom ──
gl::Disable(GL_DEPTH_TEST);
auto pass = [&](const PostTarget& t, GLuint prog, const auto& data, bool additive) {
gl::BindFramebuffer(GL_FRAMEBUFFER, t.fbo);
gl::Viewport(0, 0, GLsizei(t.w), GLsizei(t.h));
gl::UseProgram(prog);
push(data);
if (additive) {
gl::Enable(GL_BLEND);
gl::BlendFuncSeparate(GL_ONE, GL_ONE, GL_ONE, GL_ONE);
}
gl::DrawArrays(GL_TRIANGLES, 0, 3);
if (additive) gl::Disable(GL_BLEND);
};
const bool ao = p.post.aoStrength > 0.0f, bloom = p.post.bloomStrength > 0.0f, reflections = p.post.ssr > 0.0f,
volumetric = p.frame.volumetric.x > 0.0f, // the sun and the dynamic lights
ssr = reflections || volumetric; // (the half-size target both go to)
if (reflections) pass(ssr_, ssrProgram_, SsrPush{kDepthSlot, kGbufSlot, kSceneSlot, p.width, p.height, ssr_.w, ssr_.h, p.post.ssr, p.post.proj}, false);
if (volumetric) // sunlight in the air, onto the same target
pass(ssr_, volumetricProgram_, VolumetricPush{kDepthSlot, p.width, p.height, ssr_.w, ssr_.h, {}, p.post.proj}, reflections);
TextureId resolved = kSceneSlot;
if (p.post.taa) {
taaCur_ ^= 1u;
pass(taa_[taaCur_], taaProgram_,
TaaPush{p.post.reproject, kSceneSlot, kTaaSlot + (taaCur_ ^ 1u), kDepthSlot, p.width, p.height, p.post.taaReset ? 1u : 0u, ssr ? kSsrSlot : 0u, 0}, false);
resolved = kTaaSlot + taaCur_;
}
if (ao) {
pass(ao_, ssaoProgram_, SsaoPush{kDepthSlot, p.width, p.height, ao_.w, ao_.h, p.post.aoRadius, p.post.aoStrength, 0, p.post.proj}, false);
pass(aoBlur_, ssaoBlurProgram_, SsaoBlurPush{kAoSlot, kDepthSlot, p.width, p.height, aoBlur_.w, aoBlur_.h, 0, 0, p.post.proj}, false);
}
if (bloom) {
for (uint32_t i = 0; i < kBloomLevels; ++i) {
const uint32_t sw = i == 0 ? p.width : bloom_[i - 1].w, sh = i == 0 ? p.height : bloom_[i - 1].h;
pass(bloom_[i], bloomDownProgram_, BloomPush{i == 0 ? resolved : kBloomSlot + i - 1, sw, sh, bloom_[i].w, bloom_[i].h, i == 0 ? 1u : 0u, {}}, false);
}
const float radius = 1.0f;
uint32_t radiusBits;
std::memcpy(&radiusBits, &radius, 4);
for (uint32_t i = kBloomLevels - 1; i > 0; --i)
pass(bloom_[i - 1], bloomUpProgram_, BloomPush{kBloomSlot + i, bloom_[i].w, bloom_[i].h, bloom_[i - 1].w, bloom_[i - 1].h, radiusBits, {}}, true);
}
if (p.post.adapt) { // eye adaptation from the smallest bloom level
adaptCur_ ^= 1u;
pass(adapt_[adaptCur_], adaptProgram_,
AdaptPush{kBloomSlot + kBloomLevels - 1, p.post.adaptReset || !adaptValid_ ? 0u : kAdaptSlot + (adaptCur_ ^ 1u), p.post.adaptDt, 1.5f,
p.post.adaptKey, 0.6f, 1.8f, 0},
false);
adaptValid_ = true;
}
// ── Tonemap to the window, then UI ──
gl::BindFramebuffer(GL_FRAMEBUFFER, 0);
gl::Viewport(0, 0, GLsizei(p.outWidth), GLsizei(p.outHeight));
gl::Disable(GL_DEPTH_TEST);
gl::UseProgram(tonemapProgram_);
push(TonemapPush{resolved, p.exposure, ao ? kAoBlurSlot : 0u, bloom ? kBloomSlot : 0u, p.post.bloomStrength, p.post.saturation, p.post.vignette, p.post.taa ? p.post.sharpen : 0.0f, kDepthSlot, {ssr && !p.post.taa ? kSsrSlot : 0u, p.post.adapt ? kAdaptSlot + adaptCur_ : 0u, 0}, p.post.shafts, p.post.sunColor});
gl::DrawArrays(GL_TRIANGLES, 0, 3);
gl::BindVertexArray(0);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
if (!p.screenshot.empty()) {
std::vector<uint8_t> rows(size_t(p.outWidth) * p.outHeight * 4), px(rows.size());
gl::ReadBuffer(GL_BACK);
gl::ReadPixels(0, 0, GLsizei(p.outWidth), GLsizei(p.outHeight), GL_RGBA, GL_UNSIGNED_BYTE, rows.data());
const size_t stride = size_t(p.outWidth) * 4; // the window's row 0 is its bottom
for (uint32_t y = 0; y < p.outHeight; ++y) std::memcpy(&px[y * stride], &rows[(p.outHeight - 1 - y) * stride], stride);
saveScreenshot(p.screenshot, std::move(px), p.outWidth, p.outHeight);
}
SDL_GL_SwapWindow(window_);
}
} // namespace
std::unique_ptr<RenderBackend> createOpenGLBackend() { return std::make_unique<OpenGLBackend>(); }
} // namespace keel

820
engine/rhi/gl/gl_legacy.cpp Normal file
View file

@ -0,0 +1,820 @@
// The fallback renderer for old GPUs: OpenGL 4.0 or 3.1, GLSL 1.40, nothing newer — bound
// textures, vertex attributes, one uniform block for the frame, a texture buffer for skinning.
// Two tiers, cut down from the main renderer:
// 4.0 lightmaps, sun with cascaded shadows (PCF), HDR sky, fog, normal maps, GGX highlights,
// sky reflections, the 16 nearest dynamic lights (no shadows)
// 3.1 lightmaps, sun with shadows (one tap), sky, fog; flat surfaces, no highlights or lights
// Neither has clustered lights, light shadows or occlusion culling. Depth is GL's -1..1 here
// (no glClipControl), so vertex shaders turn the 0..1 matrices' z into it.
#include "render/backend.h"
#include "core/log.h"
#include "platform/window.h"
#include "render/screenshot.h"
#include <SDL3/SDL.h>
#include <SDL3/SDL_opengl.h>
#include <glm/gtc/packing.hpp>
#include <glm/gtc/type_ptr.hpp>
#include <imgui.h>
#include <imgui_impl_opengl3.h>
#include <imgui_impl_sdl3.h>
#include <algorithm>
#include <cstddef>
#include <cstring>
#include <string>
#include <vector>
namespace keel {
namespace {
// Entry points from OpenGL 3.1 (no DSA, no sampler objects, no storage buffers).
#define KEEL_GLX_FUNCTIONS(X) \
X(const GLubyte*, GetString, (GLenum)) \
X(void, GetIntegerv, (GLenum, GLint*)) \
X(void, Enable, (GLenum)) \
X(void, Disable, (GLenum)) \
X(void, Viewport, (GLint, GLint, GLsizei, GLsizei)) \
X(void, Clear, (GLbitfield)) \
X(void, ClearColor, (GLfloat, GLfloat, GLfloat, GLfloat)) \
X(void, ClearDepth, (GLdouble)) \
X(void, DepthFunc, (GLenum)) \
X(void, DepthMask, (GLboolean)) \
X(void, BlendFuncSeparate, (GLenum, GLenum, GLenum, GLenum)) \
X(void, PolygonOffset, (GLfloat, GLfloat)) \
X(void, PixelStorei, (GLenum, GLint)) \
X(void, DrawBuffer, (GLenum)) \
X(void, ReadBuffer, (GLenum)) \
X(void, ReadPixels, (GLint, GLint, GLsizei, GLsizei, GLenum, GLenum, void*)) \
X(void, GenBuffers, (GLsizei, GLuint*)) \
X(void, DeleteBuffers, (GLsizei, const GLuint*)) \
X(void, BindBuffer, (GLenum, GLuint)) \
X(void, BindBufferBase, (GLenum, GLuint, GLuint)) \
X(void, BufferData, (GLenum, GLsizeiptr, const void*, GLenum)) \
X(void, BufferSubData, (GLenum, GLintptr, GLsizeiptr, const void*)) \
X(void, GenTextures, (GLsizei, GLuint*)) \
X(void, DeleteTextures, (GLsizei, const GLuint*)) \
X(void, BindTexture, (GLenum, GLuint)) \
X(void, ActiveTexture, (GLenum)) \
X(void, TexImage2D, (GLenum, GLint, GLint, GLsizei, GLsizei, GLint, GLenum, GLenum, const void*)) \
X(void, TexParameteri, (GLenum, GLenum, GLint)) \
X(void, TexBuffer, (GLenum, GLenum, GLuint)) \
X(void, GenFramebuffers, (GLsizei, GLuint*)) \
X(void, DeleteFramebuffers, (GLsizei, const GLuint*)) \
X(void, BindFramebuffer, (GLenum, GLuint)) \
X(void, FramebufferTexture2D, (GLenum, GLenum, GLenum, GLuint, GLint)) \
X(GLenum, CheckFramebufferStatus, (GLenum)) \
X(void, GenVertexArrays, (GLsizei, GLuint*)) \
X(void, DeleteVertexArrays, (GLsizei, const GLuint*)) \
X(void, BindVertexArray, (GLuint)) \
X(void, EnableVertexAttribArray, (GLuint)) \
X(void, VertexAttribPointer, (GLuint, GLint, GLenum, GLboolean, GLsizei, const void*)) \
X(void, VertexAttribIPointer, (GLuint, GLint, GLenum, GLsizei, const void*)) \
X(void, VertexAttribI4ui, (GLuint, GLuint, GLuint, GLuint, GLuint)) \
X(void, VertexAttrib4f, (GLuint, GLfloat, GLfloat, GLfloat, GLfloat)) \
X(void, DrawElements, (GLenum, GLsizei, GLenum, const void*)) \
X(void, DrawArrays, (GLenum, GLint, GLsizei)) \
X(GLuint, CreateShader, (GLenum)) \
X(void, ShaderSource, (GLuint, GLsizei, const GLchar* const*, const GLint*)) \
X(void, CompileShader, (GLuint)) \
X(void, GetShaderiv, (GLuint, GLenum, GLint*)) \
X(void, GetShaderInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \
X(void, DeleteShader, (GLuint)) \
X(GLuint, CreateProgram, (void)) \
X(void, AttachShader, (GLuint, GLuint)) \
X(void, BindAttribLocation, (GLuint, GLuint, const GLchar*)) \
X(void, BindFragDataLocation, (GLuint, GLuint, const GLchar*)) \
X(void, LinkProgram, (GLuint)) \
X(void, GetProgramiv, (GLuint, GLenum, GLint*)) \
X(void, GetProgramInfoLog, (GLuint, GLsizei, GLsizei*, GLchar*)) \
X(void, DeleteProgram, (GLuint)) \
X(void, UseProgram, (GLuint)) \
X(GLint, GetUniformLocation, (GLuint, const GLchar*)) \
X(GLuint, GetUniformBlockIndex, (GLuint, const GLchar*)) \
X(void, UniformBlockBinding, (GLuint, GLuint, GLuint)) \
X(void, Uniform1i, (GLint, GLint)) \
X(void, Uniform4fv, (GLint, GLsizei, const GLfloat*)) \
X(void, UniformMatrix4fv, (GLint, GLsizei, GLboolean, const GLfloat*))
namespace glx {
#define KEEL_GLX_DECLARE(ret, name, params) using name##_t = ret(APIENTRY*) params; name##_t name = nullptr;
KEEL_GLX_FUNCTIONS(KEEL_GLX_DECLARE)
#undef KEEL_GLX_DECLARE
bool load() {
std::string missing;
#define KEEL_GLX_LOAD(ret, name, params) \
name = reinterpret_cast<name##_t>(SDL_GL_GetProcAddress("gl" #name)); \
if (!name) missing += " gl" #name;
KEEL_GLX_FUNCTIONS(KEEL_GLX_LOAD)
#undef KEEL_GLX_LOAD
if (!missing.empty()) log::warn("OpenGL (legacy): missing functions:{}", missing);
return missing.empty();
}
} // namespace glx
// ── Shaders (GLSL 1.40; TIER is 1 for GL 3.1, 2 for 4.0) ──
const char* kFrameBlock = R"(
layout(std140) uniform Frame { // (GLSL 1.40: no instance name, so the members say F_)
mat4 F_cascades[4];
vec4 F_splits, F_cascadeTexel, F_sunDir, F_sunColor, F_shadow, F_camPos, F_sky, F_fog, F_lightCount;
vec4 F_lights[64]; // 16 × (pos radius, colour, spot dir cos-outer, cos-inner)
};
)";
const char* kMeshVert = R"(
in vec3 a_pos; in vec3 a_normal; in vec2 a_uv; in vec2 a_lmuv; in uvec4 a_joints; in vec4 a_weights;
uniform mat4 u_viewProj, u_model;
uniform int u_skinned, u_firstJoint;
uniform samplerBuffer u_joints;
out vec3 v_normal, v_world; out vec2 v_uv, v_lmuv;
mat4 joint(uint j) {
int b = (u_firstJoint + int(j)) * 4;
return mat4(texelFetch(u_joints, b), texelFetch(u_joints, b + 1), texelFetch(u_joints, b + 2), texelFetch(u_joints, b + 3));
}
void main() {
mat4 m = u_model;
if (u_skinned != 0)
m = joint(a_joints.x) * a_weights.x + joint(a_joints.y) * a_weights.y + joint(a_joints.z) * a_weights.z + joint(a_joints.w) * a_weights.w;
vec4 w = m * vec4(a_pos, 1.0);
v_world = w.xyz;
v_normal = mat3(m) * a_normal;
v_uv = a_uv;
v_lmuv = a_lmuv;
gl_Position = u_viewProj * w;
gl_Position.z = gl_Position.z * 2.0 - gl_Position.w; // 0..1 → GL's -1..1
}
)";
const char* kDepthFrag = R"(
out vec4 o_color;
void main() { o_color = vec4(1.0); }
)";
const char* kMeshFrag = R"(
in vec3 v_normal, v_world; in vec2 v_uv, v_lmuv;
uniform sampler2D u_albedo, u_lightmap, u_material, u_skyVis, u_sky;
uniform sampler2DShadow u_sunShadow;
uniform vec4 u_tint, u_params, u_plane, u_flags; // flags: x lightmap, y material, z sky visibility
uniform vec4 u_cube[6];
out vec4 o_color;
vec3 ambientCube(vec3 n) {
vec3 w = n * n;
return w.x * (n.x > 0.0 ? u_cube[0].rgb : u_cube[1].rgb) + w.y * (n.y > 0.0 ? u_cube[2].rgb : u_cube[3].rgb)
+ w.z * (n.z > 0.0 ? u_cube[4].rgb : u_cube[5].rgb);
}
float cascadeShadow(vec3 n, vec3 fwd) {
if (F_sunColor.w < 0.5) return 1.0;
float depth = dot(v_world - F_camPos.xyz, fwd);
if (depth > F_splits.w) return 1.0;
int c = depth < F_splits.x ? 0 : depth < F_splits.y ? 1 : depth < F_splits.z ? 2 : 3;
vec4 p = F_cascades[c] * vec4(v_world + n * F_cascadeTexel[c] * 1.5, 1.0);
vec2 uv = p.xy * 0.5 + 0.5;
if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0))) || p.z > 1.0) return 1.0;
vec2 base = vec2(c & 1, c >> 1) * 0.5;
vec2 at = base + uv * 0.5;
float t = F_shadow.x;
#if TIER >= 2
float lit = 0.0;
for (int y = 0; y < 2; ++y)
for (int x = 0; x < 2; ++x) lit += texture(u_sunShadow, vec3(clamp(at + (vec2(x, y) - 0.5) * t, base + t, base + 0.5 - t), p.z - 0.0002));
return lit * 0.25;
#else
return texture(u_sunShadow, vec3(at, p.z - 0.0003));
#endif
}
vec3 sky(vec3 dir, float level) {
float c = cos(F_sky.w), s = sin(F_sky.w);
dir = vec3(c * dir.x + s * dir.y, -s * dir.x + c * dir.y, dir.z);
vec4 m = textureLod(u_sky, vec2(atan(dir.y, dir.x) * 0.15915494 + 0.5, acos(clamp(dir.z, -1.0, 1.0)) * 0.31830989), level);
return m.rgb * m.a * 16.0 * F_sky.y;
}
#if TIER >= 2
mat3 cotangentFrame(vec3 n, vec3 p, vec2 uv) {
vec3 dp1 = dFdx(p), dp2 = dFdy(p);
vec2 duv1 = dFdx(uv), duv2 = dFdy(uv);
vec3 dp2perp = cross(dp2, n), dp1perp = cross(n, dp1);
vec3 t = dp2perp * duv1.x + dp1perp * duv2.x, b = dp2perp * duv1.y + dp1perp * duv2.y;
float invmax = inversesqrt(max(max(dot(t, t), dot(b, b)), 1e-20));
return mat3(t * invmax, b * invmax, n);
}
vec3 g_f0 = vec3(0.04); // reflectance at normal incidence: 0.04, or a metal's albedo
vec3 specGGX(vec3 n, vec3 l, vec3 v, float rough) {
vec3 h = normalize(l + v);
float a = max(rough * rough, 0.002), a2 = a * a;
float ndl = max(dot(n, l), 1e-4), ndv = max(dot(n, v), 1e-4), ndh = max(dot(n, h), 0.0), vdh = max(dot(v, h), 0.0);
float q = ndh * ndh * (a2 - 1.0) + 1.0;
float vis = 0.5 / (ndl * sqrt(ndv * ndv * (1.0 - a2) + a2) + ndv * sqrt(ndl * ndl * (1.0 - a2) + a2));
return min(a2 / (3.14159265 * q * q) * vis * (g_f0 + (1.0 - g_f0) * pow(1.0 - vdh, 5.0)) * 3.14159265, vec3(64.0));
}
vec2 envBRDF(float rough, float ndv) {
vec4 r = rough * vec4(-1.0, -0.0275, -0.572, 0.022) + vec4(1.0, 0.0425, 1.04, -0.04);
float a004 = min(r.x * r.x, exp2(-9.28 * ndv)) * r.x + r.y;
return vec2(-1.04, 1.04) * a004 + r.zw;
}
#endif
void main() {
vec4 albedo = texture(u_albedo, v_uv);
if (albedo.a < 0.5) discard;
if (u_params.z > 0.5 && dot(v_world, u_plane.xyz) > u_plane.w) discard; // liquid cut
vec3 base = albedo.rgb * u_tint.rgb;
vec3 n = normalize(v_normal), geo = n;
vec3 view = normalize(F_camPos.xyz - v_world);
vec3 fwd = F_lightCount.yzw; // the camera's forward (packed here)
float gloss = 0.15;
#if TIER >= 2
mat3 tbn = cotangentFrame(n, v_world, v_uv);
if (u_flags.y > 0.5) {
vec4 m = texture(u_material, v_uv);
vec2 xy = m.xy * 2.0 - 1.0; // Z rebuilt; B is metalness
n = normalize(tbn * vec3(xy, sqrt(max(1.0 - dot(xy, xy), 0.0))));
gloss = m.w;
g_f0 = mix(vec3(0.04), base, m.z);
base *= 1.0 - m.z;
}
#endif
vec3 sunDir = F_sunDir.xyz;
float facing = dot(geo, sunDir) > 0.0 ? max(dot(n, sunDir), 0.0) : 0.0;
vec3 sun = F_sunColor.rgb * F_sunColor.w * facing;
float vis = facing > 0.0 ? cascadeShadow(geo, fwd) : 0.0;
bool realtime = F_shadow.z > 0.5;
vec3 light, env = vec3(0.0);
float skyVis = 0.0;
if (u_flags.x > 0.5) {
vec4 rgbm = texture(u_lightmap, v_lmuv);
env = rgbm.rgb * rgbm.a * 8.0;
light = realtime ? env + sun * vis : env * (1.0 - F_shadow.y * (1.0 - vis) * facing * F_sunColor.w);
if (u_flags.z > 0.5) skyVis = texture(u_skyVis, v_lmuv).r;
} else if (u_params.y > 0.5) {
env = ambientCube(n);
light = env + sun * (realtime ? vis : u_params.x * vis);
skyVis = 0.5 * u_params.x;
} else {
light = vec3(0.3 + 0.9 * max(dot(n, normalize(vec3(0.4, 0.3, 0.85))), 0.0));
}
vec3 color = base * light;
#if TIER >= 2
float rough = clamp(1.0 - gloss, 0.05, 1.0);
color += F_sunColor.rgb * F_sunColor.w * facing * vis * specGGX(n, sunDir, view, rough);
for (int i = 0; i < int(F_lightCount.x); ++i) {
vec4 pr = F_lights[i * 4], lc = F_lights[i * 4 + 1], sp = F_lights[i * 4 + 2], ex = F_lights[i * 4 + 3];
vec3 toLight = pr.xyz - v_world;
float dist = length(toLight);
if (dist >= pr.w) continue;
vec3 L = toLight / max(dist, 0.001);
float ndl = dot(n, L);
if (ndl <= 0.0) continue;
float a = 1.0 - dist / pr.w, atten = a * a;
if (sp.w > -1.5) atten *= smoothstep(sp.w, ex.x, dot(-L, sp.xyz));
color += lc.rgb * atten * ndl * (base + specGGX(n, L, view, rough));
}
if (F_sky.x > 0.5) {
vec3 r = reflect(-view, n);
vec2 ab = envBRDF(rough, clamp(dot(n, view), 1e-4, 1.0));
color += mix(env, sky(r, rough * F_sky.z), skyVis * skyVis) * (g_f0 * ab.x + ab.y);
}
#endif
float alpha = u_tint.a;
if (alpha < 0.5) { // glass: mostly what's behind, a little reflection
float fresnel = pow(1.0 - clamp(abs(dot(n, view)), 0.0, 1.0), 4.0);
color = color * 0.12 + (F_sky.x > 0.5 ? sky(reflect(-view, n), 1.0) : vec3(0.45, 0.6, 0.8)) * fresnel * 0.8;
alpha = clamp(alpha * 0.6 + fresnel * 0.5, 0.0, 1.0);
}
if (F_sky.x > 0.5 && F_fog.x > 0.0) { // height fog coloured by the sky
vec3 ray = v_world - F_camPos.xyz;
float dist = length(ray);
vec3 rd = ray / max(dist, 1e-3);
float k = F_fog.y, kz = k * rd.z;
float amount = F_fog.x * exp(-k * (F_camPos.z - F_fog.w)) * (abs(kz) > 1e-6 ? (1.0 - exp(-kz * dist)) / kz : dist);
color = mix(color, sky(normalize(vec3(rd.xy, max(rd.z, 0.05))), F_sky.z) * 0.9, min(1.0 - exp(-amount), F_fog.z));
}
o_color = vec4(color, alpha);
}
)";
const char* kSkyVert = R"(
out vec2 v_ndc;
void main() {
vec2 uv = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
v_ndc = uv * 2.0 - 1.0;
gl_Position = vec4(v_ndc, 1.0, 1.0);
}
)";
const char* kSkyFrag = R"(
in vec2 v_ndc;
uniform mat4 u_invViewProj;
uniform sampler2D u_sky;
out vec4 o_color;
void main() {
vec4 far = u_invViewProj * vec4(v_ndc, 1.0, 1.0), near = u_invViewProj * vec4(v_ndc, 0.0, 1.0);
vec3 dir = normalize(far.xyz / far.w - near.xyz / near.w);
float c = cos(F_sky.w), s = sin(F_sky.w);
vec3 d = vec3(c * dir.x + s * dir.y, -s * dir.x + c * dir.y, dir.z);
vec4 m = textureLod(u_sky, vec2(atan(d.y, d.x) * 0.15915494 + 0.5, acos(clamp(d.z, -1.0, 1.0)) * 0.31830989), 0.0);
vec3 col = m.rgb * m.a * 16.0 * F_sky.y;
if (F_sunColor.w > 0.5) {
float k = dot(dir, F_sunDir.xyz);
col += F_sunColor.rgb * (smoothstep(0.99995, 0.99999, k) * 60.0 + pow(max(k, 0.0), 600.0) * 1.5 + pow(max(k, 0.0), 40.0) * 0.08);
}
o_color = vec4(col, 1.0);
}
)";
const char* kBoxVert = R"(
uniform mat4 u_viewProj;
uniform vec4 u_center, u_extents;
out vec3 v_normal;
const vec3 kNormals[6] = vec3[](vec3(1,0,0), vec3(-1,0,0), vec3(0,1,0), vec3(0,-1,0), vec3(0,0,1), vec3(0,0,-1));
const vec2 kQuad[6] = vec2[](vec2(-1,-1), vec2(1,-1), vec2(1,1), vec2(-1,-1), vec2(1,1), vec2(-1,1));
void main() {
vec3 n = kNormals[gl_VertexID / 6];
vec3 t = abs(n.z) > 0.5 ? vec3(1,0,0) : vec3(0,0,1);
vec2 q = kQuad[gl_VertexID % 6];
vec3 w = u_center.xyz + (n + t * q.x + cross(n, t) * q.y) * u_extents.xyz;
v_normal = n;
gl_Position = u_viewProj * vec4(w, 1.0);
gl_Position.z = gl_Position.z * 2.0 - gl_Position.w;
}
)";
const char* kBoxFrag = R"(
in vec3 v_normal;
uniform vec4 u_color;
out vec4 o_color;
void main() { o_color = vec4(u_color.rgb * (0.5 + 0.5 * max(dot(v_normal, normalize(vec3(0.4, 0.3, 0.85))), 0.0)), u_color.a); }
)";
const char* kTonemapVert = R"(
out vec2 v_uv;
void main() {
v_uv = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(v_uv * 2.0 - 1.0, 0.0, 1.0);
v_uv.y = 1.0 - v_uv.y; // the scene's row 0 is the view's top (as in the main renderer)
}
)";
const char* kTonemapFrag = R"(
in vec2 v_uv;
uniform sampler2D u_scene;
uniform vec4 u_exposure;
out vec4 o_color;
void main() {
vec3 x = texture(u_scene, v_uv).rgb * u_exposure.x;
x = clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);
o_color = vec4(mix(x * 12.92, 1.055 * pow(x, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, x)), 1.0);
}
)";
// What the Frame block holds (std140: matches the GLSL above).
struct FrameBlock {
glm::mat4 cascades[4];
glm::vec4 splits, cascadeTexel, sunDir, sunColor, shadow, camPos, sky, fog, lightCount;
glm::vec4 lights[64];
};
constexpr int kMaxLegacyLights = 16;
struct Program {
GLuint id = 0;
GLint viewProj = -1, model = -1, skinned = -1, firstJoint = -1, tint = -1, params = -1, plane = -1, flags = -1, cube = -1,
invViewProj = -1, center = -1, extents = -1, color = -1, exposure = -1;
};
class LegacyGLBackend final : public RenderBackend {
public:
explicit LegacyGLBackend(int major, int minor) : major_(major), minor_(minor), tier_(major >= 4 ? 2 : 1) {
name_ = "OpenGL " + std::to_string(major) + "." + std::to_string(minor) + " (fallback)";
}
const char* name() const override { return name_.c_str(); }
GpuInfo gpuInfo() const override { return gpu_; }
GpuInfo gpu_;
bool init(const Window& window, bool) override {
window_ = window.handle();
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, major_);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, minor_);
// 3.1 has no profiles; 4.0 asks for core.
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, major_ >= 4 ? SDL_GL_CONTEXT_PROFILE_CORE : 0);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, major_ >= 4 ? 0 : SDL_GL_CONTEXT_FORWARD_COMPATIBLE_FLAG);
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 0);
context_ = SDL_GL_CreateContext(window_);
if (!context_) {
log::warn("OpenGL {}.{} context: {}", major_, minor_, SDL_GetError());
return false;
}
SDL_GL_MakeCurrent(window_, context_);
if (!glx::load()) return false;
log::info("GPU: {} ({})", reinterpret_cast<const char*>(glx::GetString(GL_RENDERER)), reinterpret_cast<const char*>(glx::GetString(GL_VERSION)));
gpu_.name = reinterpret_cast<const char*>(glx::GetString(GL_RENDERER)), gpu_.driver = reinterpret_cast<const char*>(glx::GetString(GL_VERSION));
glx::PixelStorei(GL_UNPACK_ALIGNMENT, 1);
glx::PixelStorei(GL_PACK_ALIGNMENT, 1);
const std::string header = "#version 140\n#define TIER " + std::to_string(tier_) + "\n" + kFrameBlock;
mesh_ = link(header + kMeshVert, header + kMeshFrag);
depth_ = link(header + kMeshVert, header + kDepthFrag);
sky_ = link(header + kSkyVert, header + kSkyFrag);
box_ = link(header + kBoxVert, header + kBoxFrag);
tonemap_ = link(header + kTonemapVert, header + kTonemapFrag);
if (!mesh_.id || !depth_.id || !sky_.id || !box_.id || !tonemap_.id) return false;
glx::GenBuffers(1, &frameUbo_);
glx::BindBuffer(GL_UNIFORM_BUFFER, frameUbo_);
glx::BufferData(GL_UNIFORM_BUFFER, sizeof(FrameBlock), nullptr, GL_DYNAMIC_DRAW);
glx::GenBuffers(1, &jointBuffer_);
glx::BindBuffer(GL_TEXTURE_BUFFER, jointBuffer_);
glx::BufferData(GL_TEXTURE_BUFFER, kMaxJointsPerFrame * sizeof(glm::mat4), nullptr, GL_DYNAMIC_DRAW);
glx::GenTextures(1, &jointTexture_);
glx::BindTexture(GL_TEXTURE_BUFFER, jointTexture_);
glx::TexBuffer(GL_TEXTURE_BUFFER, GL_RGBA32F, jointBuffer_);
glx::GenVertexArrays(1, &emptyVao_);
// The sun's shadow map: 2×2 cascades, smaller on the 3.1 tier.
shadowSize_ = tier_ >= 2 ? kShadowSize : kShadowSize / 2;
glx::GenTextures(1, &shadowMap_);
glx::BindTexture(GL_TEXTURE_2D, shadowMap_);
glx::TexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT32F, GLsizei(shadowSize_), GLsizei(shadowSize_), 0, GL_DEPTH_COMPONENT, GL_FLOAT, nullptr);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_COMPARE_REF_TO_TEXTURE);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_FUNC, GL_LEQUAL);
glx::GenFramebuffers(1, &shadowFbo_);
glx::BindFramebuffer(GL_FRAMEBUFFER, shadowFbo_);
glx::FramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, shadowMap_, 0);
glx::DrawBuffer(GL_NONE);
glx::ReadBuffer(GL_NONE);
glx::BindFramebuffer(GL_FRAMEBUFFER, 0);
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGui::GetIO().IniFilename = nullptr;
ImGui::StyleColorsDark();
imgui_ = ImGui_ImplSDL3_InitForOpenGL(window_, context_) && ImGui_ImplOpenGL3_Init("#version 140");
log::info("Fallback renderer: tier {} ({})", tier_, tier_ >= 2 ? "shadows, normal maps, highlights, sky reflections, 16 lights" : "lightmaps, sun shadows, sky");
return imgui_;
}
void shutdown() override {
if (!context_) return;
if (imgui_) {
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplSDL3_Shutdown();
ImGui::DestroyContext();
imgui_ = false;
}
if (glx::DeleteTextures) {
for (GLuint t : textures_)
if (t) glx::DeleteTextures(1, &t);
for (Model& m : models_) releaseModel(m);
releaseTargets();
for (GLuint* t : {&shadowMap_, &jointTexture_}) glx::DeleteTextures(1, t);
glx::DeleteFramebuffers(1, &shadowFbo_);
for (GLuint* b : {&frameUbo_, &jointBuffer_}) glx::DeleteBuffers(1, b);
glx::DeleteVertexArrays(1, &emptyVao_);
for (Program* p : {&mesh_, &depth_, &sky_, &box_, &tonemap_}) glx::DeleteProgram(p->id);
}
SDL_GL_DestroyContext(context_);
context_ = nullptr;
}
void beginUi() override {
ImGui_ImplOpenGL3_NewFrame();
ImGui_ImplSDL3_NewFrame();
ImGui::NewFrame();
}
void setVsync(bool on) override { SDL_GL_SetSwapInterval(on ? 1 : 0); }
void onResize() override {}
// No block compression here (old GPUs): the renderer decodes BC7 to RGBA8 first.
void createTexture(TextureId slot, uint32_t w, uint32_t h, bool srgb, const std::vector<std::vector<uint8_t>>& mips,
TexFormat = TexFormat::RGBA8) override {
if (slot >= textures_.size()) textures_.resize(slot + 1, 0);
GLuint& t = textures_[slot];
if (!t) glx::GenTextures(1, &t);
glx::BindTexture(GL_TEXTURE_2D, t);
for (size_t l = 0; l < mips.size(); ++l)
glx::TexImage2D(GL_TEXTURE_2D, GLint(l), srgb ? GL_SRGB8_ALPHA8 : GL_RGBA8, GLsizei(std::max(1u, w >> l)), GLsizei(std::max(1u, h >> l)), 0, GL_RGBA,
GL_UNSIGNED_BYTE, mips[l].data());
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, GLint(mips.size()) - 1);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, mips.size() > 1 ? GL_LINEAR_MIPMAP_LINEAR : GL_LINEAR);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
}
void uploadModel(uint32_t index, const KmdlModel& kmdl) override {
if (index >= models_.size()) models_.resize(index + 1);
releaseModel(models_[index]);
Model& m = models_[index];
glx::GenVertexArrays(1, &m.vao);
glx::BindVertexArray(m.vao);
glx::GenBuffers(1, &m.vertices);
glx::BindBuffer(GL_ARRAY_BUFFER, m.vertices);
glx::BufferData(GL_ARRAY_BUFFER, GLsizeiptr(kmdl.vertices.size() * sizeof(KmdlVertex)), kmdl.vertices.data(), GL_STATIC_DRAW);
const GLsizei stride = sizeof(KmdlVertex);
const struct { GLuint loc; GLint n; size_t off; } attrs[] = {{0, 3, offsetof(KmdlVertex, position)}, {1, 3, offsetof(KmdlVertex, normal)},
{2, 2, offsetof(KmdlVertex, uv)}, {3, 2, offsetof(KmdlVertex, lightmapUv)}};
for (const auto& a : attrs) {
glx::EnableVertexAttribArray(a.loc);
glx::VertexAttribPointer(a.loc, a.n, GL_FLOAT, GL_FALSE, stride, reinterpret_cast<const void*>(a.off));
}
if (!kmdl.skin.empty()) {
glx::GenBuffers(1, &m.skin);
glx::BindBuffer(GL_ARRAY_BUFFER, m.skin);
glx::BufferData(GL_ARRAY_BUFFER, GLsizeiptr(kmdl.skin.size() * sizeof(KmdlSkin)), kmdl.skin.data(), GL_STATIC_DRAW);
glx::EnableVertexAttribArray(4);
glx::VertexAttribIPointer(4, 4, GL_UNSIGNED_BYTE, sizeof(KmdlSkin), reinterpret_cast<const void*>(offsetof(KmdlSkin, joints)));
glx::EnableVertexAttribArray(5);
glx::VertexAttribPointer(5, 4, GL_UNSIGNED_BYTE, GL_TRUE, sizeof(KmdlSkin), reinterpret_cast<const void*>(offsetof(KmdlSkin, weights)));
}
glx::GenBuffers(1, &m.indices);
glx::BindBuffer(GL_ELEMENT_ARRAY_BUFFER, m.indices);
glx::BufferData(GL_ELEMENT_ARRAY_BUFFER, GLsizeiptr(kmdl.indices.size() * 4), kmdl.indices.data(), GL_STATIC_DRAW);
glx::BindVertexArray(0);
m.skinned = !kmdl.skin.empty();
}
uint64_t uiTexture(TextureId slot) override { return slot < textures_.size() ? uint64_t(textures_[slot]) : 0; }
bool beginFrame(uint32_t& width, uint32_t& height) override {
int w = 0, h = 0;
if (SDL_GetWindowFlags(window_) & SDL_WINDOW_MINIMIZED) return false;
SDL_GetWindowSizeInPixels(window_, &w, &h);
if (w <= 0 || h <= 0) return false;
ensureTargets(uint32_t(w), uint32_t(h));
width = uint32_t(w);
height = uint32_t(h);
return true;
}
bool takeHzb(HzbReadback&) override { return false; } // no occlusion culling here
void render(FramePacket& p) override;
private:
struct Model {
GLuint vao = 0, vertices = 0, indices = 0, skin = 0;
bool skinned = false;
};
Program link(const std::string& vs, const std::string& fs) {
auto compile = [](GLenum type, const std::string& src) -> GLuint {
const GLuint s = glx::CreateShader(type);
const char* text = src.c_str();
glx::ShaderSource(s, 1, &text, nullptr);
glx::CompileShader(s);
GLint ok = 0;
glx::GetShaderiv(s, GL_COMPILE_STATUS, &ok);
if (!ok) {
char logText[4096];
glx::GetShaderInfoLog(s, sizeof logText, nullptr, logText);
log::error("OpenGL (legacy): shader failed to compile:\n{}", logText);
glx::DeleteShader(s);
return 0;
}
return s;
};
Program p;
const GLuint v = compile(GL_VERTEX_SHADER, vs), f = compile(GL_FRAGMENT_SHADER, fs);
if (!v || !f) return p;
p.id = glx::CreateProgram();
glx::AttachShader(p.id, v);
glx::AttachShader(p.id, f);
const char* attrs[] = {"a_pos", "a_normal", "a_uv", "a_lmuv", "a_joints", "a_weights"};
for (GLuint i = 0; i < 6; ++i) glx::BindAttribLocation(p.id, i, attrs[i]);
glx::BindFragDataLocation(p.id, 0, "o_color");
glx::LinkProgram(p.id);
glx::DeleteShader(v);
glx::DeleteShader(f);
GLint ok = 0;
glx::GetProgramiv(p.id, GL_LINK_STATUS, &ok);
if (!ok) {
char logText[4096];
glx::GetProgramInfoLog(p.id, sizeof logText, nullptr, logText);
log::error("OpenGL (legacy): program failed to link:\n{}", logText);
glx::DeleteProgram(p.id);
p.id = 0;
return p;
}
if (const GLuint block = glx::GetUniformBlockIndex(p.id, "Frame"); block != GL_INVALID_INDEX) glx::UniformBlockBinding(p.id, block, 0);
auto u = [&](const char* n) { return glx::GetUniformLocation(p.id, n); };
p.viewProj = u("u_viewProj"), p.model = u("u_model"), p.skinned = u("u_skinned"), p.firstJoint = u("u_firstJoint");
p.tint = u("u_tint"), p.params = u("u_params"), p.plane = u("u_plane"), p.flags = u("u_flags"), p.cube = u("u_cube");
p.invViewProj = u("u_invViewProj"), p.center = u("u_center"), p.extents = u("u_extents"), p.color = u("u_color"), p.exposure = u("u_exposure");
// Fixed texture units.
glx::UseProgram(p.id);
const struct { const char* name; GLint unit; } units[] = {{"u_albedo", 0}, {"u_lightmap", 1}, {"u_material", 2}, {"u_skyVis", 3},
{"u_sunShadow", 4}, {"u_sky", 5}, {"u_joints", 6}, {"u_scene", 0}};
for (const auto& t : units)
if (const GLint loc = u(t.name); loc >= 0) glx::Uniform1i(loc, t.unit);
glx::UseProgram(0);
return p;
}
void releaseModel(Model& m) {
if (m.vao) glx::DeleteVertexArrays(1, &m.vao);
for (GLuint* b : {&m.vertices, &m.indices, &m.skin})
if (*b) glx::DeleteBuffers(1, b);
m = {};
}
void releaseTargets() {
if (sceneTex_) glx::DeleteTextures(1, &sceneTex_);
if (sceneDepth_) glx::DeleteTextures(1, &sceneDepth_);
if (sceneFbo_) glx::DeleteFramebuffers(1, &sceneFbo_);
sceneTex_ = sceneDepth_ = sceneFbo_ = 0;
}
void ensureTargets(uint32_t w, uint32_t h) {
if (sceneFbo_ && w == targetW_ && h == targetH_) return;
releaseTargets();
glx::GenTextures(1, &sceneTex_);
glx::BindTexture(GL_TEXTURE_2D, sceneTex_);
glx::TexImage2D(GL_TEXTURE_2D, 0, GL_RGBA16F, GLsizei(w), GLsizei(h), 0, GL_RGBA, GL_FLOAT, nullptr);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glx::GenTextures(1, &sceneDepth_);
glx::BindTexture(GL_TEXTURE_2D, sceneDepth_);
glx::TexImage2D(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT24, GLsizei(w), GLsizei(h), 0, GL_DEPTH_COMPONENT, GL_UNSIGNED_INT, nullptr);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
glx::TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glx::GenFramebuffers(1, &sceneFbo_);
glx::BindFramebuffer(GL_FRAMEBUFFER, sceneFbo_);
glx::FramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, sceneTex_, 0);
glx::FramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, sceneDepth_, 0);
if (glx::CheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) log::error("OpenGL (legacy): scene framebuffer incomplete");
glx::BindFramebuffer(GL_FRAMEBUFFER, 0);
targetW_ = w, targetH_ = h;
}
void bindTexture(GLint unit, TextureId slot) {
glx::ActiveTexture(GL_TEXTURE0 + GLenum(unit));
glx::BindTexture(GL_TEXTURE_2D, slot < textures_.size() && textures_[slot] ? textures_[slot] : textures_[kWhiteTexture]);
}
void drawMeshes(const FramePacket& p, const std::vector<MeshDraw>& draws, const glm::mat4& viewProj, const Program& prog, bool shadow);
std::string name_;
int major_, minor_, tier_;
SDL_Window* window_ = nullptr;
SDL_GLContext context_ = nullptr;
bool imgui_ = false;
Program mesh_, depth_, sky_, box_, tonemap_;
std::vector<GLuint> textures_;
std::vector<Model> models_;
GLuint frameUbo_ = 0, jointBuffer_ = 0, jointTexture_ = 0, emptyVao_ = 0;
GLuint shadowMap_ = 0, shadowFbo_ = 0;
uint32_t shadowSize_ = kShadowSize;
GLuint sceneTex_ = 0, sceneDepth_ = 0, sceneFbo_ = 0;
uint32_t targetW_ = 0, targetH_ = 0;
};
void LegacyGLBackend::drawMeshes(const FramePacket& p, const std::vector<MeshDraw>& draws, const glm::mat4& viewProj, const Program& prog, bool shadow) {
glx::UseProgram(prog.id);
glx::UniformMatrix4fv(prog.viewProj, 1, GL_FALSE, glm::value_ptr(viewProj));
uint32_t boundModel = UINT32_MAX;
for (const MeshDraw& d : draws) {
if (d.model >= models_.size() || !models_[d.model].vao) continue;
const Model& m = models_[d.model];
if (d.model != boundModel) glx::BindVertexArray(m.vao), boundModel = d.model;
const gpu::DrawData& dd = p.draws[d.draw];
const bool skinned = d.firstJoint != UINT32_MAX && m.skinned;
if (!skinned) glx::VertexAttribI4ui(4, 0, 0, 0, 0), glx::VertexAttrib4f(5, 1, 0, 0, 0);
glx::UniformMatrix4fv(prog.model, 1, GL_FALSE, glm::value_ptr(dd.model));
glx::Uniform1i(prog.skinned, skinned ? 1 : 0);
glx::Uniform1i(prog.firstJoint, skinned ? GLint(d.firstJoint) : 0);
bindTexture(0, d.textures & 0xFFFFu);
if (!shadow) {
const TextureId lightmap = d.textures >> 16, material = d.material & 0xFFFFu, skyVis = d.material >> 16;
if (lightmap) bindTexture(1, lightmap);
if (material && tier_ >= 2) bindTexture(2, material);
if (skyVis) bindTexture(3, skyVis);
const glm::vec4 tint = glm::unpackUnorm4x8(d.tint);
glx::Uniform4fv(prog.tint, 1, glm::value_ptr(tint));
glx::Uniform4fv(prog.params, 1, glm::value_ptr(dd.params));
glx::Uniform4fv(prog.plane, 1, glm::value_ptr(dd.plane));
const glm::vec4 flags(lightmap ? 1.0f : 0.0f, material ? 1.0f : 0.0f, skyVis ? 1.0f : 0.0f, 0.0f);
glx::Uniform4fv(prog.flags, 1, glm::value_ptr(flags));
glx::Uniform4fv(prog.cube, 6, glm::value_ptr(dd.cube[0]));
}
glx::DrawElements(GL_TRIANGLES, GLsizei(d.indexCount), GL_UNSIGNED_INT, reinterpret_cast<const void*>(uintptr_t(d.firstIndex) * 4));
}
glx::BindVertexArray(0);
}
void LegacyGLBackend::render(FramePacket& p) {
// ── Per-frame data: the Frame block (the 16 nearest lights on tier 2), joints ──
FrameBlock fb{};
std::memcpy(fb.cascades, p.frame.cascades, sizeof fb.cascades);
fb.splits = p.frame.splits, fb.cascadeTexel = p.frame.cascadeTexel, fb.sunDir = p.frame.sunDir, fb.sunColor = p.frame.sunColor;
fb.shadow = glm::vec4(1.0f / float(shadowSize_), p.frame.shadow.y, p.frame.shadow.z, 0);
fb.camPos = p.frame.camPos, fb.sky = p.frame.sky, fb.fog = p.frame.fog;
std::vector<const gpu::Light*> closest;
if (tier_ >= 2) {
for (const gpu::Light& l : p.lights) closest.push_back(&l);
const glm::vec3 cam(p.frame.camPos);
std::sort(closest.begin(), closest.end(), [&](auto* a, auto* b) {
return glm::length(glm::vec3(a->posRadius) - cam) - a->posRadius.w < glm::length(glm::vec3(b->posRadius) - cam) - b->posRadius.w;
});
closest.resize(std::min<size_t>(closest.size(), kMaxLegacyLights));
}
fb.lightCount = glm::vec4(float(closest.size()), glm::vec3(p.frame.camFwd));
for (size_t i = 0; i < closest.size(); ++i)
fb.lights[i * 4] = closest[i]->posRadius, fb.lights[i * 4 + 1] = closest[i]->color, fb.lights[i * 4 + 2] = closest[i]->spot, fb.lights[i * 4 + 3] = closest[i]->extra;
glx::BindBuffer(GL_UNIFORM_BUFFER, frameUbo_);
glx::BufferSubData(GL_UNIFORM_BUFFER, 0, sizeof fb, &fb);
glx::BindBufferBase(GL_UNIFORM_BUFFER, 0, frameUbo_);
if (!p.joints.empty()) {
glx::BindBuffer(GL_TEXTURE_BUFFER, jointBuffer_);
glx::BufferSubData(GL_TEXTURE_BUFFER, 0, GLsizeiptr(std::min(p.joints.size(), size_t(kMaxJointsPerFrame)) * sizeof(glm::mat4)), p.joints.data());
}
glx::ActiveTexture(GL_TEXTURE6);
glx::BindTexture(GL_TEXTURE_BUFFER, jointTexture_);
glx::Disable(GL_CULL_FACE);
glx::Disable(GL_BLEND);
glx::Enable(GL_DEPTH_TEST);
glx::DepthFunc(GL_LESS);
glx::DepthMask(GL_TRUE);
// ── Sun shadows (light shadows aren't drawn on this renderer) ──
glx::BindFramebuffer(GL_FRAMEBUFFER, shadowFbo_);
glx::Viewport(0, 0, GLsizei(shadowSize_), GLsizei(shadowSize_));
glx::ClearDepth(1.0);
glx::Clear(GL_DEPTH_BUFFER_BIT);
glx::Enable(GL_POLYGON_OFFSET_FILL);
glx::PolygonOffset(2.5f, 2.0f);
const float scale = float(shadowSize_) / float(p.shadowSize);
for (const ShadowPass& pass : p.sunPasses) {
glx::Viewport(GLint(pass.rect.x * scale), GLint(pass.rect.y * scale), GLsizei(pass.rect.z * scale), GLsizei(pass.rect.w * scale));
drawMeshes(p, *pass.draws, pass.viewProj, depth_, true);
}
glx::Disable(GL_POLYGON_OFFSET_FILL);
// ── Scene (HDR) ──
glx::BindFramebuffer(GL_FRAMEBUFFER, sceneFbo_);
glx::Viewport(0, 0, GLsizei(p.width), GLsizei(p.height));
glx::ClearColor(p.clearColor.r, p.clearColor.g, p.clearColor.b, 1.0f);
glx::Clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glx::ActiveTexture(GL_TEXTURE4);
glx::BindTexture(GL_TEXTURE_2D, shadowMap_);
const TextureId skyTex = TextureId(p.frame.sky.x);
if (skyTex) bindTexture(5, skyTex);
if (!p.boxes.empty()) {
glx::UseProgram(box_.id);
glx::UniformMatrix4fv(box_.viewProj, 1, GL_FALSE, glm::value_ptr(p.viewProj));
glx::BindVertexArray(emptyVao_);
for (const gpu::Box& b : p.boxes) {
glx::Uniform4fv(box_.center, 1, glm::value_ptr(b.center));
glx::Uniform4fv(box_.extents, 1, glm::value_ptr(b.extents));
glx::Uniform4fv(box_.color, 1, glm::value_ptr(b.color));
glx::DrawArrays(GL_TRIANGLES, 0, 36);
}
}
drawMeshes(p, p.opaque, p.viewProj, mesh_, false);
if (skyTex) {
glx::UseProgram(sky_.id);
glx::UniformMatrix4fv(sky_.invViewProj, 1, GL_FALSE, glm::value_ptr(p.invViewProj));
glx::DepthFunc(GL_LEQUAL);
glx::DepthMask(GL_FALSE);
glx::BindVertexArray(emptyVao_);
glx::DrawArrays(GL_TRIANGLES, 0, 3);
glx::DepthMask(GL_TRUE);
glx::DepthFunc(GL_LESS);
}
if (!p.translucent.empty()) {
glx::Enable(GL_BLEND);
glx::BlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
glx::DepthMask(GL_FALSE);
drawMeshes(p, p.translucent, p.viewProj, mesh_, false);
glx::DepthMask(GL_TRUE);
glx::Disable(GL_BLEND);
}
// ── Tonemap to the window, then UI ──
glx::BindFramebuffer(GL_FRAMEBUFFER, 0);
glx::Viewport(0, 0, GLsizei(p.width), GLsizei(p.height));
glx::Disable(GL_DEPTH_TEST);
glx::UseProgram(tonemap_.id);
const glm::vec4 exposure(p.exposure, 0, 0, 0);
glx::Uniform4fv(tonemap_.exposure, 1, glm::value_ptr(exposure));
glx::ActiveTexture(GL_TEXTURE0);
glx::BindTexture(GL_TEXTURE_2D, sceneTex_);
glx::BindVertexArray(emptyVao_);
glx::DrawArrays(GL_TRIANGLES, 0, 3);
glx::BindVertexArray(0);
glx::UseProgram(0);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
if (!p.screenshot.empty()) {
std::vector<uint8_t> rows(size_t(p.width) * p.height * 4), px(rows.size());
glx::ReadBuffer(GL_BACK);
glx::ReadPixels(0, 0, GLsizei(p.width), GLsizei(p.height), GL_RGBA, GL_UNSIGNED_BYTE, rows.data());
const size_t stride = size_t(p.width) * 4; // the window's row 0 is its bottom
for (uint32_t y = 0; y < p.height; ++y) std::memcpy(&px[y * stride], &rows[(p.height - 1 - y) * stride], stride);
saveScreenshot(p.screenshot, std::move(px), p.width, p.height);
}
SDL_GL_SwapWindow(window_);
}
} // namespace
std::unique_ptr<RenderBackend> createLegacyOpenGLBackend(int major, int minor) { return std::make_unique<LegacyGLBackend>(major, minor); }
} // namespace keel

View file

@ -0,0 +1,21 @@
#include "rhi/gl/gl_loader.h"
#include "core/log.h"
#include <SDL3/SDL_video.h>
#include <string>
namespace keel::gl {
bool load() {
std::string missing;
#define KEEL_GL_LOAD(ret, name, params) \
name = reinterpret_cast<name##_t>(SDL_GL_GetProcAddress("gl" #name)); \
if (!name) missing += " gl" #name;
KEEL_GL_FUNCTIONS(KEEL_GL_LOAD)
#undef KEEL_GL_LOAD
if (!missing.empty()) log::warn("OpenGL: missing functions:{}", missing);
return missing.empty();
}
} // namespace keel::gl

87
engine/rhi/gl/gl_loader.h Normal file
View file

@ -0,0 +1,87 @@
#pragma once
// OpenGL 4.6 entry points, loaded through SDL at runtime (no GL library linked, no extra
// dependency): gl::Enable(...), gl::DrawElements(...). Types and constants come from SDL's
// copy of the Khronos headers. Add a function to KEEL_GL_FUNCTIONS to use it.
#include <SDL3/SDL_opengl.h>
#define KEEL_GL_FUNCTIONS(X) \
X(const GLubyte*, GetString, (GLenum name)) \
X(const GLubyte*, GetStringi, (GLenum name, GLuint index)) \
X(void, GetIntegerv, (GLenum pname, GLint* data)) \
X(GLenum, GetError, (void)) \
X(void, Enable, (GLenum cap)) \
X(void, Disable, (GLenum cap)) \
X(void, ClipControl, (GLenum origin, GLenum depth)) \
X(void, FrontFace, (GLenum mode)) \
X(void, Viewport, (GLint x, GLint y, GLsizei w, GLsizei h)) \
X(void, Scissor, (GLint x, GLint y, GLsizei w, GLsizei h)) \
X(void, DepthFunc, (GLenum func)) \
X(void, DepthMask, (GLboolean flag)) \
X(void, BlendFuncSeparate, (GLenum sc, GLenum dc, GLenum sa, GLenum da)) \
X(void, PolygonOffset, (GLfloat factor, GLfloat units)) \
X(void, PixelStorei, (GLenum pname, GLint param)) \
X(void, CreateBuffers, (GLsizei n, GLuint* buffers)) \
X(void, DeleteBuffers, (GLsizei n, const GLuint* buffers)) \
X(void, NamedBufferStorage, (GLuint buffer, GLsizeiptr size, const void* data, GLbitfield flags)) \
X(void, NamedBufferSubData, (GLuint buffer, GLintptr offset, GLsizeiptr size, const void* data)) \
X(void, BindBufferBase, (GLenum target, GLuint index, GLuint buffer)) \
X(void, CreateTextures, (GLenum target, GLsizei n, GLuint* textures)) \
X(void, DeleteTextures, (GLsizei n, const GLuint* textures)) \
X(void, TextureStorage2D, (GLuint texture, GLsizei levels, GLenum format, GLsizei w, GLsizei h)) \
X(void, TextureSubImage2D, (GLuint texture, GLint level, GLint x, GLint y, GLsizei w, GLsizei h, GLenum format, \
GLenum type, const void* pixels)) \
X(void, CompressedTextureSubImage2D, (GLuint texture, GLint level, GLint x, GLint y, GLsizei w, GLsizei h, \
GLenum format, GLsizei size, const void* data)) \
X(void, CreateSamplers, (GLsizei n, GLuint* samplers)) \
X(void, DeleteSamplers, (GLsizei n, const GLuint* samplers)) \
X(void, SamplerParameteri, (GLuint sampler, GLenum pname, GLint param)) \
X(void, SamplerParameterf, (GLuint sampler, GLenum pname, GLfloat param)) \
X(GLuint64, GetTextureSamplerHandleARB, (GLuint texture, GLuint sampler)) \
X(void, MakeTextureHandleResidentARB, (GLuint64 handle)) \
X(void, MakeTextureHandleNonResidentARB, (GLuint64 handle)) \
X(void, CreateFramebuffers, (GLsizei n, GLuint* framebuffers)) \
X(void, DeleteFramebuffers, (GLsizei n, const GLuint* framebuffers)) \
X(void, NamedFramebufferTexture, (GLuint fb, GLenum attachment, GLuint texture, GLint level)) \
X(void, NamedFramebufferDrawBuffer, (GLuint fb, GLenum buf)) \
X(GLenum, CheckNamedFramebufferStatus, (GLuint fb, GLenum target)) \
X(void, BindFramebuffer, (GLenum target, GLuint fb)) \
X(void, NamedFramebufferDrawBuffers, (GLuint fb, GLsizei n, const GLenum* bufs)) \
X(void, ColorMaski, (GLuint index, GLboolean r, GLboolean g, GLboolean b, GLboolean a)) \
X(void, ClearNamedFramebufferfv, (GLuint fb, GLenum buffer, GLint drawbuffer, const GLfloat* value)) \
X(GLuint, CreateShader, (GLenum type)) \
X(void, ShaderSource, (GLuint shader, GLsizei count, const GLchar* const* string, const GLint* length)) \
X(void, CompileShader, (GLuint shader)) \
X(void, GetShaderiv, (GLuint shader, GLenum pname, GLint* params)) \
X(void, GetShaderInfoLog, (GLuint shader, GLsizei max, GLsizei* length, GLchar* log)) \
X(void, DeleteShader, (GLuint shader)) \
X(GLuint, CreateProgram, (void)) \
X(void, AttachShader, (GLuint program, GLuint shader)) \
X(void, LinkProgram, (GLuint program)) \
X(void, GetProgramiv, (GLuint program, GLenum pname, GLint* params)) \
X(void, GetProgramInfoLog, (GLuint program, GLsizei max, GLsizei* length, GLchar* log)) \
X(void, DeleteProgram, (GLuint program)) \
X(void, UseProgram, (GLuint program)) \
X(void, CreateVertexArrays, (GLsizei n, GLuint* arrays)) \
X(void, DeleteVertexArrays, (GLsizei n, const GLuint* arrays)) \
X(void, BindVertexArray, (GLuint array)) \
X(void, VertexArrayElementBuffer, (GLuint vao, GLuint buffer)) \
X(void, DrawElements, (GLenum mode, GLsizei count, GLenum type, const void* indices)) \
X(void, DrawArrays, (GLenum mode, GLint first, GLsizei count)) \
X(void, ReadPixels, (GLint x, GLint y, GLsizei w, GLsizei h, GLenum format, GLenum type, void* pixels)) \
X(void, ReadBuffer, (GLenum src)) \
X(GLsync, FenceSync, (GLenum condition, GLbitfield flags)) \
X(GLenum, ClientWaitSync, (GLsync sync, GLbitfield flags, GLuint64 timeout)) \
X(void, DeleteSync, (GLsync sync)) \
X(void, BindBuffer, (GLenum target, GLuint buffer)) \
X(void*, MapNamedBufferRange, (GLuint buffer, GLintptr offset, GLsizeiptr length, GLbitfield access)) \
X(GLboolean, UnmapNamedBuffer, (GLuint buffer)) \
X(void, DebugMessageCallback, (GLDEBUGPROC callback, const void* user))
namespace keel::gl {
#define KEEL_GL_DECLARE(ret, name, params) using name##_t = ret(APIENTRY*) params; inline name##_t name = nullptr;
KEEL_GL_FUNCTIONS(KEEL_GL_DECLARE)
#undef KEEL_GL_DECLARE
// Loads every entry point from the current context; false (and logs which) if any is missing.
bool load();
} // namespace keel::gl

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,356 @@
#define VMA_IMPLEMENTATION
#define VMA_STATIC_VULKAN_FUNCTIONS 0
#define VMA_DYNAMIC_VULKAN_FUNCTIONS 1
#include "rhi/vulkan/vk_device.h"
#include "core/log.h"
#include "platform/window.h"
#include <SDL3/SDL_vulkan.h>
#include <VkBootstrap.h>
#include <cstdlib>
#include <cstring>
namespace keel::vk {
void check(VkResult r, const char* expr, const char* file, int line) {
if (r == VK_SUCCESS) return;
log::error("{} failed with VkResult {} ({}:{})", expr, int(r), file, line);
std::abort();
}
void transitionImage(VkCommandBuffer cmd, VkImage image, VkImageLayout from, VkImageLayout to, VkImageAspectFlags aspect) {
VkImageMemoryBarrier2 barrier{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER_2};
// Coarse but correct for now; per-pass barriers come with the render graph.
barrier.srcStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;
barrier.srcAccessMask = VK_ACCESS_2_MEMORY_WRITE_BIT;
barrier.dstStageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;
barrier.dstAccessMask = VK_ACCESS_2_MEMORY_WRITE_BIT | VK_ACCESS_2_MEMORY_READ_BIT;
barrier.oldLayout = from;
barrier.newLayout = to;
barrier.image = image;
barrier.subresourceRange = {aspect, 0, VK_REMAINING_MIP_LEVELS, 0, VK_REMAINING_ARRAY_LAYERS};
VkDependencyInfo dep{VK_STRUCTURE_TYPE_DEPENDENCY_INFO};
dep.imageMemoryBarrierCount = 1;
dep.pImageMemoryBarriers = &barrier;
vkCmdPipelineBarrier2(cmd, &dep);
}
static VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback(VkDebugUtilsMessageSeverityFlagBitsEXT severity,
VkDebugUtilsMessageTypeFlagsEXT,
const VkDebugUtilsMessengerCallbackDataEXT* data, void*) {
if (severity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT) log::error("[vulkan] {}", data->pMessage);
else if (severity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT) log::warn("[vulkan] {}", data->pMessage);
return VK_FALSE;
}
bool Device::init(const Window& window, bool validation) {
window_ = &window;
if (volkInitialize() != VK_SUCCESS) {
log::error("No Vulkan loader found - is a Vulkan driver installed?");
return false;
}
Uint32 extCount = 0;
const char* const* sdlExts = SDL_Vulkan_GetInstanceExtensions(&extCount);
vkb::InstanceBuilder ib(vkGetInstanceProcAddr);
ib.set_app_name("Fry3DX").require_api_version(1, 3, 0).enable_extensions(extCount, sdlExts);
if (validation) ib.request_validation_layers().set_debug_callback(debugCallback);
auto inst = ib.build();
if (!inst) {
log::error("Vulkan instance: {}", inst.error().message());
return false;
}
instance_ = inst->instance;
messenger_ = inst->debug_messenger;
volkLoadInstance(instance_);
if (!SDL_Vulkan_CreateSurface(window.handle(), instance_, nullptr, &surface_)) {
log::error("SDL_Vulkan_CreateSurface: {}", SDL_GetError());
return false;
}
VkPhysicalDeviceVulkan13Features f13{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES};
f13.dynamicRendering = VK_TRUE;
f13.synchronization2 = VK_TRUE;
f13.shaderDemoteToHelperInvocation = VK_TRUE; // `discard` compiles to demote in Vulkan 1.3 SPIR-V
VkPhysicalDeviceVulkan12Features f12{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES};
f12.bufferDeviceAddress = VK_TRUE;
f12.descriptorIndexing = VK_TRUE;
f12.runtimeDescriptorArray = VK_TRUE;
f12.descriptorBindingPartiallyBound = VK_TRUE;
f12.descriptorBindingSampledImageUpdateAfterBind = VK_TRUE;
f12.shaderSampledImageArrayNonUniformIndexing = VK_TRUE;
VkPhysicalDeviceFeatures f10{};
f10.samplerAnisotropy = VK_TRUE;
f10.independentBlend = VK_TRUE; // (the G-buffer pass blends its attachments differently)
auto phys = vkb::PhysicalDeviceSelector(*inst)
.set_surface(surface_)
.set_minimum_version(1, 3)
.set_required_features_13(f13)
.set_required_features_12(f12)
.set_required_features(f10)
.prefer_gpu_device_type(vkb::PreferredDeviceType::discrete)
.select();
if (!phys) {
log::error("No suitable GPU (needs Vulkan 1.3): {}", phys.error().message());
return false;
}
vkb::PhysicalDevice pd = phys.value();
physical_ = pd.physical_device;
log::info("GPU: {}", pd.properties.deviceName);
// Ray queries where the GPU has them (not needed: what uses them is skipped without).
VkPhysicalDeviceAccelerationStructureFeaturesKHR asf{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ACCELERATION_STRUCTURE_FEATURES_KHR};
asf.accelerationStructure = VK_TRUE;
VkPhysicalDeviceRayQueryFeaturesKHR rqf{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_QUERY_FEATURES_KHR};
rqf.rayQuery = VK_TRUE;
const char* rt[] = {VK_KHR_ACCELERATION_STRUCTURE_EXTENSION_NAME, VK_KHR_RAY_QUERY_EXTENSION_NAME, VK_KHR_DEFERRED_HOST_OPERATIONS_EXTENSION_NAME};
rayQuery_ = pd.enable_extensions_if_present(3, rt) && pd.enable_extension_features_if_present(asf) && pd.enable_extension_features_if_present(rqf);
log::info("Ray queries: {}", rayQuery_ ? "yes" : "no");
memoryBudget_ = pd.enable_extension_if_present(VK_EXT_MEMORY_BUDGET_EXTENSION_NAME); // (real VRAM use, for the debug overlay)
gpuName_ = pd.properties.deviceName;
VkPhysicalDeviceDriverProperties drv{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRIVER_PROPERTIES};
VkPhysicalDeviceProperties2 props2{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, &drv};
vkGetPhysicalDeviceProperties2(pd.physical_device, &props2);
gpuDriver_ = std::string(drv.driverName) + (drv.driverInfo[0] ? std::string(" ") + drv.driverInfo : std::string());
auto dev = vkb::DeviceBuilder(pd).build();
if (!dev) {
log::error("Vulkan device: {}", dev.error().message());
return false;
}
device_ = dev->device;
volkLoadDevice(device_);
queue_ = dev->get_queue(vkb::QueueType::graphics).value();
queueFamily_ = dev->get_queue_index(vkb::QueueType::graphics).value();
VmaVulkanFunctions fns{};
fns.vkGetInstanceProcAddr = vkGetInstanceProcAddr;
fns.vkGetDeviceProcAddr = vkGetDeviceProcAddr;
VmaAllocatorCreateInfo aci{};
aci.flags = VMA_ALLOCATOR_CREATE_BUFFER_DEVICE_ADDRESS_BIT | (memoryBudget_ ? VMA_ALLOCATOR_CREATE_EXT_MEMORY_BUDGET_BIT : 0);
aci.vulkanApiVersion = VK_API_VERSION_1_3;
aci.instance = instance_;
aci.physicalDevice = physical_;
aci.device = device_;
aci.pVulkanFunctions = &fns;
VK_CHECK(vmaCreateAllocator(&aci, &allocator_));
for (Frame& f : frames_) {
VkCommandPoolCreateInfo pci{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};
pci.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
pci.queueFamilyIndex = queueFamily_;
VK_CHECK(vkCreateCommandPool(device_, &pci, nullptr, &f.pool));
VkCommandBufferAllocateInfo ai{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
ai.commandPool = f.pool;
ai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
ai.commandBufferCount = 1;
VK_CHECK(vkAllocateCommandBuffers(device_, &ai, &f.cmd));
VkSemaphoreCreateInfo sci{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO};
VK_CHECK(vkCreateSemaphore(device_, &sci, nullptr, &f.acquired));
VkFenceCreateInfo fci{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
fci.flags = VK_FENCE_CREATE_SIGNALED_BIT;
VK_CHECK(vkCreateFence(device_, &fci, nullptr, &f.inFlight));
}
VkCommandPoolCreateInfo pci{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};
pci.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
pci.queueFamilyIndex = queueFamily_;
VK_CHECK(vkCreateCommandPool(device_, &pci, nullptr, &immPool_));
VkCommandBufferAllocateInfo ai{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
ai.commandPool = immPool_;
ai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
ai.commandBufferCount = 1;
VK_CHECK(vkAllocateCommandBuffers(device_, &ai, &immCmd_));
VkFenceCreateInfo fci{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
VK_CHECK(vkCreateFence(device_, &fci, nullptr, &immFence_));
return createSwapchain();
}
void Device::immediateSubmit(const std::function<void(VkCommandBuffer)>& record) {
VK_CHECK(vkResetCommandBuffer(immCmd_, 0));
VkCommandBufferBeginInfo bi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
VK_CHECK(vkBeginCommandBuffer(immCmd_, &bi));
record(immCmd_);
VK_CHECK(vkEndCommandBuffer(immCmd_));
VkCommandBufferSubmitInfo cbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO};
cbi.commandBuffer = immCmd_;
VkSubmitInfo2 si{VK_STRUCTURE_TYPE_SUBMIT_INFO_2};
si.commandBufferInfoCount = 1;
si.pCommandBufferInfos = &cbi;
VK_CHECK(vkQueueSubmit2(queue_, 1, &si, immFence_));
VK_CHECK(vkWaitForFences(device_, 1, &immFence_, VK_TRUE, UINT64_MAX));
VK_CHECK(vkResetFences(device_, 1, &immFence_));
}
Buffer Device::createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, bool hostVisible, bool readback) {
VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
bci.size = size;
bci.usage = usage | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT;
VmaAllocationCreateInfo aci{};
aci.usage = VMA_MEMORY_USAGE_AUTO;
if (hostVisible)
aci.flags = (readback ? VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT : VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT) | VMA_ALLOCATION_CREATE_MAPPED_BIT;
Buffer b;
VmaAllocationInfo info{};
VK_CHECK(vmaCreateBuffer(allocator_, &bci, &aci, &b.buffer, &b.alloc, &info));
b.mapped = info.pMappedData;
VkBufferDeviceAddressInfo dai{VK_STRUCTURE_TYPE_BUFFER_DEVICE_ADDRESS_INFO};
dai.buffer = b.buffer;
b.address = vkGetBufferDeviceAddress(device_, &dai);
return b;
}
void Device::destroyBuffer(Buffer& b) {
if (b.buffer) vmaDestroyBuffer(allocator_, b.buffer, b.alloc);
b = {};
}
Buffer Device::uploadBuffer(const void* data, VkDeviceSize size, VkBufferUsageFlags usage) {
Buffer staging = createBuffer(size, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, true);
std::memcpy(staging.mapped, data, size);
Buffer b = createBuffer(size, usage | VK_BUFFER_USAGE_TRANSFER_DST_BIT, false);
immediateSubmit([&](VkCommandBuffer cmd) {
VkBufferCopy copy{0, 0, size};
vkCmdCopyBuffer(cmd, staging.buffer, b.buffer, 1, &copy);
});
destroyBuffer(staging);
return b;
}
bool Device::createSwapchain() {
uint32_t w, h;
window_->pixelSize(w, h);
if (w == 0 || h == 0) return false;
vkDeviceWaitIdle(device_);
VkSwapchainKHR old = swapchain_;
auto sc = vkb::SwapchainBuilder(physical_, device_, surface_)
.set_desired_format({VK_FORMAT_B8G8R8A8_UNORM, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR})
.set_desired_present_mode(vsync_ ? VK_PRESENT_MODE_FIFO_KHR : VK_PRESENT_MODE_MAILBOX_KHR)
.add_fallback_present_mode(vsync_ ? VK_PRESENT_MODE_FIFO_KHR : VK_PRESENT_MODE_IMMEDIATE_KHR)
.set_desired_extent(w, h)
.add_image_usage_flags(VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT)
.set_old_swapchain(old)
.build();
if (!sc) {
log::error("Swapchain: {}", sc.error().message());
return false;
}
destroySwapchainViews();
if (old) vkDestroySwapchainKHR(device_, old, nullptr);
swapchain_ = sc->swapchain;
swapchainFormat_ = sc->image_format;
extent_ = sc->extent;
images_ = sc->get_images().value();
views_ = sc->get_image_views().value();
renderDone_.resize(images_.size());
for (VkSemaphore& s : renderDone_) {
VkSemaphoreCreateInfo sci{VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO};
VK_CHECK(vkCreateSemaphore(device_, &sci, nullptr, &s));
}
swapchainDirty_ = false;
return true;
}
void Device::destroySwapchainViews() {
for (VkImageView v : views_) vkDestroyImageView(device_, v, nullptr);
for (VkSemaphore s : renderDone_) vkDestroySemaphore(device_, s, nullptr);
views_.clear();
renderDone_.clear();
}
VkCommandBuffer Device::beginFrame() {
if (swapchainDirty_ && !createSwapchain()) return nullptr;
Frame& f = frames_[frameIndex_];
VK_CHECK(vkWaitForFences(device_, 1, &f.inFlight, VK_TRUE, UINT64_MAX));
VkResult r = vkAcquireNextImageKHR(device_, swapchain_, UINT64_MAX, f.acquired, VK_NULL_HANDLE, &imageIndex_);
if (r == VK_ERROR_OUT_OF_DATE_KHR) {
swapchainDirty_ = true;
return nullptr;
}
if (r != VK_SUBOPTIMAL_KHR) VK_CHECK(r);
VK_CHECK(vkResetFences(device_, 1, &f.inFlight));
VK_CHECK(vkResetCommandBuffer(f.cmd, 0));
VkCommandBufferBeginInfo bi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
VK_CHECK(vkBeginCommandBuffer(f.cmd, &bi));
transitionImage(f.cmd, images_[imageIndex_], VK_IMAGE_LAYOUT_UNDEFINED, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL);
return f.cmd;
}
void Device::endFrame() {
Frame& f = frames_[frameIndex_];
transitionImage(f.cmd, images_[imageIndex_], VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR);
VK_CHECK(vkEndCommandBuffer(f.cmd));
VkSemaphoreSubmitInfo wait{VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO};
wait.semaphore = f.acquired;
wait.stageMask = VK_PIPELINE_STAGE_2_COLOR_ATTACHMENT_OUTPUT_BIT;
VkSemaphoreSubmitInfo signal{VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO};
signal.semaphore = renderDone_[imageIndex_];
signal.stageMask = VK_PIPELINE_STAGE_2_ALL_COMMANDS_BIT;
VkCommandBufferSubmitInfo cbi{VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO};
cbi.commandBuffer = f.cmd;
VkSubmitInfo2 si{VK_STRUCTURE_TYPE_SUBMIT_INFO_2};
si.waitSemaphoreInfoCount = 1;
si.pWaitSemaphoreInfos = &wait;
si.commandBufferInfoCount = 1;
si.pCommandBufferInfos = &cbi;
si.signalSemaphoreInfoCount = 1;
si.pSignalSemaphoreInfos = &signal;
VK_CHECK(vkQueueSubmit2(queue_, 1, &si, f.inFlight));
VkPresentInfoKHR pi{VK_STRUCTURE_TYPE_PRESENT_INFO_KHR};
pi.waitSemaphoreCount = 1;
pi.pWaitSemaphores = &renderDone_[imageIndex_];
pi.swapchainCount = 1;
pi.pSwapchains = &swapchain_;
pi.pImageIndices = &imageIndex_;
VkResult r = vkQueuePresentKHR(queue_, &pi);
if (r == VK_ERROR_OUT_OF_DATE_KHR || r == VK_SUBOPTIMAL_KHR) swapchainDirty_ = true;
else VK_CHECK(r);
frameIndex_ = (frameIndex_ + 1) % kFramesInFlight;
}
void Device::shutdown() {
if (!device_) return;
vkDeviceWaitIdle(device_);
for (Frame& f : frames_) {
vkDestroyCommandPool(device_, f.pool, nullptr);
vkDestroySemaphore(device_, f.acquired, nullptr);
vkDestroyFence(device_, f.inFlight, nullptr);
}
vkDestroyCommandPool(device_, immPool_, nullptr);
vkDestroyFence(device_, immFence_, nullptr);
destroySwapchainViews();
vkDestroySwapchainKHR(device_, swapchain_, nullptr);
vmaDestroyAllocator(allocator_);
vkDestroyDevice(device_, nullptr);
vkDestroySurfaceKHR(instance_, surface_, nullptr);
if (messenger_) vkDestroyDebugUtilsMessengerEXT(instance_, messenger_, nullptr);
vkDestroyInstance(instance_, nullptr);
device_ = VK_NULL_HANDLE;
}
void Device::vram(uint64_t& total, uint64_t& used, uint64_t& ours) const {
total = used = ours = 0;
if (!allocator_) return;
const VkPhysicalDeviceMemoryProperties* mem = nullptr;
vmaGetMemoryProperties(allocator_, &mem);
VmaBudget budgets[VK_MAX_MEMORY_HEAPS];
vmaGetHeapBudgets(allocator_, budgets);
for (uint32_t h = 0; h < mem->memoryHeapCount; ++h) {
if (!(mem->memoryHeaps[h].flags & VK_MEMORY_HEAP_DEVICE_LOCAL_BIT)) continue;
total += mem->memoryHeaps[h].size;
used += budgets[h].usage; // (the whole process's, with VK_EXT_memory_budget; VMA's own blocks without)
ours += budgets[h].statistics.blockBytes;
}
}
} // namespace keel::vk

View file

@ -0,0 +1,115 @@
#pragma once
// Vulkan 1.3 device, swapchain and frames in flight.
// Uses dynamic rendering and synchronization2; no render passes.
#include <volk.h>
#include <vk_mem_alloc.h>
#include <functional>
#include <string>
#include <vector>
namespace keel {
class Window;
}
namespace keel::vk {
#define VK_CHECK(x) ::keel::vk::check((x), #x, __FILE__, __LINE__)
void check(VkResult r, const char* expr, const char* file, int line);
void transitionImage(VkCommandBuffer cmd, VkImage image, VkImageLayout from, VkImageLayout to,
VkImageAspectFlags aspect = VK_IMAGE_ASPECT_COLOR_BIT);
struct Buffer {
VkBuffer buffer = VK_NULL_HANDLE;
VmaAllocation alloc = VK_NULL_HANDLE;
VkDeviceAddress address = 0;
void* mapped = nullptr; // host-visible buffers only
};
class Device {
public:
static constexpr uint32_t kFramesInFlight = 2;
bool init(const Window& window, bool validation);
void shutdown();
// Returns a command buffer recording into the current swapchain image (already in
// COLOR_ATTACHMENT_OPTIMAL), or nullptr when no frame can be drawn (minimized, resizing).
VkCommandBuffer beginFrame();
void endFrame();
// Records and runs commands right away, waiting for completion. For loading, not per frame.
void immediateSubmit(const std::function<void(VkCommandBuffer)>& record);
// hostVisible: mapped CPU-writable memory (staging); otherwise device-local.
// readback: host-cached memory the CPU reads from (screenshots); otherwise write-combined.
Buffer createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, bool hostVisible, bool readback = false);
void destroyBuffer(Buffer& b);
// Device-local buffer filled with `data` through a staging copy.
Buffer uploadBuffer(const void* data, VkDeviceSize size, VkBufferUsageFlags usage);
void setVsync(bool vsync) { vsync_ = vsync; swapchainDirty_ = true; }
void markResized() { swapchainDirty_ = true; }
void waitIdle() { vkDeviceWaitIdle(device_); }
VkInstance instance() const { return instance_; }
VkPhysicalDevice physicalDevice() const { return physical_; }
// Hardware ray queries (VK_KHR_ray_query + acceleration structures): real-time GI uses them.
bool rayQuery() const { return rayQuery_; }
VkDevice device() const { return device_; }
VkQueue queue() const { return queue_; }
uint32_t queueFamily() const { return queueFamily_; }
VmaAllocator allocator() const { return allocator_; }
const std::string& gpuName() const { return gpuName_; }
const std::string& gpuDriver() const { return gpuDriver_; }
// VRAM (the device-local heaps): total, used by everyone (ours if the driver can't say), ours.
void vram(uint64_t& total, uint64_t& used, uint64_t& ours) const;
VkFormat swapchainFormat() const { return swapchainFormat_; }
VkExtent2D extent() const { return extent_; }
uint32_t frameIndex() const { return frameIndex_; } // which frame-in-flight slot is recording
uint32_t imageCount() const { return uint32_t(images_.size()); }
VkImage currentImage() const { return images_[imageIndex_]; }
VkImageView currentImageView() const { return views_[imageIndex_]; }
private:
struct Frame {
VkCommandPool pool = VK_NULL_HANDLE;
VkCommandBuffer cmd = VK_NULL_HANDLE;
VkSemaphore acquired = VK_NULL_HANDLE;
VkFence inFlight = VK_NULL_HANDLE;
};
bool createSwapchain();
void destroySwapchainViews();
const Window* window_ = nullptr;
VkInstance instance_ = VK_NULL_HANDLE;
VkDebugUtilsMessengerEXT messenger_ = VK_NULL_HANDLE;
VkSurfaceKHR surface_ = VK_NULL_HANDLE;
VkPhysicalDevice physical_ = VK_NULL_HANDLE;
bool rayQuery_ = false, memoryBudget_ = false;
std::string gpuName_, gpuDriver_;
VkDevice device_ = VK_NULL_HANDLE;
VkQueue queue_ = VK_NULL_HANDLE;
uint32_t queueFamily_ = 0;
VmaAllocator allocator_ = VK_NULL_HANDLE;
VkSwapchainKHR swapchain_ = VK_NULL_HANDLE;
VkFormat swapchainFormat_ = VK_FORMAT_B8G8R8A8_UNORM;
VkExtent2D extent_{};
std::vector<VkImage> images_;
std::vector<VkImageView> views_;
std::vector<VkSemaphore> renderDone_; // per swapchain image, so present never reuses one in flight
bool vsync_ = true;
bool swapchainDirty_ = false;
Frame frames_[kFramesInFlight];
VkCommandPool immPool_ = VK_NULL_HANDLE;
VkCommandBuffer immCmd_ = VK_NULL_HANDLE;
VkFence immFence_ = VK_NULL_HANDLE;
uint32_t frameIndex_ = 0;
uint32_t imageIndex_ = 0;
};
} // namespace keel::vk

32
engine/shaders/adapt.frag Normal file
View file

@ -0,0 +1,32 @@
#version 460
// Eye adaptation (1×1): the scene's average brightness (geometric mean of 16 taps of the
// smallest bloom level), turned into an exposure that keeps a mid grey where it should be,
// eased towards (0.6x to 1.8x, so nothing burns out) from last frame's at `speed` per second (faster into the dark is slower,
// as eyes are). The tonemap multiplies its exposure by it.
#define KEEL_POST // screen-space: render targets, sampled clamped
#include "backend.glsl"
PUSH_BLOCK {
uint src; // the smallest bloom level
uint prev; // last frame's (0 = start fresh)
float dt;
float speed;
float key; // the brightness to aim for
float minE, maxE;
float pad;
} pc;
layout(location = 0) out vec4 o_exposure;
void main() {
float sum = 0.0;
for (int y = 0; y < 4; ++y)
for (int x = 0; x < 4; ++x) {
vec3 c = sampleTexLod(pc.src, (vec2(x, y) + 0.5) / 4.0, 0.0).rgb;
sum += log(max(dot(c, vec3(0.2126, 0.7152, 0.0722)), 1e-4));
}
float average = exp(sum / 16.0);
float target = clamp(pc.key / average, pc.minE, pc.maxE);
if (pc.prev == 0u) { o_exposure = vec4(target); return; }
float last = sampleTexLod(pc.prev, vec2(0.5), 0.0).r;
float rate = target > last ? pc.speed * 0.6 : pc.speed; // brightening (into the dark) is slower
o_exposure = vec4(last + (target - last) * (1.0 - exp(-pc.dt * rate)));
}

View file

@ -0,0 +1,61 @@
// The light in the air at a point (volumetric.frag, froxel.comp): the sun through its shadow
// cascades, and the dynamic lights of that point's cluster with a one-tap shadow each. Needs
// FRAME, LIGHT, CELL, LIGHT_INDEX and SHADOW_VIEW (model.glsl with KEEL_FRAME_ONLY).
float sunlitAt(vec3 w) {
float depth = dot(w - FRAME.camPos.xyz, FRAME.camFwd.xyz);
if (depth > FRAME.splits.w) return 1.0;
int c = depth < FRAME.splits.x ? 0 : depth < FRAME.splits.y ? 1 : depth < FRAME.splits.z ? 2 : 3;
vec4 p = FRAME.cascades[c] * vec4(w, 1.0);
vec2 uv = p.xy * 0.5 + 0.5;
if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0))) || p.z > 1.0) return 1.0;
vec2 base = vec2(c & 1, c >> 1) * 0.5;
float t = FRAME.shadow.x;
return shadowTap(uint(FRAME.sunDir.w), base + uv * 0.5, base + t, base + 0.5 - t, p.z - 0.0005);
}
// The light cluster a point in the air is in (render/clusters.h), from its pixel's place on screen.
const uvec3 kClusters = uvec3(16, 9, 24);
const float kClusterNear = 8.0, kClusterFar = 16384.0;
uint clusterAt(vec3 w, vec2 uv) {
float depth = dot(w - FRAME.camPos.xyz, FRAME.camFwd.xyz);
uint z = depth <= kClusterNear ? 0u : min(uint(log(depth / kClusterNear) / log(kClusterFar / kClusterNear) * float(kClusters.z)), kClusters.z - 1u);
uvec2 tile = min(uvec2(uv * vec2(kClusters.xy)), kClusters.xy - 1u);
return (z * kClusters.y + tile.y) * kClusters.x + tile.x;
}
float phaseHG(float mu, float g) { return (1.0 - g * g) / (12.566 * pow(1.0 + g * g - 2.0 * g * mu, 1.5)); }
// The dynamic lights lighting the air at w (towards the eye along -rd).
vec3 lightsAt(vec3 w, vec3 rd, vec2 uv) {
if (FRAME.camPos.w < 0.5) return vec3(0.0);
uvec2 cell = CELL(clusterAt(w, uv));
vec3 total = vec3(0.0);
for (uint k = 0u; k < min(cell.y, 8u); ++k) {
Light l = LIGHT(LIGHT_INDEX(cell.x + k));
vec3 toLight = l.posRadius.xyz - w;
float dist = length(toLight);
if (dist >= l.posRadius.w) continue;
vec3 L = toLight / max(dist, 0.001);
float a = 1.0 - dist / l.posRadius.w, atten = a * a;
if (l.spot.w > -1.5) atten *= spotShape(l, dot(-L, l.spot.xyz));
if (atten <= 0.0) continue;
if (l.color.w >= 0.0) { // shadowed: one tap in its atlas tile
int view = int(l.color.w);
if (l.spot.w <= -1.5) {
vec3 d = -toLight, ad = abs(d);
view += ad.x >= ad.y && ad.x >= ad.z ? (d.x > 0.0 ? 0 : 1) : ad.y >= ad.z ? (d.y > 0.0 ? 2 : 3) : (d.z > 0.0 ? 4 : 5);
}
ShadowView sv = SHADOW_VIEW(view);
vec4 c = sv.viewProj * vec4(w, 1.0);
if (c.w > 0.0) {
c.xyz /= c.w;
if (all(lessThanEqual(abs(c.xy), vec2(1.0))) && c.z <= 1.0) {
float texel = 1.0 / FRAME.screen.w;
vec2 at = sv.rect.xy + (c.xy * 0.5 + 0.5) * sv.rect.zw;
atten *= shadowTap(uint(FRAME.screen.z), at, sv.rect.xy + texel, sv.rect.xy + sv.rect.zw - texel, c.z - 0.00005);
}
}
}
total += l.color.rgb * atten * phaseHG(dot(rd, -L), 0.3);
}
return total;
}

View file

@ -0,0 +1,16 @@
#ifndef KEEL_BACKEND_GLSL
#define KEEL_BACKEND_GLSL
// What every shader includes: textures and the push block, for the backend being built.
// Vulkan: bindless descriptor array + push constants. OpenGL 4.6: ARB_bindless_texture handles
// in storage buffers + a uniform block. Shader code only uses the names defined here.
#ifdef KEEL_GL
#include "backend_gl.glsl"
#else
#include "backend_vk.glsl"
#endif
float sunLight(vec3 n) {
const vec3 sun = normalize(vec3(0.4, 0.3, 0.85));
return 0.3 + 0.9 * max(dot(n, sun), 0.0);
}
#endif

View file

@ -0,0 +1,26 @@
// OpenGL 4.6 + ARB_bindless_texture (enabled by the loader's preamble): every texture is a
// 64-bit handle (sampler state included) in a table, indexed like Vulkan's descriptor array.
// Depth textures also get a comparison handle in a second table, for shadows.
layout(std430, binding = 9) readonly buffer TextureHandles { uvec2 handles[]; } u_textureHandles;
layout(std430, binding = 10) readonly buffer ShadowHandles { uvec2 handles[]; } u_shadowHandles;
#define PUSH_BLOCK layout(std140, binding = 0) uniform Push
#define VERTEX_ID gl_VertexID
vec4 sampleTex(uint index, vec2 uv) {
return texture(sampler2D(u_textureHandles.handles[index]), uv);
}
ivec2 texSize(uint index) { return textureSize(sampler2D(u_textureHandles.handles[index]), 0); }
vec4 fetchTex(uint index, ivec2 p) {
return texelFetch(sampler2D(u_textureHandles.handles[index]), p, 0);
}
vec4 sampleTexLod(uint index, vec2 uv, float lod) {
return textureLod(sampler2D(u_textureHandles.handles[index]), uv, lod);
}
float shadowTap(uint index, vec2 uv, vec2 lo, vec2 hi, float ref) {
return textureLod(sampler2DShadow(u_shadowHandles.handles[index]), vec3(clamp(uv, lo, hi), ref), 0.0);
}

View file

@ -0,0 +1,35 @@
// Vulkan: one global descriptor array of textures, separate samplers, push constants.
#extension GL_EXT_nonuniform_qualifier : require
#extension GL_EXT_buffer_reference : require
layout(set = 0, binding = 0) uniform texture2D u_textures[];
layout(set = 0, binding = 1) uniform sampler u_sampler;
layout(set = 0, binding = 2) uniform sampler u_shadowSampler; // depth compare, linear: bilinear PCF
layout(set = 0, binding = 3) uniform sampler u_clampSampler; // render targets, for the screen-space passes
// Post passes (#define KEEL_POST before including): every lookup clamps at the edges.
#ifdef KEEL_POST
#define KEEL_SAMPLER u_clampSampler
#else
#define KEEL_SAMPLER u_sampler
#endif
#define PUSH_BLOCK layout(push_constant) uniform Push
#define VERTEX_ID gl_VertexIndex
vec4 sampleTex(uint index, vec2 uv) {
return texture(sampler2D(u_textures[nonuniformEXT(index)], KEEL_SAMPLER), uv);
}
ivec2 texSize(uint index) { return textureSize(sampler2D(u_textures[nonuniformEXT(index)], KEEL_SAMPLER), 0); }
vec4 fetchTex(uint index, ivec2 p) {
return texelFetch(sampler2D(u_textures[nonuniformEXT(index)], KEEL_SAMPLER), p, 0);
}
vec4 sampleTexLod(uint index, vec2 uv, float lod) {
return textureLod(sampler2D(u_textures[nonuniformEXT(index)], KEEL_SAMPLER), uv, lod);
}
// 1 = lit. Hardware-filtered depth compare at uv (clamped inside [lo, hi]).
float shadowTap(uint index, vec2 uv, vec2 lo, vec2 hi, float ref) {
return textureLod(sampler2DShadow(u_textures[nonuniformEXT(index)], u_shadowSampler), vec3(clamp(uv, lo, hi), ref), 0.0);
}

View file

@ -0,0 +1,30 @@
#version 460
// Bloom, going down: a 13-tap filter (Jimenez, "Next generation post processing in Call of
// Duty: Advanced Warfare") halving the size each step. The first step clamps fireflies.
#define KEEL_POST // screen-space: render targets, sampled clamped
#include "backend.glsl"
PUSH_BLOCK {
uint src;
uint srcW, srcH;
uint outW, outH;
uint first;
} pc;
layout(location = 0) out vec4 o_color;
vec3 tap(vec2 uv) {
vec2 h = 0.5 / vec2(pc.srcW, pc.srcH);
return sampleTex(pc.src, clamp(uv, h, 1.0 - h)).rgb;
}
void main() {
vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH);
vec2 t = 1.0 / vec2(pc.srcW, pc.srcH);
vec3 a = tap(uv + t * vec2(-2, -2)), b = tap(uv + t * vec2(0, -2)), c = tap(uv + t * vec2(2, -2));
vec3 d = tap(uv + t * vec2(-1, -1)), e = tap(uv + t * vec2(1, -1));
vec3 f = tap(uv + t * vec2(-2, 0)), g = tap(uv), hh = tap(uv + t * vec2(2, 0));
vec3 i = tap(uv + t * vec2(-1, 1)), j = tap(uv + t * vec2(1, 1));
vec3 k = tap(uv + t * vec2(-2, 2)), l = tap(uv + t * vec2(0, 2)), m = tap(uv + t * vec2(2, 2));
vec3 sum = (d + e + i + j) * 0.125 + (a + c + k + m) * 0.03125 + (b + f + hh + l) * 0.0625 + g * 0.125;
if (pc.first != 0u) sum = min(sum, vec3(32.0)); // no single-pixel sparkle blooming the screen
o_color = vec4(sum, 1.0);
}

View file

@ -0,0 +1,25 @@
#version 460
// Bloom, coming up: a 3×3 tent of the smaller level, added onto the larger one (blended).
#define KEEL_POST // screen-space: render targets, sampled clamped
#include "backend.glsl"
PUSH_BLOCK {
uint src;
uint srcW, srcH;
uint outW, outH;
float radius;
} pc;
layout(location = 0) out vec4 o_color;
vec3 tap(vec2 uv) {
vec2 h = 0.5 / vec2(pc.srcW, pc.srcH);
return sampleTex(pc.src, clamp(uv, h, 1.0 - h)).rgb;
}
void main() {
vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH);
vec2 t = pc.radius / vec2(pc.srcW, pc.srcH);
vec3 s = tap(uv) * 4.0;
s += (tap(uv + vec2(-t.x, 0)) + tap(uv + vec2(t.x, 0)) + tap(uv + vec2(0, -t.y)) + tap(uv + vec2(0, t.y))) * 2.0;
s += tap(uv + vec2(-t.x, -t.y)) + tap(uv + vec2(t.x, -t.y)) + tap(uv + vec2(-t.x, t.y)) + tap(uv + vec2(t.x, t.y));
o_color = vec4(s / 16.0, 1.0);
}

20
engine/shaders/box.frag Normal file
View file

@ -0,0 +1,20 @@
#version 460
#include "backend.glsl"
PUSH_BLOCK {
mat4 viewProj;
vec4 center;
vec4 extents;
vec4 color;
} pc;
layout(location = 0) in vec3 v_normal;
layout(location = 1) in vec3 v_world;
layout(location = 0) out vec4 o_color;
void main() {
// World-space UVs: textures tile every 128 units regardless of box size.
vec3 n = abs(v_normal);
vec2 uv = (n.x > 0.5 ? v_world.yz : n.y > 0.5 ? v_world.xz : v_world.xy) / 128.0;
vec4 tex = sampleTex(uint(pc.center.w), uv);
o_color = vec4(pc.color.rgb * tex.rgb * sunLight(v_normal), 1.0);
}

26
engine/shaders/box.vert Normal file
View file

@ -0,0 +1,26 @@
#version 460
// Axis-aligned boxes generated from the vertex index: 36 vertices, no vertex buffer.
#include "backend.glsl"
PUSH_BLOCK {
mat4 viewProj;
vec4 center; // xyz, w = texture index
vec4 extents; // xyz = half size
vec4 color;
} pc;
layout(location = 0) out vec3 v_normal;
layout(location = 1) out vec3 v_world;
const vec3 kNormals[6] = vec3[](vec3(1,0,0), vec3(-1,0,0), vec3(0,1,0), vec3(0,-1,0), vec3(0,0,1), vec3(0,0,-1));
const vec2 kQuad[6] = vec2[](vec2(-1,-1), vec2(1,-1), vec2(1,1), vec2(-1,-1), vec2(1,1), vec2(-1,1));
void main() {
int face = VERTEX_ID / 6;
vec3 n = kNormals[face];
vec3 t = abs(n.z) > 0.5 ? vec3(1,0,0) : vec3(0,0,1);
vec3 b = cross(n, t);
vec2 q = kQuad[VERTEX_ID % 6];
v_world = pc.center.xyz + (n + t * q.x + b * q.y) * pc.extents.xyz;
v_normal = n;
gl_Position = pc.viewProj * vec4(v_world, 1.0);
}

59
engine/shaders/cull.comp Normal file
View file

@ -0,0 +1,59 @@
#version 460
// GPU occlusion culling (r_gpuculling): one thread per opaque draw. Its world box, projected the
// way the occlusion grid's frame saw the world (last frame's view), is hidden if every grid texel
// under it holds something nearer than the box's nearest point. Writes the draw's indirect command
// (one instance or none) for the depth pre-pass and the scene pass, and counts the hidden ones.
#include "backend.glsl"
layout(local_size_x = 64) in;
struct CullBox { vec4 lo; vec4 hi; uvec4 draw; }; // draw: index count, first index, test (1), -
layout(buffer_reference, std430) readonly buffer Boxes { CullBox b[]; };
struct Cmd { uint indexCount, instanceCount, firstIndex; int vertexOffset; uint firstInstance; };
layout(buffer_reference, std430) writeonly buffer Cmds { Cmd c[]; };
layout(buffer_reference, std430) buffer Counter { uint hidden; };
PUSH_BLOCK {
mat4 viewProj; // the frame the grid was made from
Boxes boxes;
Cmds cmds;
Counter counter;
uint count;
uint hzb; // the pyramid's first level (kHzbLevels slots, each half the last)
uint occlusion; // 0: the camera moved too much since (draw everything in view)
uint pad;
} pc;
void main() {
uint i = gl_GlobalInvocationID.x;
if (i >= pc.count) return;
CullBox b = pc.boxes.b[i];
bool visible = true;
if (pc.occlusion != 0u && (b.draw.z & 1u) != 0u) {
vec3 mn = vec3(1e9), mx = vec3(-1e9);
bool behind = false;
for (int k = 0; k < 8; ++k) {
vec3 c = vec3((k & 1) != 0 ? b.hi.x : b.lo.x, (k & 2) != 0 ? b.hi.y : b.lo.y, (k & 4) != 0 ? b.hi.z : b.lo.z);
vec4 p = pc.viewProj * vec4(c, 1.0);
if (p.w <= 0.001) { behind = true; break; } // crosses the eye: can't tell, draw it
vec3 n = p.xyz / p.w;
mn = min(mn, n), mx = max(mx, n);
}
if (!behind) {
// The level where the box covers a few texels (each holds the farthest depth under
// it, so testing there never hides what's visible).
vec2 size0 = vec2(texSize(pc.hzb));
vec2 extent = (mx.xy - mn.xy) * 0.5 * size0;
uint level = uint(clamp(ceil(log2(max(max(extent.x, extent.y), 1.0) / 3.0)), 0.0, 7.0));
uint tex = pc.hzb + level;
ivec2 size = texSize(tex);
ivec2 a = clamp(ivec2(floor((mn.xy * 0.5 + 0.5) * vec2(size))), ivec2(0), size - 1);
ivec2 z = clamp(ivec2(floor((mx.xy * 0.5 + 0.5) * vec2(size))), ivec2(0), size - 1);
float far = 0.0;
for (int y = a.y; y <= z.y; ++y)
for (int x = a.x; x <= z.x; ++x) far = max(far, fetchTex(tex, ivec2(x, y)).r);
if (mn.z > far + 1e-5) visible = false;
}
}
pc.cmds.c[i] = Cmd(b.draw.x, visible ? 1u : 0u, b.draw.y, 0, 0u);
if (!visible) atomicAdd(pc.counter.hidden, 1u);
}

11
engine/shaders/depth.frag Normal file
View file

@ -0,0 +1,11 @@
#version 460
// Depth pre-pass (r_prepass): only the depth, with alpha-tested texels cut out like mesh.frag
// does, so the scene pass shades each pixel once (what's hidden behind isn't lit for nothing).
#include "backend.glsl"
#include "model.glsl"
layout(location = 1) in vec2 v_uv;
void main() {
if (sampleTex(pc.textures & 0xFFFFu, v_uv).a < 0.5) discard;
}

View file

@ -0,0 +1,2 @@
#version 460
#include "froxel_main.glsl"

View file

@ -0,0 +1,30 @@
// The fog grid ("froxels", as in Frostbite / id Tech): kFroxels cells over the screen and along
// the view, slices spaced exponentially from FRAME.froxel.y to .z. froxel.comp fills it; entry k
// of a column holds the light the fog adds (rgb) and how much of what's behind gets through (a)
// from the eye to the far side of slice k.
const uvec3 kFroxels = uvec3(160, 90, 64);
uint froxelIndex(uvec3 c) { return (c.z * kFroxels.y + c.y) * kFroxels.x + c.x; }
// Where slice boundary j lies (view depth); slice k spans boundaries k and k + 1.
float froxelBoundary(float j) { return FRAME.froxel.y * pow(FRAME.froxel.z / FRAME.froxel.y, j / float(kFroxels.z)); }
float froxelSlice(float depth) { return log(max(depth, 1e-3) / FRAME.froxel.y) / log(FRAME.froxel.z / FRAME.froxel.y) * float(kFroxels.z); }
#ifndef KEEL_FROXEL_WRITER
vec4 froxelEntry(ivec2 xy, int z) {
uvec2 v = FRAME.froxels.f[froxelIndex(uvec3(clamp(xy, ivec2(0), ivec2(kFroxels.xy) - 1), clamp(z, 0, int(kFroxels.z) - 1)))];
return vec4(unpackHalf2x16(v.x), unpackHalf2x16(v.y));
}
vec4 froxelColumn(ivec2 xy, float c) { // c: entry index, fractional
if (c < 0.0) return mix(vec4(0.0, 0.0, 0.0, 1.0), froxelEntry(xy, 0), clamp(c + 1.0, 0.0, 1.0));
int z = int(c);
return mix(froxelEntry(xy, z), froxelEntry(xy, z + 1), c >= float(kFroxels.z - 1) ? 0.0 : fract(c));
}
// The fog between the eye and a point at uv on screen, depth along the view: (light, transmittance).
vec4 froxelFog(vec2 uv, float depth) {
vec2 g = uv * vec2(kFroxels.xy) - 0.5;
ivec2 b = ivec2(floor(g));
vec2 f = g - vec2(b);
// Entry k ends at boundary k + 1; nearer than the first slice's far side fades in from nothing.
float c = max(froxelSlice(depth) - 1.0, -1.0);
return mix(mix(froxelColumn(b, c), froxelColumn(b + ivec2(1, 0), c), f.x), mix(froxelColumn(b + ivec2(0, 1), c), froxelColumn(b + ivec2(1, 1), c), f.x), f.y);
}
#endif

View file

@ -0,0 +1,218 @@
// The body of froxel.comp and froxel_rt.comp (the same, plus rays for the sky above each cell).
// The fog grid (froxel.glsl), in two dispatches:
// mode 0, one thread per cell: the fog's density there (height fog, plus the air when
// r_volumetric's on) and the light it scatters towards the eye (the sky's, the sun through its
// shadow, the lamps of its cluster), at a point jittered along the slice each frame and blended
// with last frame's cell at the same place in the world, so it's smooth and doesn't crawl.
// mode 1, one thread per column: walked front to back, adding up light and transmittance.
#include "backend.glsl"
#define KEEL_FRAME_ONLY
#include "model.glsl"
#include "sky.glsl"
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
layout(buffer_reference, std430) buffer Cells4 { vec4 c[]; }; // per cell: scattered light (per unit), extinction
layout(buffer_reference, std430) writeonly buffer Out { uvec2 f[]; };
layout(buffer_reference, std430) readonly buffer SmokeCells { uint v[]; }; // two cells a word
layout(buffer_reference, std430) writeonly buffer SmokeSky { uvec2 f[]; }; // a column's smoke alone
PUSH_BLOCK {
FrameData frame;
Cells4 cur, prev;
Out integrated;
vec4 proj; // tan(half fov x), tan(half fov y)
vec4 prevPos; // last frame's eye; w 1 = there's a last frame to blend with
vec4 prevFwd; // and its view direction; w this frame's jitter along the slice (0..1)
uint mode;
uint frameIndex;
uvec2 tlas; // KEEL_FOG_RAYS: the scene's acceleration structure
SmokeCells smoke; // the smoke grid (render/backend.h kSmokeDims): density | shade << 8 a cell
SmokeSky smokeSky; // integrate: each column's smoke alone, end to end (smoke_sky.frag lays it over the sky)
vec4 smokeOrigin; // its world corner; w a clock (seconds) for the billows, < 0: no smoke
} pc;
#define FRAME pc.frame
#define KEEL_FROXEL_WRITER
#include "froxel.glsl"
#include "airlight.glsl"
// ── Smoke (shared/smoke.h): the grid's cells, blended, roughened by drifting billows ──
const ivec3 kSmokeDims = ivec3(160, 160, 48);
const float kSmokeCell = 16.0;
vec2 smokeCell(ivec3 c) {
if (any(lessThan(c, ivec3(0))) || any(greaterThanEqual(c, kSmokeDims))) return vec2(0.0);
uint i = uint((c.z * kSmokeDims.y + c.y) * kSmokeDims.x + c.x);
uint h = (pc.smoke.v[i >> 1] >> ((i & 1u) * 16u)) & 0xFFFFu;
return vec2(float(h & 0xFFu), float(h >> 8)) / 255.0;
}
// x density, y shade (both blended between the 8 cells round w).
vec2 smokeAt(vec3 w) {
vec3 g = (w - pc.smokeOrigin.xyz) / kSmokeCell - 0.5;
ivec3 b = ivec3(floor(g));
vec3 f = g - vec3(b);
float d = 0.0, s = 0.0;
for (int k = 0; k < 8; ++k) {
ivec3 o = ivec3(k & 1, (k >> 1) & 1, k >> 2);
vec3 tri = mix(1.0 - f, f, vec3(o));
vec2 c = smokeCell(b + o);
float wgt = tri.x * tri.y * tri.z;
d += c.x * wgt;
s += c.y * c.x * wgt;
}
return vec2(d, d > 1e-4 ? s / d : 0.5);
}
float hash3(vec3 p) {
p = fract(p * 0.3183099 + 0.1) * 17.0;
return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
}
float valueNoise(vec3 x) {
vec3 i = floor(x), f = fract(x);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(mix(hash3(i), hash3(i + vec3(1, 0, 0)), f.x), mix(hash3(i + vec3(0, 1, 0)), hash3(i + vec3(1, 1, 0)), f.x), f.y),
mix(mix(hash3(i + vec3(0, 0, 1)), hash3(i + vec3(1, 0, 1)), f.x), mix(hash3(i + vec3(0, 1, 1)), hash3(i + vec3(1, 1, 1)), f.x), f.y), f.z);
}
// The density there after the billows: thick inside, torn and drifting at the edges.
float smokeDensity(vec3 w, float d) {
if (d <= 0.0) return 0.0;
float t = pc.smokeOrigin.w;
vec3 q = w * 0.03 + vec3(t * 0.04, t * 0.03, -t * 0.12);
float n = 0.5 * valueNoise(q) + 0.3 * valueNoise(q * 2.3 + vec3(t * 0.07)) + 0.2 * valueNoise(q * 5.1 - vec3(t * 0.1));
// Thin smoke (the rim) is torn most; the thick middle only darkens and lightens a little.
return clamp(d * 1.6 - (1.0 - n) * mix(1.1, 0.5, d), 0.0, 1.0) * mix(0.75, 1.15, n);
}
vec3 viewRay(vec3 fwd, vec2 uv) { // not normalized: 1 along fwd
vec3 right = normalize(cross(fwd, vec3(0.0, 0.0, 1.0))), up = cross(right, fwd);
vec2 s = (uv * 2.0 - 1.0) * pc.proj.xy;
return fwd + right * s.x - up * s.y;
}
// Last frame's cell (blended) at a world point; w < 0 when it was off that grid.
vec4 history(vec3 w) {
vec3 fwd = pc.prevFwd.xyz, right = normalize(cross(fwd, vec3(0.0, 0.0, 1.0))), up = cross(right, fwd);
vec3 v = w - pc.prevPos.xyz;
float depth = dot(v, fwd);
if (depth < FRAME.froxel.y) return vec4(-1.0);
vec2 uv = (vec2(dot(v, right), -dot(v, up)) / depth / pc.proj.xy) * 0.5 + 0.5;
vec3 g = vec3(uv * vec2(kFroxels.xy), froxelSlice(depth)) - 0.5;
if (any(lessThan(g, vec3(0.0))) || any(greaterThan(g, vec3(kFroxels) - 1.0))) return vec4(-1.0);
ivec3 b = ivec3(floor(g));
vec3 f = g - vec3(b);
vec4 r = vec4(0.0);
for (int k = 0; k < 8; ++k) {
ivec3 o = ivec3(k & 1, (k >> 1) & 1, k >> 2);
float wgt = (o.x != 0 ? f.x : 1.0 - f.x) * (o.y != 0 ? f.y : 1.0 - f.y) * (o.z != 0 ? f.z : 1.0 - f.z);
r += pc.prev.c[froxelIndex(uvec3(min(b + o, ivec3(kFroxels) - 1)))] * wgt;
}
return r;
}
void inject(uvec3 c) {
vec2 uv = (vec2(c.xy) + 0.5) / vec2(kFroxels.xy);
vec3 ray = viewRay(FRAME.camFwd.xyz, uv), rd = normalize(ray);
vec3 w = FRAME.camPos.xyz + ray * froxelBoundary(float(c.z) + pc.prevFwd.w);
float fog = 0.0;
if (FRAME.sky.x > 0.5 && FRAME.fog.x > 0.0) fog = FRAME.fog.x * exp(-FRAME.fog.y * (w.z - FRAME.fog.w));
float air = FRAME.volumetric.x > 0.0 ? FRAME.volumetric.z : 0.0;
vec2 smoke = pc.smokeOrigin.w >= 0.0 ? smokeAt(w) : vec2(0.0);
float smokeExt = smokeDensity(w, smoke.x) * 0.035; // (thick smoke: ~30 units hide what's behind)
vec3 light = vec3(0.0);
if (fog + air + smokeExt > 0.0) {
const bool sunOn = FRAME.sunColor.w > 0.5;
float lit = sunOn ? sunlitAt(w) : 0.0;
float mu = dot(rd, FRAME.sunDir.xyz);
vec3 lamps = lightsAt(w, rd, uv);
// The fog: lit by the sky (blurred, as the old height fog was) and, where the sun reaches
// it, glowing towards the sun; the lamps light it too.
vec3 sky = FRAME.sky.x > 0.5 ? skyRadiance(uint(FRAME.sky.x), FRAME.sky.y, FRAME.sky.w, normalize(vec3(rd.xy, max(rd.z, 0.05))), FRAME.sky.z) * 0.9 : vec3(0.0);
vec3 smokeSky = sky; // (the smoke's own: steady, no per-frame noise to average away)
#ifdef KEEL_FOG_RAYS
// How much sky this point sees: one ray a frame into the sky above it, in a different
// direction each frame (the blend with last frame's cells averages them). Under a roof
// the fog gets next to no sky light, so rooms stay clear instead of filling with grey.
uint h = (c.x * 73856093u) ^ (c.y * 19349663u) ^ (c.z * 83492791u) ^ (pc.frameIndex * 2654435761u);
h = h * 747796405u + 2891336453u;
float r1 = float(h & 0xFFFFu) / 65535.0, r2 = float(h >> 16) / 65535.0;
vec3 up = normalize(vec3(cos(r1 * 6.2831853), sin(r1 * 6.2831853), 0.0) * sqrt(r2) * 1.2 + vec3(0.0, 0.0, 1.0));
rayQueryEXT q;
rayQueryInitializeEXT(q, accelerationStructureEXT(pc.tlas), gl_RayFlagsTerminateOnFirstHitEXT | gl_RayFlagsOpaqueEXT, 0xFF, w, 1.0, up, 6000.0);
while (rayQueryProceedEXT(q)) {}
float open = rayQueryGetIntersectionTypeEXT(q, true) == gl_RayQueryCommittedIntersectionNoneEXT ? 1.0 : 0.0;
sky *= mix(0.06, 1.0, open);
if (smokeExt > 0.0) { // under a roof or not: one ray straight up
rayQueryEXT q2;
rayQueryInitializeEXT(q2, accelerationStructureEXT(pc.tlas), gl_RayFlagsTerminateOnFirstHitEXT | gl_RayFlagsOpaqueEXT, 0xFF, w, 1.0, vec3(0.0, 0.0, 1.0), 6000.0);
while (rayQueryProceedEXT(q2)) {}
smokeSky *= rayQueryGetIntersectionTypeEXT(q2, true) == gl_RayQueryCommittedIntersectionNoneEXT ? 1.0 : 0.3;
}
#else
// Where the sun can't reach (under a roof, mostly) the sky's light is dimmer too.
if (sunOn) sky *= mix(0.3, 1.0, lit);
smokeSky = sky;
#endif
vec3 fogLight = sky + (sunOn ? FRAME.sunColor.rgb * lit * pow(max(mu, 0.0), 8.0) * 0.25 : vec3(0.0)) + lamps * 12.0;
// The air (r_volumetric): shafts of sun and haloes round lamps; it adds light only.
vec3 airLight = (sunOn && FRAME.volumetric.w > 0.5 ? FRAME.sunColor.rgb * lit * phaseHG(mu, 0.6) : vec3(0.0)) + lamps * 7.0;
light = fog * fogLight + air * airLight * FRAME.volumetric.x;
if (smokeExt > 0.0) {
// Smoke: the sky's light all round, the sun through its shadows and through the smoke
// between it and the sun (a few steps that way), the lamps; tinted by its shade.
float toward = sunOn ? smokeAt(w + FRAME.sunDir.xyz * 24.0).x + smokeAt(w + FRAME.sunDir.xyz * 64.0).x + smokeAt(w + FRAME.sunDir.xyz * 128.0).x : 0.0;
vec3 sunIn = sunOn ? FRAME.sunColor.rgb * lit * exp(-toward * 1.4) * 0.5 : vec3(0.0);
// The smoke above shades what's under it (the sky's light comes mostly from above).
float above = smokeAt(w + vec3(0.0, 0.0, 32.0)).x + smokeAt(w + vec3(0.0, 0.0, 80.0)).x;
light += smokeExt * smoke.y * (smokeSky * mix(1.0, 0.35, clamp(above * 0.5, 0.0, 1.0)) + sunIn + lamps * 4.0);
}
}
vec4 now = vec4(light, fog + smokeExt);
uint i = froxelIndex(c);
if (pc.prevPos.w > 0.5) {
vec4 h = history(w);
if (h.w >= 0.0) now = mix(h, now, 0.12);
}
pc.cur.c[i] = now;
}
void integrate(uvec2 xy) {
vec2 uv = (vec2(xy) + 0.5) / vec2(kFroxels.xy);
float rayScale = length(viewRay(FRAME.camFwd.xyz, uv)); // ray length per unit of depth
vec3 ray = viewRay(FRAME.camFwd.xyz, uv);
vec3 light = vec3(0.0);
// The fog never hides more than its max (FRAME.fog.z; 0 or 1: no cap): once the fog alone has
// hidden that much, the cells further on keep only their smoke (and its share of the light).
float through = 1.0, fogThrough = 1.0, keep = 1.0 - FRAME.fog.z;
vec3 smokeLight = vec3(0.0);
float smokeThrough = 1.0;
bool capped = FRAME.fog.z > 0.0 && FRAME.fog.z < 1.0;
float near = 0.0;
for (uint z = 0u; z < kFroxels.z; ++z) {
float far = froxelBoundary(float(z + 1u)), len = (far - near) * rayScale;
vec3 w = FRAME.camPos.xyz + ray * (near + far) * 0.5;
near = far;
vec4 cell = pc.cur.c[froxelIndex(uvec3(xy, z))];
// The smoke here, sampled again (exact: it doesn't move with the slices' jitter as the fog
// would); the rest of the cell's extinction is the fog.
float smokeHere = pc.smokeOrigin.w >= 0.0 ? min(smokeDensity(w, smokeAt(w).x) * 0.035, cell.w) : 0.0;
float fog = max(cell.w - smokeHere, 0.0);
if (capped && fogThrough <= keep && cell.w > 1e-9) {
cell.rgb *= (cell.w - fog) / cell.w;
cell.w -= fog;
fog = 0.0;
}
fogThrough *= exp(-fog * len);
if (smokeHere > 1e-9) { // the smoke's share of the cell
float se = smokeHere, st = exp(-se * len);
smokeLight += smokeThrough * cell.rgb * (se / cell.w) * (1.0 - st) / se;
smokeThrough *= st;
}
float t = exp(-cell.w * len);
light += through * (cell.w > 1e-9 ? cell.rgb * (1.0 - t) / cell.w : cell.rgb * len);
through *= t;
pc.integrated.f[froxelIndex(uvec3(xy, z))] = uvec2(packHalf2x16(light.rg), packHalf2x16(vec2(light.b, through)));
}
if (pc.smokeOrigin.w >= 0.0) pc.smokeSky.f[xy.y * kFroxels.x + xy.x] = uvec2(packHalf2x16(smokeLight.rg), packHalf2x16(vec2(smokeLight.b, smokeThrough)));
}
void main() {
uvec3 id = gl_GlobalInvocationID;
if (any(greaterThanEqual(id.xy, kFroxels.xy))) return;
if (pc.mode == 0u) inject(id);
else integrate(id.xy);
}

View file

@ -0,0 +1,5 @@
#version 460
// The fog grid with ray queries: each cell sees whether it has sky above it (froxel_main.glsl).
#extension GL_EXT_ray_query : require
#define KEEL_FOG_RAYS
#include "froxel_main.glsl"

View file

@ -0,0 +1,11 @@
#version 460
#include "backend.glsl"
layout(location = 0) out vec2 v_uv;
void main() {
v_uv = vec2((VERTEX_ID << 1) & 2, VERTEX_ID & 2);
gl_Position = vec4(v_uv * 2.0 - 1.0, 0.0, 1.0);
#ifdef KEEL_GL
// The window shows row 0 at the bottom in OpenGL; our images keep the view's top in row 0 (as in Vulkan).
v_uv.y = 1.0 - v_uv.y;
#endif
}

143
engine/shaders/gi.comp Normal file
View file

@ -0,0 +1,143 @@
#version 460
// Real-time GI (gi.glsl): one workgroup per probe, one hardware ray per thread (ray queries
// against this frame's scene). Where a ray lands: that surface's colour, lit by the dynamic
// lights that reach it (a shadow ray each) and by last frame's probes there (so light bounces
// more than once); behind a surface (the probe's stuck in a wall) it counts against the probe.
// Then the probe's harmonics and distances, blended with what it had.
#extension GL_EXT_ray_query : require
#include "backend.glsl"
#define KEEL_FRAME_ONLY
#include "model.glsl"
layout(local_size_x = 32) in;
layout(buffer_reference, std430) readonly buffer GiVertices { Vertex v[]; };
layout(buffer_reference, std430) readonly buffer GiIndices { uint i[]; };
struct RayInst { GiVertices vertices; GiIndices indices; uint firstIndex, textures, tint, pad; };
layout(buffer_reference, std430) readonly buffer RayInsts { RayInst r[]; };
layout(buffer_reference, std430) writeonly buffer GiProbesOut { GiProbe p[]; };
PUSH_BLOCK {
FrameData frame;
GiProbesOut cur;
GiProbes prev;
RayInsts insts;
uvec2 tlas; // the scene's acceleration structure
uint fresh; // 1: start over (nothing to blend with)
float hysteresis; // how much of the old light stays each frame
vec4 rotation; // this frame's turn of the ray pattern (quaternion)
} pc;
#define FRAME pc.frame
#define GI_PROBES pc.prev
#include "gi.glsl"
const uint kRays = 32u;
shared vec3 s_light[kRays], s_dir[kRays];
shared float s_dist[kRays];
shared uint s_back[kRays];
vec3 rotate(vec4 q, vec3 v) { return v + 2.0 * cross(q.xyz, cross(q.xyz, v) + q.w * v); }
vec3 fibonacci(uint i, uint n) {
float z = 1.0 - (2.0 * float(i) + 1.0) / float(n), r = sqrt(max(1.0 - z * z, 0.0));
float phi = float(i) * 2.39996323;
return vec3(cos(phi) * r, sin(phi) * r, z);
}
bool blocked(vec3 from, vec3 dir, float len) {
rayQueryEXT q;
rayQueryInitializeEXT(q, accelerationStructureEXT(pc.tlas), gl_RayFlagsTerminateOnFirstHitEXT | gl_RayFlagsOpaqueEXT, 0xFF, from, 0.01, dir, len);
while (rayQueryProceedEXT(q)) {}
return rayQueryGetIntersectionTypeEXT(q, true) != gl_RayQueryCommittedIntersectionNoneEXT;
}
void main() {
uint probe = gl_WorkGroupID.x, t = gl_LocalInvocationID.x;
ivec3 slot = ivec3(probe % uint(kGiDims.x), (probe / uint(kGiDims.x)) % uint(kGiDims.y), probe / uint(kGiDims.x * kGiDims.y));
ivec3 origin = ivec3(FRAME.giOrigin.xyz);
ivec3 cell = origin + giWrap(slot - origin); // the world cell this slot holds now
vec3 pos = (vec3(cell) + 0.5) * FRAME.gi.y; // between floors and walls (on the grid lines maps are built on)
GiProbe old = pc.prev.p[probe];
bool fresh = pc.fresh != 0u || old.tag.w < 0.5 || ivec3(old.tag.xyz) != cell;
vec3 dir = normalize(rotate(pc.rotation, fibonacci(t, kRays)));
const float kFar = FRAME.gi.y * 8.0;
rayQueryEXT q;
rayQueryInitializeEXT(q, accelerationStructureEXT(pc.tlas), gl_RayFlagsOpaqueEXT, 0xFF, pos, 0.0, dir, kFar);
while (rayQueryProceedEXT(q)) {}
vec3 light = vec3(0.0);
float dist = kFar;
uint back = 0u;
if (rayQueryGetIntersectionTypeEXT(q, true) == gl_RayQueryCommittedIntersectionTriangleEXT) {
dist = rayQueryGetIntersectionTEXT(q, true);
RayInst ri = pc.insts.r[rayQueryGetIntersectionInstanceCustomIndexEXT(q, true)];
uint first = ri.firstIndex + uint(rayQueryGetIntersectionPrimitiveIndexEXT(q, true)) * 3u;
Vertex a = ri.vertices.v[ri.indices.i[first]], b = ri.vertices.v[ri.indices.i[first + 1u]], c = ri.vertices.v[ri.indices.i[first + 2u]];
vec2 bc = rayQueryGetIntersectionBarycentricsEXT(q, true);
vec3 w3 = vec3(1.0 - bc.x - bc.y, bc.x, bc.y);
mat4x3 toWorld = rayQueryGetIntersectionObjectToWorldEXT(q, true);
vec3 n = normalize(mat3(toWorld) * (vec3(a.nx, a.ny, a.nz) * w3.x + vec3(b.nx, b.ny, b.nz) * w3.y + vec3(c.nx, c.ny, c.nz) * w3.z));
if (dot(n, dir) > 0.0) { // its back: inside something (a wall's far side, a closed box)
back = 1u;
dist *= 0.2;
} else {
vec2 uv = vec2(a.u, a.v) * w3.x + vec2(b.u, b.v) * w3.y + vec2(c.u, c.v) * w3.z;
vec3 albedo = sampleTexLod(ri.textures & 0xFFFFu, uv, 4.0).rgb * unpackUnorm4x8(ri.tint).rgb;
vec3 hit = pos + dir * dist + n * 0.5;
vec3 direct = vec3(0.0);
uint count = min(uint(FRAME.camPos.w), 32u);
for (uint k = 0u; k < count; ++k) {
Light l = LIGHT(k);
vec3 toLight = l.posRadius.xyz - hit;
float d = length(toLight);
if (d >= l.posRadius.w) continue;
vec3 L = toLight / max(d, 0.001);
float ndl = dot(n, L);
if (ndl <= 0.0) continue;
float fall = 1.0 - d / l.posRadius.w, atten = fall * fall;
if (l.spot.w > -1.5) atten *= spotShape(l, dot(-L, l.spot.xyz));
// (Stopping short of the light: lamps sit inside their fittings.)
if (atten <= 1e-3 || blocked(hit, L, max(d - 12.0, 0.0))) continue;
direct += l.color.rgb * atten * ndl;
}
// More bounces: what last frame's probes say reaches that spot.
direct += giIrradiance(hit, n, -dir);
light = albedo * direct;
}
}
s_light[t] = light;
s_dir[t] = dir;
s_dist[t] = dist;
s_back[t] = back;
barrier();
if (t != 0u) return;
GiProbe p;
for (int i = 0; i < 4; ++i) p.sh[i] = vec4(0.0);
vec4 mean0 = vec4(0.0), mean1 = vec4(0.0), w0 = vec4(0.0), w1 = vec4(0.0); // +x -x +y -y, +z -z (and their squares)
vec4 sq0 = vec4(0.0), sq1 = vec4(0.0);
uint backs = 0u;
const float weight = 12.566371 / float(kRays);
for (uint i = 0u; i < kRays; ++i) {
vec3 d = s_dir[i], L = s_light[i] * weight;
p.sh[0].rgb += L * 0.282095;
p.sh[1].rgb += L * 0.488603 * d.y;
p.sh[2].rgb += L * 0.488603 * d.z;
p.sh[3].rgb += L * 0.488603 * d.x;
vec4 a = vec4(max(d.x, 0.0), max(-d.x, 0.0), max(d.y, 0.0), max(-d.y, 0.0)), b = vec4(max(d.z, 0.0), max(-d.z, 0.0), 0.0, 0.0);
a = pow(a, vec4(4.0)), b = pow(b, vec4(4.0));
float r = s_dist[i];
mean0 += a * r, sq0 += a * r * r, w0 += a;
mean1 += b * r, sq1 += b * r * r, w1 += b;
backs += s_back[i];
}
mean0 /= max(w0, vec4(1e-4)), sq0 /= max(w0, vec4(1e-4));
mean1 /= max(w1, vec4(1e-4)), sq1 /= max(w1, vec4(1e-4));
p.dist[0] = mean0; // +x -x +y -y
p.dist[1] = vec4(mean1.xy, sq0.xy); // +z -z, then squares +x -x
p.dist[2] = vec4(sq0.zw, sq1.xy); // squares +y -y +z -z
// Stuck in a wall (many rays see backs): don't trust it.
p.sh[0].w = float(backs) > float(kRays) * 0.25 ? 0.0 : 1.0;
p.tag = vec4(vec3(cell), 1.0);
if (!fresh) {
float h = pc.hysteresis;
for (int i = 0; i < 4; ++i) p.sh[i].rgb = mix(p.sh[i].rgb, old.sh[i].rgb, h);
for (int i = 0; i < 3; ++i) p.dist[i] = mix(p.dist[i], old.dist[i], 0.97); // shapes change slowly
}
pc.cur.p[probe] = p;
}

56
engine/shaders/gi.glsl Normal file
View file

@ -0,0 +1,56 @@
// Real-time GI probes (gi.comp fills them, with ray queries): a grid of kGiDims probes,
// FRAME.gi.y apart, that follows the eye; a probe's slot is its world cell wrapped into the grid,
// so moving keeps what's already there. They hold the light that dynamic lights (flashlight,
// fire, explosions, lamps) bounce off what's around; the lightmaps already bounce the rest.
// GI_PROBES: the probes to read (#define before including).
const ivec3 kGiDims = ivec3(24, 24, 12);
// a mod kGiDims, also below zero (GLSL's % isn't defined there)
ivec3 giWrap(ivec3 a) { return a - kGiDims * ivec3(floor(vec3(a) / vec3(kGiDims))); }
uint giSlot(ivec3 cell) {
ivec3 s = giWrap(cell);
return uint((s.z * kGiDims.y + s.y) * kGiDims.x + s.x);
}
// Irradiance / π from L1 spherical harmonics of radiance, facing n.
vec3 giShIrradiance(GiProbe p, vec3 n) {
return max(0.282095 * p.sh[0].rgb + 0.325735 * (p.sh[1].rgb * n.y + p.sh[2].rgb * n.z + p.sh[3].rgb * n.x), vec3(0.0));
}
// The light bounced onto a surface at world (normal n, towards the eye v): the 8 probes round it,
// weighted by nearness, whether they're in front of the surface, and whether they can see it
// (what's between, by the distances each one keeps: Chebyshev, as in DDGI).
vec3 giIrradiance(vec3 world, vec3 n, vec3 v) {
float s = FRAME.gi.y;
ivec3 origin = ivec3(FRAME.giOrigin.xyz);
vec3 p = world + (n * 0.2 + v * 0.1) * s;
vec3 g = p / s - 0.5; // probe c sits at (c + 0.5) * s
ivec3 base = ivec3(floor(g));
vec3 f = g - vec3(base);
vec3 sum = vec3(0.0);
float total = 0.0;
for (int k = 0; k < 8; ++k) {
ivec3 o = ivec3(k & 1, (k >> 1) & 1, k >> 2), c = base + o;
if (any(lessThan(c, origin)) || any(greaterThanEqual(c, origin + kGiDims))) continue;
GiProbe pr = GI_PROBES.p[giSlot(c)];
if (pr.tag.w < 0.5 || ivec3(pr.tag.xyz) != c) continue;
vec3 tri = mix(1.0 - f, f, vec3(o));
float w = tri.x * tri.y * tri.z * pr.sh[0].w;
vec3 at = (vec3(c) + 0.5) * s;
vec3 toProbe = normalize(at - world);
float wrap = (dot(toProbe, n) + 1.0) * 0.5;
w *= wrap * wrap + 0.2;
vec3 d = p - at;
float r = length(d);
d /= max(r, 1e-4);
vec3 d2 = d * d;
vec2 m = vec2(d.x > 0.0 ? pr.dist[0].x : pr.dist[0].y, d.x > 0.0 ? pr.dist[1].z : pr.dist[1].w) * d2.x
+ vec2(d.y > 0.0 ? pr.dist[0].z : pr.dist[0].w, d.y > 0.0 ? pr.dist[2].x : pr.dist[2].y) * d2.y
+ vec2(d.z > 0.0 ? pr.dist[1].x : pr.dist[1].y, d.z > 0.0 ? pr.dist[2].z : pr.dist[2].w) * d2.z;
if (r > m.x) {
float variance = abs(m.y - m.x * m.x), e = r - m.x;
float cheb = variance / (variance + e * e);
w *= max(cheb * cheb * cheb, 0.02);
}
sum += giShIrradiance(pr, n) * w;
total += w;
}
return total > 1e-4 ? sum / total : vec3(0.0);
}

24
engine/shaders/hzb.frag Normal file
View file

@ -0,0 +1,24 @@
#version 460
// The hierarchical Z-buffer's base: the scene's depth shrunk to a small grid, each texel the
// farthest depth under it. The CPU reads it back and builds the rest of the pyramid (see
// Renderer::occluded). Rows match the depth buffer's on both backends.
#define KEEL_POST // screen-space: render targets, sampled clamped
#include "backend.glsl"
PUSH_BLOCK {
uint depth; // texture slot of the scene's depth
uint srcW, srcH; // its size
uint dstW, dstH; // this grid's
} pc;
layout(location = 0) out vec4 o_depth;
void main() {
ivec2 o = ivec2(gl_FragCoord.xy);
vec2 scale = vec2(pc.srcW, pc.srcH) / vec2(pc.dstW, pc.dstH);
ivec2 lo = ivec2(floor(vec2(o) * scale));
ivec2 hi = min(ivec2(ceil(vec2(o + 1) * scale)), ivec2(pc.srcW, pc.srcH));
float far = 0.0;
for (int y = lo.y; y < hi.y; ++y)
for (int x = lo.x; x < hi.x; ++x) far = max(far, fetchTex(pc.depth, ivec2(x, y)).r);
o_depth = vec4(far);
}

View file

@ -0,0 +1,99 @@
// Clustered dynamic lights for mesh.frag. Grid constants match render/clusters.h.
const uvec3 kClusters = uvec3(16, 9, 24);
const float kClusterNear = 8.0, kClusterFar = 16384.0;
uint clusterIndex(vec3 world) {
float depth = dot(world - FRAME.camPos.xyz, FRAME.camFwd.xyz);
uint z = depth <= kClusterNear ? 0u : min(uint(log(depth / kClusterNear) / log(kClusterFar / kClusterNear) * float(kClusters.z)), kClusters.z - 1u);
uvec2 tile = min(uvec2(gl_FragCoord.xy / FRAME.screen.xy * vec2(kClusters.xy)), kClusters.xy - 1u);
return (z * kClusters.y + tile.y) * kClusters.x + tile.x;
}
// 1 = lit. 3×3 bilinear PCF taps inside the light's tile of the shadow atlas.
float atlasShadow(int view, vec3 world, vec3 n, float dist) {
ShadowView sv = SHADOW_VIEW(view);
vec4 c = sv.viewProj * vec4(world + n * (0.75 + dist * 0.006), 1.0);
if (c.w <= 0.0) return 1.0;
c.xyz /= c.w;
if (any(greaterThan(abs(c.xy), vec2(1.0))) || c.z > 1.0) return 1.0;
float texel = 1.0 / FRAME.screen.w;
vec2 lo = sv.rect.xy + texel, hi = sv.rect.xy + sv.rect.zw - texel;
vec2 at = sv.rect.xy + (c.xy * 0.5 + 0.5) * sv.rect.zw;
uint atlas = uint(FRAME.screen.z);
float lit = 0.0;
for (int y = -1; y <= 1; ++y)
for (int x = -1; x <= 1; ++x) lit += shadowTap(atlas, at + vec2(x, y) * texel, lo, hi, c.z - 0.00002);
return lit / 9.0;
}
// GGX specular: height-correlated Smith visibility, Schlick Fresnel (F0: g_f0).
// Times π, because our light values are "what a white wall shows" (diffuse = albedo × light).
// Multiply by n·l and the light. rough: perceptual roughness.
// Reflectance at normal incidence for this surface (mesh.frag sets it: 0.04 for dielectrics,
// the albedo for metals).
vec3 g_f0 = vec3(0.04);
vec3 specGGX(vec3 n, vec3 l, vec3 v, float rough) {
vec3 h = normalize(l + v);
float a = max(rough * rough, 0.002), a2 = a * a;
float ndl = max(dot(n, l), 1e-4), ndv = max(dot(n, v), 1e-4), ndh = max(dot(n, h), 0.0), vdh = max(dot(v, h), 0.0);
float q = ndh * ndh * (a2 - 1.0) + 1.0;
float d = a2 / (3.14159265 * q * q);
float vis = 0.5 / (ndl * sqrt(ndv * ndv * (1.0 - a2) + a2) + ndv * sqrt(ndl * ndl * (1.0 - a2) + a2));
vec3 f = g_f0 + (1.0 - g_f0) * pow(1.0 - vdh, 5.0);
return min(3.14159265 * d * vis * f, vec3(64.0));
}
// Light reaching the surface from every dynamic light in its cluster: diffuse (returned) and
// the highlights (spec), sharper and stronger the glossier the surface.
vec3 dynamicLights(vec3 world, vec3 n, float rough, vec3 v, out uint count, out vec3 spec) {
count = 0u;
spec = vec3(0.0);
if (FRAME.camPos.w < 0.5) return vec3(0.0);
// A mirror's pass sees from somewhere else than the clusters were made for: every light.
bool every = FRAME.mirror.y > 0.5;
uvec2 cell = every ? uvec2(0u, min(uint(FRAME.camPos.w), 64u)) : CELL(clusterIndex(world));
count = cell.y;
vec3 total = vec3(0.0);
for (uint k = 0u; k < cell.y; ++k) {
Light l = LIGHT(every ? k : LIGHT_INDEX(cell.x + k));
vec3 toLight = l.posRadius.xyz - world;
float dist = length(toLight);
if (dist >= l.posRadius.w) continue;
vec3 L = toLight / max(dist, 0.001);
float ndl = dot(n, L);
if (ndl <= 0.0) continue;
float a = 1.0 - dist / l.posRadius.w;
float atten = a * a;
if (l.spot.w > -1.5) atten *= spotShape(l, dot(-L, l.spot.xyz));
if (atten <= 0.0) continue;
if (l.color.w >= 0.0) {
int view = int(l.color.w);
if (l.spot.w <= -1.5) { // point light: one atlas tile per cube face, +X -X +Y -Y +Z -Z
vec3 d = -toLight, ad = abs(d);
view += ad.x >= ad.y && ad.x >= ad.z ? (d.x > 0.0 ? 0 : 1) : ad.y >= ad.z ? (d.y > 0.0 ? 2 : 3) : (d.z > 0.0 ? 4 : 5);
}
atten *= atlasShadow(view, world, n, dist);
}
total += l.color.rgb * atten * ndl;
// Area light (a sphere of radius extra.y): the highlight comes from the point on it
// nearest the reflected ray, and widens to match (Karis 2013, "representative point").
vec3 Ls = L;
float norm = 1.0;
if (l.extra.y > 0.0) {
vec3 r = reflect(-v, n);
vec3 toRay = dot(toLight, r) * r - toLight;
vec3 closest = toLight + toRay * clamp(l.extra.y / max(length(toRay), 1e-3), 0.0, 1.0);
Ls = normalize(closest);
float a = max(rough * rough, 0.002), a2 = clamp(a + l.extra.y / (2.0 * dist), 0.0, 1.0);
norm = (a / a2) * (a / a2);
}
spec += l.color.rgb * atten * ndl * specGGX(n, Ls, v, rough) * norm;
}
return total;
}
vec3 heatmap(uint count) {
float t = clamp(float(count) / 16.0, 0.0, 1.0);
return count == 0u ? vec3(0.0, 0.0, 0.15) : mix(vec3(0.0, 0.4, 1.0), vec3(1.0, 0.1, 0.0), t);
}

376
engine/shaders/mesh.frag Normal file
View file

@ -0,0 +1,376 @@
#version 460
#include "backend.glsl"
#include "model.glsl"
#include "lights.glsl"
#include "sky.glsl"
layout(location = 0) in vec3 v_normal;
layout(location = 1) in vec2 v_uv;
layout(location = 2) in vec2 v_lightmapUv;
layout(location = 3) in vec3 v_world;
layout(location = 0) out vec4 o_color;
layout(location = 1) out vec4 o_gbuf; // view normal (octahedral), roughness, reflection weight: for ssr.frag
vec2 octEncode(vec3 n) {
n /= abs(n.x) + abs(n.y) + abs(n.z);
if (n.z < 0.0) n.xy = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0);
return n.xy * 0.5 + 0.5;
}
// 1 = sunlit. Cascade by view distance, normal offset by the cascade's texel size,
// 3×3 bilinear PCF taps (a smooth 4×4 filter).
float sunShadow(vec3 n) {
if (FRAME.sunColor.w < 0.5) return 1.0;
float depth = dot(v_world - FRAME.camPos.xyz, FRAME.camFwd.xyz);
if (depth > FRAME.splits.w) return 1.0;
int c = depth < FRAME.splits.x ? 0 : depth < FRAME.splits.y ? 1 : depth < FRAME.splits.z ? 2 : 3;
float texel = FRAME.cascadeTexel[c];
vec4 p = FRAME.cascades[c] * vec4(v_world + n * texel * 1.5, 1.0);
vec2 uv = p.xy * 0.5 + 0.5;
if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0))) || p.z > 1.0) return 1.0;
float atlasTexel = FRAME.shadow.x;
vec2 base = vec2(c & 1, c >> 1) * 0.5;
vec2 lo = base + atlasTexel, hi = base + 0.5 - atlasTexel;
vec2 at = base + uv * 0.5;
uint map = uint(FRAME.sunDir.w);
float lit = 0.0;
float soft = FRAME.shadow.w; // the sun's apparent size (r_shadowsoft); 0 = the plain 3x3 filter
if (soft <= 0.0) {
for (int y = -1; y <= 1; ++y)
for (int x = -1; x <= 1; ++x) lit += shadowTap(map, at + vec2(x, y) * atlasTexel, lo, hi, p.z - 0.0002);
lit /= 9.0;
} else {
// PCSS: how far above this point the things shading it are (blocker search), then a
// filter that widens with that gap, so shadows are sharp where they touch and soft away.
const vec2 disk[12] = vec2[](vec2(-0.326, -0.406), vec2(-0.840, -0.074), vec2(-0.696, 0.457), vec2(-0.203, 0.621),
vec2(0.962, -0.195), vec2(0.473, -0.480), vec2(0.519, 0.767), vec2(0.185, -0.893),
vec2(0.507, 0.064), vec2(0.896, 0.412), vec2(-0.322, -0.933), vec2(-0.792, -0.598));
float spin = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715)))) * 6.2831853;
mat2 rot = mat2(cos(spin), sin(spin), -sin(spin), cos(spin));
float blockers = 0.0, found = 0.0;
for (int i = 0; i < 12; ++i) {
float d = sampleTexLod(map, clamp(at + rot * disk[i] * atlasTexel * 6.0, lo, hi), 0.0).r;
if (d < p.z - 0.0002) blockers += d, found += 1.0;
}
if (found == 0.0) return 1.0;
float gap = (p.z - blockers / found) * 16000.0; // world units (the cascades' depth range)
float width = clamp(gap * 0.012 * soft / texel, 1.0, 8.0); // in shadow texels
for (int i = 0; i < 12; ++i) lit += shadowTap(map, at + rot * disk[i] * atlasTexel * width, lo, hi, p.z - 0.0002);
lit /= 12.0;
}
// Fade out over the last 10% of the shadow distance instead of a hard edge.
float fade = clamp((FRAME.splits.w - depth) / (FRAME.splits.w * 0.1), 0.0, 1.0);
return mix(1.0, lit, fade);
}
// Smooth value noise, for ripples and the ragged edges of puddles.
float hash3(vec3 p) { return fract(sin(dot(p, vec3(127.1, 311.7, 74.7))) * 43758.5453); }
float vnoise(vec3 p) {
vec3 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
return mix(mix(mix(hash3(i), hash3(i + vec3(1, 0, 0)), f.x), mix(hash3(i + vec3(0, 1, 0)), hash3(i + vec3(1, 1, 0)), f.x), f.y),
mix(mix(hash3(i + vec3(0, 0, 1)), hash3(i + vec3(1, 0, 1)), f.x), mix(hash3(i + vec3(0, 1, 1)), hash3(i + vec3(1, 1, 1)), f.x), f.y), f.z);
}
// Tangent frame from screen-space derivatives (no stored tangents needed): T along +u, B along +v.
mat3 cotangentFrame(vec3 n, vec3 p, vec2 uv) {
vec3 dp1 = dFdx(p), dp2 = dFdy(p);
vec2 duv1 = dFdx(uv), duv2 = dFdy(uv);
vec3 dp2perp = cross(dp2, n), dp1perp = cross(n, dp1);
vec3 t = dp2perp * duv1.x + dp1perp * duv2.x;
vec3 b = dp2perp * duv1.y + dp1perp * duv2.y;
float invmax = inversesqrt(max(max(dot(t, t), dot(b, b)), 1e-20));
return mat3(t * invmax, b * invmax, n);
}
// Image-based light: what the split-sum integral of GGX × Fresnel comes to (F0 scale, bias),
// fitted analytically (Karis) instead of a lookup texture.
vec2 envBRDF(float rough, float ndv) {
const vec4 c0 = vec4(-1.0, -0.0275, -0.572, 0.022), c1 = vec4(1.0, 0.0425, 1.04, -0.04);
vec4 r = rough * c0 + c1;
float a004 = min(r.x * r.x, exp2(-9.28 * ndv)) * r.x + r.y;
return vec2(-1.04, 1.04) * a004 + r.zw;
}
bool hasSky() { return FRAME.sky.x > 0.5; }
vec3 sky(vec3 dir, float level) { return skyRadiance(uint(FRAME.sky.x), FRAME.sky.y, FRAME.sky.w, dir, level); }
// The sky's light on a surface facing n (irradiance / π), from its spherical harmonics.
vec3 skyLight(vec3 n) {
return FRAME.skySH[0].rgb * 0.282095 + FRAME.skySH[1].rgb * 0.488603 * n.y + FRAME.skySH[2].rgb * 0.488603 * n.z
+ FRAME.skySH[3].rgb * 0.488603 * n.x + FRAME.skySH[4].rgb * 1.092548 * n.x * n.y + FRAME.skySH[5].rgb * 1.092548 * n.y * n.z
+ FRAME.skySH[6].rgb * 0.315392 * (3.0 * n.z * n.z - 1.0) + FRAME.skySH[7].rgb * 1.092548 * n.x * n.z
+ FRAME.skySH[8].rgb * 0.546274 * (n.x * n.x - n.y * n.y);
}
float luma(vec3 c) { return dot(c, vec3(0.2126, 0.7152, 0.0722)); }
// Height fog / aerial perspective: denser low down, coloured by the sky behind (blurred) and
// brighter towards the sun. Returns the colour seen through `dist` of it.
#ifndef KEEL_GL
#include "froxel.glsl"
#define GI_PROBES FRAME.giProbes
#include "gi.glsl"
#endif
vec3 applyFog(vec3 color, vec3 world) {
vec3 ray = world - FRAME.camPos.xyz;
float dist = length(ray);
vec3 rd = ray / max(dist, 1e-3);
if (!hasSky() || FRAME.fog.x <= 0.0) return color;
float k = FRAME.fog.y;
float start = FRAME.fog.x * exp(-k * (FRAME.camPos.z - FRAME.fog.w));
float kz = k * rd.z;
float amount = start * (abs(kz) > 1e-6 ? (1.0 - exp(-kz * dist)) / kz : dist);
float f = min(1.0 - exp(-amount), FRAME.fog.z);
vec3 fogColor = sky(normalize(vec3(rd.xy, max(rd.z, 0.05))), FRAME.sky.z) * 0.9;
if (FRAME.sunColor.w > 0.5) fogColor += FRAME.sunColor.rgb * pow(max(dot(rd, FRAME.sunDir.xyz), 0.0), 8.0) * 0.25;
return mix(color, fogColor, f);
}
vec3 ambientCube(vec3 n) {
vec3 w = n * n;
return w.x * (n.x > 0.0 ? DRAW.cube[0].rgb : DRAW.cube[1].rgb)
+ w.y * (n.y > 0.0 ? DRAW.cube[2].rgb : DRAW.cube[3].rgb)
+ w.z * (n.z > 0.0 ? DRAW.cube[4].rgb : DRAW.cube[5].rgb);
}
void main() {
// A mirror's pass: nothing behind the mirror (the wall it's on, the room beyond).
if (dot(FRAME.clip.xyz, FRAME.clip.xyz) > 0.5 && dot(v_world, FRAME.clip.xyz) < FRAME.clip.w) discard;
// The mirror itself: the picture its pass made, where this pixel is (slightly darkened, as
// real mirrors are), and nothing for the screen-space reflections to add.
if (FRAME.mirror.x > 0.5 && (pc.textures & 0xFFFFu) == uint(FRAME.mirror.x)) {
o_color = vec4(fetchTex(uint(FRAME.mirror.x), ivec2(gl_FragCoord.xy)).rgb * vec3(0.9, 0.92, 0.92), 1.0);
o_gbuf = vec4(0.5, 0.5, 1.0, 0.0);
return;
}
vec4 albedo = sampleTex(pc.textures & 0xFFFFu, v_uv);
if (albedo.a < 0.5) discard; // alpha-tested texture (fences, foliage)
vec4 tint = unpackUnorm4x8(pc.tint);
vec3 base = albedo.rgb * tint.rgb;
vec3 n = normalize(v_normal);
vec3 geo = n; // the surface itself, before relief
vec3 view = normalize(FRAME.camPos.xyz - v_world);
// Relief and gloss from the material map. Derivatives first, while control flow is uniform.
mat3 tbn = cotangentFrame(n, v_world, v_uv);
float gloss = 0.15, metal = 0.0;
uint materialMap = pc.material & 0xFFFFu, skyVisMap = pc.material >> 16;
if (materialMap != 0u) {
vec4 m = sampleTex(materialMap, v_uv);
vec2 xy = m.xy * 2.0 - 1.0; // X, Y; Z rebuilt (B holds metalness)
n = normalize(tbn * vec3(xy, sqrt(max(1.0 - dot(xy, xy), 0.0))));
metal = m.z;
gloss = m.w;
}
// Weather: how open to the sky this spot is (the lightmap's sky map; moving things: their
// sun visibility), then rain wetting it (darker, glossy) and snow lying on what faces up.
float exposed = skyVisMap != 0u ? sampleTex(skyVisMap, v_lightmapUv).r : DRAW.params.y > 0.5 ? DRAW.params.x : 0.0;
exposed = smoothstep(0.25, 0.7, exposed);
if (FRAME.weather.x > 0.0) {
float wet = FRAME.weather.x * exposed * (0.45 + 0.55 * max(geo.z, 0.0));
base *= 1.0 - 0.45 * wet;
gloss = mix(gloss, 0.93, wet * 0.85);
}
float snowCover = 0.0;
if (FRAME.weather.y > 0.0) {
float snow = FRAME.weather.y * exposed * smoothstep(0.35, 0.8, geo.z) * smoothstep(0.2, 0.6, vnoise(v_world * 0.05) * 0.5 + FRAME.weather.y);
snowCover = snow;
// Snow isn't flat white: drifts a little brighter, hollows a little bluer.
float drift = vnoise(v_world * 0.012) * 0.6 + vnoise(v_world * 0.05) * 0.4;
base = mix(base, mix(vec3(0.78, 0.82, 0.9), vec3(0.9, 0.91, 0.93), drift), snow);
gloss = mix(gloss, 0.25, snow);
metal *= 1.0 - snow;
}
int liquid = int(DRAW.params.w + 0.5);
float liquidAlpha = 1.0;
if (liquid == 1) {
// A film of liquid: thickest in the middle (UV v = 0), ragged at the edge, rippled.
float thickness = 1.0 - v_uv.y;
float ragged = vnoise(v_world * 0.35) * 0.6 + vnoise(v_world * 1.1) * 0.4;
float edge = 0.12 + 0.45 * ragged;
if (thickness < edge) discard;
liquidAlpha = smoothstep(edge, edge + 0.2, thickness);
float e = 0.05;
vec3 q = v_world * 0.6;
vec3 grad = vec3(vnoise(q + vec3(e, 0, 0)) - vnoise(q - vec3(e, 0, 0)), vnoise(q + vec3(0, e, 0)) - vnoise(q - vec3(0, e, 0)),
vnoise(q + vec3(0, 0, e)) - vnoise(q - vec3(0, 0, e))) / (2.0 * e);
n = normalize(n - (grad - n * dot(grad, n)) * 0.12);
// Deeper liquid is richer in colour (Beer–Lambert-ish); thin edges just wet the surface.
base = mix(base * 0.35, base, 0.4 + 0.6 * thickness);
gloss = 0.95;
} else if (liquid == 2) {
// A body of liquid: light passes through it, more of it where it's thicker. Looking
// straight through the middle is the longest path (rich, dense); the edges are thin.
float through = abs(dot(n, view));
base = mix(base * 1.15, base * 0.5, through);
liquidAlpha = mix(0.45, 1.0, through);
gloss = 0.92;
}
if (liquid == 4) {
// A decal (a burn mark) on a disc: matte, darkest in the middle, its edge soft and ragged.
float thickness = 1.0 - v_uv.y;
float ragged = vnoise(v_world * 0.12) * 0.6 + vnoise(v_world * 0.45) * 0.4;
liquidAlpha = smoothstep(0.0, 0.9, thickness + (ragged - 0.5) * 0.6);
gloss = 0.08;
}
if (liquid == 3) {
// Open water: waves (a few travelling swells plus noise ripples) bend the normal; looking
// straight down you see into it, at a grazing angle it mirrors the sky (Fresnel, below).
float t = DRAW.probe.z;
vec2 p = v_world.xy, g = vec2(0.0);
vec2 d1 = vec2(0.8, 0.6), d2 = vec2(-0.5, 0.86), d3 = vec2(0.2, -0.98);
g += d1 * cos(dot(p, d1) * 0.035 + t * 1.1) * 0.09;
g += d2 * cos(dot(p, d2) * 0.07 + t * 1.7) * 0.06;
g += d3 * cos(dot(p, d3) * 0.13 + t * 2.3) * 0.04;
// Two layers of ripples drifting different ways (the wind, and the surface's own churn).
float e = 0.5;
vec3 q = vec3(p * 0.09 + vec2(t * 0.25, t * 0.1), t * 0.35), q2 = vec3(p * 0.21 - vec2(t * 0.12, t * 0.3), t * 0.6);
g += vec2(vnoise(q + vec3(e, 0, 0)) - vnoise(q - vec3(e, 0, 0)), vnoise(q + vec3(0, e, 0)) - vnoise(q - vec3(0, e, 0))) * 0.3;
g += vec2(vnoise(q2 + vec3(e, 0, 0)) - vnoise(q2 - vec3(e, 0, 0)), vnoise(q2 + vec3(0, e, 0)) - vnoise(q2 - vec3(0, e, 0))) * 0.18;
n = normalize(geo + vec3(-g, 0.0));
float through = abs(dot(n, view));
base = mix(base * 1.25, base * 0.6, through);
liquidAlpha = mix(0.95, 0.62, through);
gloss = 0.97;
}
if (DRAW.params.z > 0.5) {
// Liquid: cut away above the surface; looking in through the cut, the inside (back)
// faces stand in for the flat surface.
if (dot(v_world, DRAW.plane.xyz) > DRAW.plane.w) discard;
if (!gl_FrontFacing) {
n = DRAW.plane.xyz;
base *= 1.2;
}
}
// Metals reflect in their own colour and scatter nothing: F0 is the albedo, diffuse goes.
g_f0 = mix(vec3(0.04), base, metal);
base *= 1.0 - metal;
vec3 sunDir = FRAME.sunDir.xyz;
// Relief can't light what the surface itself faces away from (no light leaking round corners).
float facing = dot(geo, sunDir) > 0.0 ? max(dot(n, sunDir), 0.0) : 0.0;
vec3 sun = FRAME.sunColor.rgb * FRAME.sunColor.w * facing;
float rough = clamp(1.0 - gloss, 0.05, 1.0);
vec3 sunSpec = FRAME.sunColor.rgb * FRAME.sunColor.w * facing * specGGX(n, sunDir, view, rough);
// How much open sky this spot sees (reflections of the sky fade with it).
float skyVis = 0.0;
// Cavities in the relief (grout, pores) get less of the soft, baked light.
float cavity = clamp(1.0 - (1.0 - dot(n, geo)) * 2.5, 0.45, 1.0);
vec3 specular = vec3(0.0), env = vec3(0.0);
bool realtime = FRAME.shadow.z > 0.5;
uint lightmap = pc.textures >> 16;
vec3 light;
if (lightmap != 0u) {
vec4 rgbm = sampleTex(lightmap, v_lightmapUv); // RGBM, range 8
vec3 baked = rgbm.rgb * rgbm.a * 8.0 * cavity;
// Night: what the sky gave (most of the light where it's open) goes; lamps stay.
baked *= 1.0 - FRAME.weather.z * 0.9 * smoothstep(0.0, 0.45, skyVisMap != 0u ? sampleTex(skyVisMap, v_lightmapUv).r : 0.0);
env = baked;
skyVis = skyVisMap != 0u ? sampleTex(skyVisMap, v_lightmapUv).r : 0.0;
if (realtime) {
float vis = facing > 0.0 ? sunShadow(geo) : 0.0;
light = baked + sun * vis;
specular = sunSpec * vis;
} else { // old maps: the sun is baked in; players and props darken it where they block it
float shade = 1.0 - FRAME.shadow.y * (1.0 - sunShadow(n)) * facing * FRAME.sunColor.w;
light = baked * shade;
}
} else if (DRAW.params.y > 0.5) {
// Moving things: baked ambient cube plus the sun. With a real-time sun the shadow map
// already has the world in it; otherwise the probe's sun visibility stands in for it.
float vis = facing > 0.0 ? sunShadow(geo) : 0.0;
vis = realtime ? vis : DRAW.params.x * vis;
env = ambientCube(n) * cavity * (1.0 - FRAME.weather.z * 0.8 * max(DRAW.params.x, 0.35));
light = env + sun * vis;
// Probes don't know where the sky is: compare their light with the open sky's.
if (hasSky()) skyVis = clamp(luma(env) / max(luma(skyLight(n)), 1e-3), 0.0, 1.0) * mix(0.6, 1.0, DRAW.params.x);
specular = sunSpec * vis;
} else if (hasSky()) {
// No baked light of its own (water, map-less things): the sky's light here and the real
// sun or moon, through its shadow — so it darkens at night like everything else.
float vis = facing > 0.0 ? sunShadow(geo) : 0.0;
env = skyLight(n);
light = env + sun * vis;
specular = sunSpec * vis;
} else {
light = vec3(sunLight(n));
}
uint count;
vec3 dynSpec;
light += dynamicLights(v_world, n, rough, view, count, dynSpec);
#ifndef KEEL_GL
if (FRAME.gi.x > 0.5) {
vec3 bounced = giIrradiance(v_world, n, view) * cavity; // their light, bounced
if (FRAME.gi.z > 0.5) { o_color = vec4(bounced * 4.0, 1.0); o_gbuf = vec4(0.0); return; }
light += bounced;
}
#endif
// Reflections (split-sum image-based light): the sky, prefiltered for this roughness, where
// the sky is open; the light arriving here stands in for the room where it isn't.
float ndv = clamp(dot(n, view), 1e-4, 1.0);
vec3 r = reflect(-view, n);
r = normalize(r + geo * max(-dot(r, geo), 0.0) * 1.05); // keep it above the surface
vec2 ab = envBRDF(rough, ndv);
float occlusion = skyVis * skyVis;
// What's around: the nearest reflection probe (env_cubemap) where there is one, else the
// light arriving here; the sky takes over as far as this spot sees it.
// (probes were baked in daylight: at night what they show goes dark too)
vec3 near = DRAW.probe.x > 0.5 ? skyRadiance(uint(DRAW.probe.x), 1.0 - 0.92 * FRAME.weather.z, 0.0, r, rough * DRAW.probe.y) : env;
vec3 surroundings = hasSky() ? mix(near, sky(r, rough * FRAME.sky.z), occlusion) : near;
vec3 reflection = surroundings * (g_f0 * ab.x + ab.y) * cavity;
vec3 color = base * light + specular + dynSpec + reflection;
// Snow glitters: here and there a crystal catches the sun (or a lamp) towards the eye.
if (snowCover > 0.05) {
vec3 cell = floor(v_world * 1.7);
float h = fract(sin(dot(cell, vec3(12.9898, 78.233, 45.164))) * 43758.5453);
float glint = step(0.975, h) * pow(max(dot(n, normalize(sunDir + view)), 0.0), 40.0);
color += (FRAME.sunColor.rgb * FRAME.sunColor.w * (facing > 0.0 ? 1.0 : 0.0) + dynSpec * 4.0) * glint * snowCover * 1.2;
}
float alpha = tint.a * liquidAlpha;
if (liquid == 3) {
// Open water barely scatters light of its own: it's what's mirrored (more the flatter
// you look: Fresnel) over what's seen through it (the bottom, more looking straight
// down), tinted by a dim veil of its own colour. Blended over what's behind it, so:
// alpha = the share it covers, colour = what covers it.
vec3 mirrored = hasSky() ? mix(env, sky(r, 0.0), occlusion) : vec3(0.45, 0.6, 0.8);
float fres = 0.02 + 0.98 * pow(1.0 - clamp(abs(dot(n, view)), 0.0, 1.0), 5.0);
float clear = 0.72 * clamp(abs(dot(geo, view)), 0.0, 1.0); // (how much of the bottom shows through)
vec3 veil = base * (light * 0.3 + vec3(0.015));
float cover = fres + (1.0 - fres) * (1.0 - clear);
color = (mirrored * fres + veil * (1.0 - fres) * (1.0 - clear)) / max(cover, 1e-3);
color += FRAME.sunColor.rgb * FRAME.sunColor.w * facing * specGGX(n, sunDir, view, 0.06) + dynSpec; // (the sun's glint, lamps')
alpha = clamp(cover, 0.0, 1.0);
} else if (liquid != 0 && liquid != 4) {
// Wet: a sharp sun glint and the sky mirrored at grazing angles, on top of what's lit.
vec3 mirrored = hasSky() ? mix(env, sky(r, 0.0), occlusion) : vec3(0.45, 0.6, 0.8);
float fres = 0.02 + 0.98 * pow(1.0 - clamp(dot(n, view), 0.0, 1.0), 5.0);
color += mirrored * fres * 0.9 + FRAME.sunColor.rgb * FRAME.sunColor.w * facing * specGGX(n, sunDir, view, 0.08);
if (liquid == 1) alpha = clamp(alpha * (0.75 + fres), 0.0, 0.95);
}
if (alpha < 0.5 && liquid != 4 && liquid != 3) {
// Glass (clear materials; denser translucent things like liquid or cracks shade normally): more reflective at grazing angles (Fresnel), plus a sharp sun glint.
vec3 v = normalize(FRAME.camPos.xyz - v_world);
vec3 nf = dot(n, v) < 0.0 ? -n : n; // the side facing us, whatever the winding
float fresnel = pow(1.0 - clamp(dot(nf, v), 0.0, 1.0), 4.0);
float glint = pow(max(dot(nf, normalize(sunDir + v)), 0.0), 150.0) * FRAME.sunColor.w;
// Glass barely scatters light itself: mostly what's behind it, plus reflections.
vec3 rg = reflect(-v, nf);
vec3 mirrored = hasSky() ? mix(env, sky(rg, 0.0), max(occlusion, 0.25)) : vec3(0.45, 0.6, 0.8);
color = color * 0.12 + FRAME.sunColor.rgb * glint * 3.0 + mirrored * fresnel * 0.8;
// A neutral sheen where it turns away from you (no colour: grey to white), so glass reads
// as glass at an angle.
color += vec3(0.06 + fresnel * 0.35) * (hasSky() ? max(occlusion, 0.4) : 0.6);
alpha = clamp(alpha * 0.6 + fresnel * 0.5 + glint, 0.0, 1.0);
}
#ifndef KEEL_GL
if (FRAME.froxel.x > 0.5) { // the fog grid: lit, shadowed fog (and the air's light)
vec4 f = froxelFog(gl_FragCoord.xy / FRAME.screen.xy, dot(v_world - FRAME.camPos.xyz, FRAME.camFwd.xyz));
color = color * f.a + f.rgb;
} else
#endif
color = applyFog(color, v_world);
o_color = vec4(color, alpha);
// View space as the post passes see it: x right, y down the screen, z forward.
vec3 fwd = FRAME.camFwd.xyz, right = normalize(cross(fwd, vec3(0.0, 0.0, 1.0))), up = cross(right, fwd);
vec3 nv = vec3(dot(n, right), -dot(n, up), dot(n, fwd));
float reflectWeight = dot(g_f0 * ab.x + ab.y, vec3(0.333)) * cavity * (liquid != 0 ? 1.5 : 1.0);
o_gbuf = vec4(octEncode(nv), rough, reflectWeight);
if (FRAME.camFwd.w > 0.5) o_color.rgb = mix(o_color.rgb, heatmap(count), 0.6);
}

18
engine/shaders/mesh.vert Normal file
View file

@ -0,0 +1,18 @@
#version 460
#include "model.glsl"
layout(location = 0) out vec3 v_normal;
layout(location = 1) out vec2 v_uv;
layout(location = 2) out vec2 v_lightmapUv;
layout(location = 3) out vec3 v_world;
void main() {
Vertex vx = VERTEX(VERTEX_ID);
mat4 model = DRAW.model;
vec4 world = model * vec4(vx.px, vx.py, vx.pz, 1.0);
v_normal = normalize(mat3(model) * vec3(vx.nx, vx.ny, vx.nz));
v_uv = vec2(vx.u, vx.v);
v_lightmapUv = vec2(vx.lu, vx.lv);
v_world = world.xyz;
gl_Position = pc.viewProj * world;
}

169
engine/shaders/model.glsl Normal file
View file

@ -0,0 +1,169 @@
#ifndef KEEL_MODEL_GLSL
#define KEEL_MODEL_GLSL
// Shared by mesh.vert, skinned.vert and mesh.frag: per-draw and per-frame data. The layouts are
// identical on both backends (render/backend.h mirrors them); only how they're reached differs.
#include "backend.glsl"
struct Vertex { float px, py, pz, nx, ny, nz, u, v, lu, lv; };
// Dynamic lights (clustered, see render/clusters.h).
struct Light {
vec4 posRadius;
vec4 color; // rgb, w = first shadow view or -1
vec4 spot; // xyz direction, w = cos(outer cone); -2 = point light
vec4 extra; // x = cos(inner cone), y = the light's size (area light radius)
};
struct ShadowView { mat4 viewProj; vec4 rect; }; // rect: atlas uv offset, size
// How much of a spot light reaches a direction at cos c from its axis. A plain spot fades from
// its inner cone to its outer one; a reflector (a flashlight, extra.z = 1) has a hot centre, a
// faint bright ring where its mirror ends, faint rings in the beam and a dim spill out to the edge.
float spotShape(Light l, float c) {
if (l.extra.z < 0.5) return smoothstep(l.spot.w, l.extra.x, c);
float outer = acos(clamp(l.spot.w, -1.0, 1.0)), inner = acos(clamp(l.extra.x, -1.0, 1.0));
float t = acos(clamp(c, -1.0, 1.0)) / max(outer, 1e-3), k = inner / max(outer, 1e-3);
float core = mix(0.14, 1.0, 1.0 - smoothstep(k * 0.5, k * 1.1, t));
float ring = exp(-pow((t - k * 1.15) * 16.0, 2.0)) * 0.22;
float rings = 0.95 + 0.05 * sin(t * 60.0);
return (core + ring) * rings * (1.0 - smoothstep(0.7, 1.0, t));
}
// Per draw: transform, baked ambient cube (+X -X +Y -Y +Z -Z), params x = sun visibility,
// y = lit by probes, z = liquid (cut at `plane`: xyz normal, w distance, world space),
// w = liquid style (1 film, 2 body; see LiquidStyle).
#define DRAW_FIELDS mat4 model; vec4 cube[6]; vec4 params; vec4 plane; vec4 probe;
// Per frame: sun and its shadow map, camera, dynamic lights.
#define FRAME_FIELDS \
mat4 cascades[4]; /* sun shadow cascades, each a quarter of the shadow map */ \
vec4 splits; /* view distance where each cascade ends */ \
vec4 cascadeTexel; /* world size of a shadow texel per cascade */ \
vec4 sunDir; /* xyz towards the sun, w = shadow map texture index */ \
vec4 sunColor; /* rgb, w = 1 when there is a sun */ \
vec4 shadow; /* x = texel size, y = strength on baked surfaces, z = 1 real-time */ \
vec4 volumetric; /* volumetric light: x strength, y reach, z air density, w sun too */ \
vec4 camPos; /* xyz, w = dynamic light count */ \
vec4 camFwd; /* xyz, w = debug view (1 = lights per cluster) */ \
vec4 screen; /* width, height, shadow atlas texture index, atlas size */ \
vec4 sky; /* x sky texture (0 = none), y intensity, z top level, w yaw */ \
vec4 fog; /* x density per unit, y height falloff per unit, z max, w base z */ \
vec4 skySH[9]; /* the sky's light: rgb spherical harmonics, irradiance / pi */ \
vec4 froxel; /* the fog grid (froxel.glsl): x 1 = on (Vulkan), y near, z far */ \
vec4 gi; /* real-time GI probes (gi.glsl): x 1 = on, y spacing */ \
vec4 giOrigin; /* the probe grid's first cell (whole numbers) */ \
vec4 mirror; /* x: the mirror's picture's texture (a mirror surface shows it); y 1: \
the mirror's own pass (every light, not this view's clusters) */ \
vec4 clip; /* the mirror's pass: what's behind its plane (xyz·p < w) is cut */ \
vec4 weather; /* x wet (rain), y snow cover, z night (0 day .. 1), w lightning flash */
#ifdef KEEL_FRAME_ONLY
// Post passes that need the frame (volumetric.frag): it and the dynamic lights. Vulkan: a
// FrameData reference in the pass's own push block (#define FRAME pc.frame there); OpenGL: the
// frame's and the lights' bindings.
#ifdef KEEL_GL
layout(std430, binding = 4) readonly buffer FrameBlock { FRAME_FIELDS uvec2 unusedRefs[6]; } u_frame;
layout(std430, binding = 5) readonly buffer Lights { Light l[]; } u_lights;
layout(std430, binding = 6) readonly buffer Cells { uvec2 c[]; } u_cells;
layout(std430, binding = 7) readonly buffer LightIndices { uint i[]; } u_lightIndices;
layout(std430, binding = 8) readonly buffer ShadowViews { ShadowView v[]; } u_shadowViews;
#define FRAME u_frame
#define LIGHT(n_) u_lights.l[n_]
#define CELL(n_) u_cells.c[n_]
#define LIGHT_INDEX(n_) u_lightIndices.i[n_]
#define SHADOW_VIEW(n_) u_shadowViews.v[n_]
#else
layout(buffer_reference, std430) readonly buffer Lights { Light l[]; };
layout(buffer_reference, std430) readonly buffer Cells { uvec2 c[]; };
layout(buffer_reference, std430) readonly buffer LightIndices { uint i[]; };
layout(buffer_reference, std430) readonly buffer ShadowViews { ShadowView v[]; };
layout(buffer_reference, std430) readonly buffer Froxels { uvec2 f[]; }; // the fog grid, integrated: rgb light, a transmittance (halves)
// A GI probe (gi.glsl): the light reaching it as L1 spherical harmonics (sh[0..3].rgb: band 0,
// then y, z, x; sh[0].w how much to trust it), the mean distance to what's around it and its
// square along ±x ±y ±z, and the world cell it holds (w 1 = filled).
struct GiProbe { vec4 sh[4]; vec4 dist[3]; vec4 tag; };
layout(buffer_reference, std430) readonly buffer GiProbes { GiProbe p[]; };
layout(buffer_reference, std430) readonly buffer FrameData {
FRAME_FIELDS
Lights lights;
Cells cells;
LightIndices lightIndices;
ShadowViews shadowViews;
Froxels froxels;
GiProbes giProbes;
};
#define LIGHT(n_) FRAME.lights.l[n_]
#define CELL(n_) FRAME.cells.c[n_]
#define LIGHT_INDEX(n_) FRAME.lightIndices.i[n_]
#define SHADOW_VIEW(n_) FRAME.shadowViews.v[n_]
#endif
#elif defined(KEEL_GL)
struct DrawData { DRAW_FIELDS };
layout(std430, binding = 0) readonly buffer Vertices { Vertex v[]; } u_vertices;
layout(std430, binding = 1) readonly buffer Skin { uvec2 s[]; } u_skin; // joints (4×u8), weights (4×u8)
layout(std430, binding = 2) readonly buffer Joints { mat4 m[]; } u_joints;
layout(std430, binding = 3) readonly buffer Draws { DrawData d[]; } u_draws;
layout(std430, binding = 4) readonly buffer FrameBlock { FRAME_FIELDS uvec2 unusedRefs[6]; } u_frame;
layout(std430, binding = 5) readonly buffer Lights { Light l[]; } u_lights;
layout(std430, binding = 6) readonly buffer Cells { uvec2 c[]; } u_cells; // first index, count
layout(std430, binding = 7) readonly buffer LightIndices { uint i[]; } u_lightIndices;
layout(std430, binding = 8) readonly buffer ShadowViews { ShadowView v[]; } u_shadowViews;
PUSH_BLOCK {
mat4 viewProj;
uint textures; // albedo | lightmap << 16
uint tint; // RGBA8
uint material; // texture: rgb tangent-space normal, a gloss (0 = flat, matte-ish) | sky visibility << 16
uint drawIndex; // into Draws
uint firstJoint; // into Joints (skinned draws)
} pc;
#define VERTEX(n_) u_vertices.v[n_]
#define SKIN(n_) u_skin.s[n_]
#define JOINT(n_) u_joints.m[pc.firstJoint + (n_)]
#define DRAW u_draws.d[pc.drawIndex]
#define FRAME u_frame
#define LIGHT(n_) u_lights.l[n_]
#define CELL(n_) u_cells.c[n_]
#define LIGHT_INDEX(n_) u_lightIndices.i[n_]
#define SHADOW_VIEW(n_) u_shadowViews.v[n_]
#else
layout(buffer_reference, std430) readonly buffer Vertices { Vertex v[]; };
layout(buffer_reference, std430) readonly buffer Skin { uvec2 s[]; }; // joints (4×u8), weights (4×u8)
layout(buffer_reference, std430) readonly buffer Joints { mat4 m[]; };
layout(buffer_reference, std430) readonly buffer DrawData { DRAW_FIELDS };
layout(buffer_reference, std430) readonly buffer Lights { Light l[]; };
layout(buffer_reference, std430) readonly buffer Cells { uvec2 c[]; }; // first index, count
layout(buffer_reference, std430) readonly buffer LightIndices { uint i[]; };
layout(buffer_reference, std430) readonly buffer ShadowViews { ShadowView v[]; };
layout(buffer_reference, std430) readonly buffer Froxels { uvec2 f[]; }; // the fog grid, integrated: rgb light, a transmittance (halves)
// A GI probe (gi.glsl): the light reaching it as L1 spherical harmonics (sh[0..3].rgb: band 0,
// then y, z, x; sh[0].w how much to trust it), the mean distance to what's around it and its
// square along ±x ±y ±z, and the world cell it holds (w 1 = filled).
struct GiProbe { vec4 sh[4]; vec4 dist[3]; vec4 tag; };
layout(buffer_reference, std430) readonly buffer GiProbes { GiProbe p[]; };
layout(buffer_reference, std430) readonly buffer FrameData {
FRAME_FIELDS
Lights lights;
Cells cells;
LightIndices lightIndices;
ShadowViews shadowViews;
Froxels froxels;
GiProbes giProbes;
};
PUSH_BLOCK {
mat4 viewProj;
Vertices vertices;
Skin skin; // skinned draws only
Joints joints; // skinned draws only; already include the model transform
DrawData draw;
FrameData frame;
uint textures; // albedo | lightmap << 16
uint tint; // RGBA8
uint material; // texture: rgb tangent-space normal, a gloss (0 = flat, matte-ish) | sky visibility << 16
} pc;
#define VERTEX(n_) pc.vertices.v[n_]
#define SKIN(n_) pc.skin.s[n_]
#define JOINT(n_) pc.joints.m[n_]
#define DRAW pc.draw
#define FRAME pc.frame
#define LIGHT(n_) pc.frame.lights.l[n_]
#define CELL(n_) pc.frame.cells.c[n_]
#define LIGHT_INDEX(n_) pc.frame.lightIndices.i[n_]
#define SHADOW_VIEW(n_) pc.frame.shadowViews.v[n_]
#endif
#endif

View file

@ -0,0 +1,21 @@
#version 460
#include "model.glsl"
layout(location = 0) out vec3 v_normal;
layout(location = 1) out vec2 v_uv;
layout(location = 2) out vec2 v_lightmapUv;
layout(location = 3) out vec3 v_world;
void main() {
Vertex vx = VERTEX(VERTEX_ID);
uvec2 sk = SKIN(VERTEX_ID);
vec4 w = unpackUnorm4x8(sk.y);
mat4 m = JOINT(sk.x & 0xFFu) * w.x + JOINT((sk.x >> 8) & 0xFFu) * w.y
+ JOINT((sk.x >> 16) & 0xFFu) * w.z + JOINT(sk.x >> 24) * w.w;
vec4 world = m * vec4(vx.px, vx.py, vx.pz, 1.0);
v_normal = normalize(mat3(m) * vec3(vx.nx, vx.ny, vx.nz));
v_uv = vec2(vx.u, vx.v);
v_lightmapUv = vec2(0.0);
v_world = world.xyz;
gl_Position = pc.viewProj * world;
}

58
engine/shaders/sky.frag Normal file
View file

@ -0,0 +1,58 @@
#version 460
#include "backend.glsl"
PUSH_BLOCK {
mat4 invViewProj;
vec4 sunDir; // xyz towards the sun
vec4 sunColor; // rgb, w = 1 when there is a sun
vec4 sky; // x texture, y intensity, z top level, w yaw
vec4 weather; // x cloud cover, y the photo's own sun's height (z; 2 = none), z night, w lightning flash
} pc;
#include "sky.glsl"
layout(location = 0) in vec2 v_ndc;
layout(location = 0) out vec4 o_color;
void main() {
vec4 far = pc.invViewProj * vec4(v_ndc, 1.0, 1.0), near = pc.invViewProj * vec4(v_ndc, 0.0, 1.0);
vec3 dir = normalize(far.xyz / far.w - near.xyz / near.w);
vec3 c = skyRadiance(uint(pc.sky.x), pc.sky.y, pc.sky.w, dir, 0.0);
// Overcast: the photo's sky greys over (a flat, brighter-low-down cloud deck).
float cloud = pc.weather.x;
// The photo's own sun (turned to the real sun's bearing, but at the height it was shot at):
// when the time of day has moved the sun away from it, it's painted over with the sky beside it.
if (pc.weather.y < 1.5 && length(pc.sunDir.xy) > 1e-3) {
vec3 photoSun = vec3(normalize(pc.sunDir.xy) * sqrt(max(1.0 - pc.weather.y * pc.weather.y, 0.0)), pc.weather.y);
float moved = 1.0 - smoothstep(0.9990, 0.9998, dot(normalize(pc.sunDir.xyz), photoSun));
float near = smoothstep(0.975, 0.997, dot(dir, photoSun)) * moved;
if (near > 0.0) { // the same height a little round to the side: clean sky like what's round it
const float t = 0.38; // ~22°
vec3 beside = vec3(dir.x * cos(t) - dir.y * sin(t), dir.x * sin(t) + dir.y * cos(t), dir.z);
c = mix(c, skyRadiance(uint(pc.sky.x), pc.sky.y, pc.sky.w, beside, 1.0), near);
}
}
// The photo blurred right out (its top level): no sun in it, for the cloud deck and the night.
vec3 soft = (cloud > 0.0 || pc.weather.z > 0.0) ? skyRadiance(uint(pc.sky.x), pc.sky.y, pc.sky.w, dir, pc.sky.z) : c;
if (cloud > 0.0) {
float l = dot(soft, vec3(0.2126, 0.7152, 0.0722));
vec3 deck = vec3(l) * vec3(0.92, 0.95, 1.0) * mix(1.0, 0.7, smoothstep(0.0, 0.6, dir.z));
c = mix(c, deck, cloud);
}
if (pc.sunColor.w > 0.5 && cloud < 0.95) {
// The sun itself (taken out of the photo so it matches the shadows): a disc and a glow.
float d = dot(dir, pc.sunDir.xyz);
c += pc.sunColor.rgb * (smoothstep(0.99995, 0.99999, d) * 60.0 + pow(max(d, 0.0), 600.0) * 1.5 + pow(max(d, 0.0), 40.0) * 0.08) * (1.0 - cloud) * (1.0 - cloud);
}
// Night: the photo's sky goes dark blue, and stars come out (fixed to the sky).
float night = pc.weather.z;
if (night > 0.0) {
c = mix(c, vec3(0.004, 0.006, 0.014) + soft * 0.15, night);
vec3 d = dir * 220.0;
vec3 cell = floor(d), f = fract(d) - 0.5;
float h = fract(sin(dot(cell, vec3(12.9898, 78.233, 37.719))) * 43758.5453);
float star = step(0.985, h) * smoothstep(0.12, 0.0, length(f)) * smoothstep(-0.05, 0.25, dir.z);
float bright = 0.3 + 0.7 * fract(h * 97.0); // stars of different brightness
c += vec3(0.8, 0.85, 1.0) * star * bright * night * 0.6;
}
c *= 1.0 + pc.weather.w * 4.0; // lightning lights the clouds
o_color = vec4(c, 1.0);
}

16
engine/shaders/sky.glsl Normal file
View file

@ -0,0 +1,16 @@
#ifndef KEEL_SKY_GLSL
#define KEEL_SKY_GLSL
// The sky (.ksky, engine/asset/ksky.h): equirectangular RGBM; level k is the sky as a surface
// of roughness k / top reflects it.
const float kSkyRange = 16.0;
// Radiance from direction `dir` (world space). yaw turns the photo so its sun lines up with
// the map's.
vec3 skyRadiance(uint tex, float intensity, float yaw, vec3 dir, float level) {
float c = cos(yaw), s = sin(yaw);
dir = vec3(c * dir.x + s * dir.y, -s * dir.x + c * dir.y, dir.z);
vec2 uv = vec2(atan(dir.y, dir.x) * 0.15915494 + 0.5, acos(clamp(dir.z, -1.0, 1.0)) * 0.31830989);
vec4 m = sampleTexLod(tex, uv, level);
return m.rgb * m.a * kSkyRange * intensity;
}
#endif

10
engine/shaders/sky.vert Normal file
View file

@ -0,0 +1,10 @@
#version 460
// The sky: a full-screen triangle on the far plane, drawn where nothing else is (depth test
// less-or-equal against the cleared 1.0).
#include "backend.glsl"
layout(location = 0) out vec2 v_ndc;
void main() {
vec2 uv = vec2((VERTEX_ID << 1) & 2, VERTEX_ID & 2);
v_ndc = uv * 2.0 - 1.0;
gl_Position = vec4(v_ndc, 1.0, 1.0);
}

View file

@ -0,0 +1,30 @@
#version 460
// Smoke in front of the sky (Vulkan): the sky pass has no fog (its photo has its own haze), so
// smoke against open sky is laid over it here, from the fog grid's smoke-only column totals
// (froxel_main.glsl: light, and how much of what's behind gets through). Drawn on the far plane
// after the sky, where nothing else is: colour × through + light.
#include "backend.glsl"
#define KEEL_FRAME_ONLY
#include "model.glsl"
layout(buffer_reference, std430) readonly buffer SmokeSky { uvec2 f[]; };
PUSH_BLOCK {
FrameData frame;
SmokeSky sky; // 160 × 90 (the fog grid's columns)
} pc;
#define FRAME pc.frame
layout(location = 0) in vec2 v_ndc;
layout(location = 0) out vec4 o_color;
const ivec2 kCols = ivec2(160, 90);
vec4 column(ivec2 c) {
uvec2 v = pc.sky.f[clamp(c.y, 0, kCols.y - 1) * kCols.x + clamp(c.x, 0, kCols.x - 1)];
return vec4(unpackHalf2x16(v.x), unpackHalf2x16(v.y));
}
void main() {
vec2 g = gl_FragCoord.xy / FRAME.screen.xy * vec2(kCols) - 0.5;
ivec2 b = ivec2(floor(g));
vec2 f = g - vec2(b);
vec4 s = mix(mix(column(b), column(b + ivec2(1, 0)), f.x), mix(column(b + ivec2(0, 1)), column(b + ivec2(1, 1)), f.x), f.y);
o_color = vec4(s.rgb, s.a); // (blended: src + dst × src.a)
}

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