mirror of
https://github.com/Nexoniarz/Fry3DX.git
synced 2026-11-03 12:34:27 +00:00
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:
commit
f3e8eca87b
135 changed files with 23423 additions and 0 deletions
29
engine/CMakeLists.txt
Normal file
29
engine/CMakeLists.txt
Normal 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
177
engine/asset/kmap.h
Normal 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
200
engine/asset/kmdl.h
Normal 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
25
engine/asset/ksky.h
Normal 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
334
engine/audio/audio.cpp
Normal 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
45
engine/audio/audio.h
Normal 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
309
engine/core/console.cpp
Normal 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
13
engine/core/console.h
Normal 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
322
engine/core/cvar.cpp
Normal 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
101
engine/core/cvar.h
Normal 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
33
engine/core/log.cpp
Normal 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
25
engine/core/log.h
Normal 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
21
engine/core/pch.h
Normal 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
117
engine/core/sysinfo.cpp
Normal 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, ®s[i * 4], ®s[i * 4 + 1], ®s[i * 4 + 2], ®s[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
21
engine/core/sysinfo.h
Normal 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
13
engine/core/time.h
Normal 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
394
engine/crash.cpp
Normal 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
25
engine/crash.h
Normal 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
580
engine/engine.cpp
Normal 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
62
engine/engine.h
Normal 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
212
engine/fs/vfs.cpp
Normal 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
52
engine/fs/vfs.h
Normal 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
278
engine/fs/ymp.cpp
Normal 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
57
engine/fs/ymp.h
Normal 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
171
engine/platform/input.cpp
Normal 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
35
engine/platform/input.h
Normal 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
190
engine/platform/touch.cpp
Normal 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
22
engine/platform/touch.h
Normal 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
|
||||
98
engine/platform/window.cpp
Normal file
98
engine/platform/window.cpp
Normal 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
31
engine/platform/window.h
Normal 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
240
engine/render/backend.h
Normal 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
|
||||
72
engine/render/clusters.cpp
Normal file
72
engine/render/clusters.cpp
Normal 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
45
engine/render/clusters.h
Normal 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
804
engine/render/renderer.cpp
Normal 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 = ≺
|
||||
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
260
engine/render/renderer.h
Normal 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
293
engine/render/resources.cpp
Normal 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
|
||||
38
engine/render/screenshot.cpp
Normal file
38
engine/render/screenshot.cpp
Normal 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
|
||||
11
engine/render/screenshot.h
Normal file
11
engine/render/screenshot.h
Normal 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
|
||||
858
engine/rhi/gl/gl_backend.cpp
Normal file
858
engine/rhi/gl/gl_backend.cpp
Normal 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
820
engine/rhi/gl/gl_legacy.cpp
Normal 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
|
||||
21
engine/rhi/gl/gl_loader.cpp
Normal file
21
engine/rhi/gl/gl_loader.cpp
Normal 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
87
engine/rhi/gl/gl_loader.h
Normal 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
|
||||
1641
engine/rhi/vulkan/vk_backend.cpp
Normal file
1641
engine/rhi/vulkan/vk_backend.cpp
Normal file
File diff suppressed because it is too large
Load diff
356
engine/rhi/vulkan/vk_device.cpp
Normal file
356
engine/rhi/vulkan/vk_device.cpp
Normal 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, ©);
|
||||
});
|
||||
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
|
||||
115
engine/rhi/vulkan/vk_device.h
Normal file
115
engine/rhi/vulkan/vk_device.h
Normal 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
32
engine/shaders/adapt.frag
Normal 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)));
|
||||
}
|
||||
61
engine/shaders/airlight.glsl
Normal file
61
engine/shaders/airlight.glsl
Normal 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;
|
||||
}
|
||||
|
||||
16
engine/shaders/backend.glsl
Normal file
16
engine/shaders/backend.glsl
Normal 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
|
||||
26
engine/shaders/backend_gl.glsl
Normal file
26
engine/shaders/backend_gl.glsl
Normal 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);
|
||||
}
|
||||
35
engine/shaders/backend_vk.glsl
Normal file
35
engine/shaders/backend_vk.glsl
Normal 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);
|
||||
}
|
||||
30
engine/shaders/bloom_down.frag
Normal file
30
engine/shaders/bloom_down.frag
Normal 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);
|
||||
}
|
||||
25
engine/shaders/bloom_up.frag
Normal file
25
engine/shaders/bloom_up.frag
Normal 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
20
engine/shaders/box.frag
Normal 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
26
engine/shaders/box.vert
Normal 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
59
engine/shaders/cull.comp
Normal 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
11
engine/shaders/depth.frag
Normal 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;
|
||||
}
|
||||
2
engine/shaders/froxel.comp
Normal file
2
engine/shaders/froxel.comp
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
#version 460
|
||||
#include "froxel_main.glsl"
|
||||
30
engine/shaders/froxel.glsl
Normal file
30
engine/shaders/froxel.glsl
Normal 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
|
||||
218
engine/shaders/froxel_main.glsl
Normal file
218
engine/shaders/froxel_main.glsl
Normal 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);
|
||||
}
|
||||
5
engine/shaders/froxel_rt.comp
Normal file
5
engine/shaders/froxel_rt.comp
Normal 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"
|
||||
11
engine/shaders/fullscreen.vert
Normal file
11
engine/shaders/fullscreen.vert
Normal 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
143
engine/shaders/gi.comp
Normal 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
56
engine/shaders/gi.glsl
Normal 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
24
engine/shaders/hzb.frag
Normal 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);
|
||||
}
|
||||
99
engine/shaders/lights.glsl
Normal file
99
engine/shaders/lights.glsl
Normal 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
376
engine/shaders/mesh.frag
Normal 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
18
engine/shaders/mesh.vert
Normal 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
169
engine/shaders/model.glsl
Normal 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
|
||||
21
engine/shaders/skinned.vert
Normal file
21
engine/shaders/skinned.vert
Normal 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
58
engine/shaders/sky.frag
Normal 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
16
engine/shaders/sky.glsl
Normal 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
10
engine/shaders/sky.vert
Normal 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);
|
||||
}
|
||||
30
engine/shaders/smoke_sky.frag
Normal file
30
engine/shaders/smoke_sky.frag
Normal 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)
|
||||
}
|
||||
19
engine/shaders/sprite.frag
Normal file
19
engine/shaders/sprite.frag
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#version 460
|
||||
// Particles: unlit, the texture times the colour (fire, sparks, flashes added on; smoke blended).
|
||||
#include "backend.glsl"
|
||||
PUSH_BLOCK {
|
||||
mat4 viewProj;
|
||||
vec4 posSize;
|
||||
vec4 color;
|
||||
vec4 right;
|
||||
vec4 up;
|
||||
} pc;
|
||||
layout(location = 0) in vec2 v_uv;
|
||||
layout(location = 0) out vec4 o_color;
|
||||
void main() {
|
||||
vec4 t = sampleTex(floatBitsToUint(pc.up.w), v_uv);
|
||||
// Block compression leaves a little alpha where there should be none; bright sprites
|
||||
// would show it as a square: cut it.
|
||||
float a = clamp((t.a - 0.03) / 0.97, 0.0, 1.0);
|
||||
o_color = vec4(t.rgb * pc.color.rgb, a * pc.color.a);
|
||||
}
|
||||
21
engine/shaders/sprite.vert
Normal file
21
engine/shaders/sprite.vert
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#version 460
|
||||
// Particles: a camera-facing quad per sprite, from the vertex index (6 vertices, no buffer).
|
||||
#include "backend.glsl"
|
||||
PUSH_BLOCK {
|
||||
mat4 viewProj;
|
||||
vec4 posSize; // xyz centre, w half size
|
||||
vec4 color; // rgb (HDR: can be bright), a
|
||||
vec4 right; // camera right, w = rotation (radians)
|
||||
vec4 up; // camera up, w = texture index (bits)
|
||||
} pc;
|
||||
layout(location = 0) out vec2 v_uv;
|
||||
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() {
|
||||
vec2 q = kQuad[VERTEX_ID];
|
||||
float c = cos(pc.right.w), s = sin(pc.right.w);
|
||||
vec2 r = vec2(c * q.x - s * q.y, s * q.x + c * q.y);
|
||||
vec3 world = pc.posSize.xyz + (pc.right.xyz * r.x + pc.up.xyz * r.y) * pc.posSize.w;
|
||||
v_uv = q * 0.5 + 0.5;
|
||||
v_uv.y = 1.0 - v_uv.y;
|
||||
gl_Position = pc.viewProj * vec4(world, 1.0);
|
||||
}
|
||||
63
engine/shaders/ssao.frag
Normal file
63
engine/shaders/ssao.frag
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
#version 460
|
||||
// Ambient occlusion from the depth buffer alone (Alchemy-style, half resolution): for each
|
||||
// pixel, view-space points around it in a spiral; those above the surface's plane and close
|
||||
// enough darken it. Rows are the depth buffer's own (uv from gl_FragCoord), on both backends.
|
||||
#define KEEL_POST // screen-space: render targets, sampled clamped
|
||||
#include "backend.glsl"
|
||||
PUSH_BLOCK {
|
||||
uint depth; // scene depth slot
|
||||
uint w, h; // its size
|
||||
uint outW, outH; // this target's
|
||||
float radius; // world units
|
||||
float strength;
|
||||
float pad;
|
||||
vec4 proj; // tan(half fov x), tan(half fov y), near, far
|
||||
} pc;
|
||||
layout(location = 0) out vec4 o_ao;
|
||||
|
||||
// The point seen at uv: its depth from the pixel there, and its place across the screen from
|
||||
// that same pixel's centre (not uv itself: on a slanted wall the two would disagree by a part of
|
||||
// a pixel, in a pattern that depends on how the window's size halves; stripes on walls).
|
||||
vec3 viewPos(vec2 uv) {
|
||||
ivec2 p = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1));
|
||||
float d = fetchTex(pc.depth, p).r;
|
||||
float z = pc.proj.z * pc.proj.w / (pc.proj.w - d * (pc.proj.w - pc.proj.z));
|
||||
vec2 at = (vec2(p) + 0.5) / vec2(pc.w, pc.h);
|
||||
return vec3((at * 2.0 - 1.0) * pc.proj.xy * z, z);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH);
|
||||
ivec2 full = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1));
|
||||
if (fetchTex(pc.depth, full).r >= 1.0) { o_ao = vec4(1.0); return; } // sky
|
||||
vec3 p = viewPos(uv);
|
||||
// The surface normal from neighbouring depths (the smaller step on each axis, so edges hold).
|
||||
vec2 texel = 1.0 / vec2(pc.outW, pc.outH);
|
||||
vec3 px1 = viewPos(uv + vec2(texel.x, 0)) - p, px2 = p - viewPos(uv - vec2(texel.x, 0));
|
||||
vec3 py1 = viewPos(uv + vec2(0, texel.y)) - p, py2 = p - viewPos(uv - vec2(0, texel.y));
|
||||
// The smaller step, but never one off the screen's edge (there it clamps onto this pixel,
|
||||
// a zero step, and the normal goes wrong: a dark line along the border).
|
||||
bool right = uv.x + texel.x < 1.0, left = uv.x - texel.x > 0.0, down = uv.y + texel.y < 1.0, up = uv.y - texel.y > 0.0;
|
||||
vec3 dx = !right ? px2 : !left ? px1 : dot(px1, px1) < dot(px2, px2) ? px1 : px2;
|
||||
vec3 dy = !down ? py2 : !up ? py1 : dot(py1, py1) < dot(py2, py2) ? py1 : py2;
|
||||
vec3 n = normalize(cross(dx, dy));
|
||||
if (dot(n, p) > 0.0) n = -n; // towards the camera
|
||||
// Interleaved gradient noise turns the spiral per pixel; the blur evens it out.
|
||||
float noise = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
|
||||
const int kSamples = 12;
|
||||
float occlusion = 0.0;
|
||||
for (int i = 0; i < kSamples; ++i) {
|
||||
float t = (float(i) + noise) / float(kSamples);
|
||||
float a = (float(i) * 2.39996 + noise * 6.2831853);
|
||||
float r = pc.radius * t;
|
||||
vec2 off = vec2(cos(a), sin(a)) * r / (p.z * pc.proj.xy * 2.0);
|
||||
vec2 q = uv + off;
|
||||
if (any(lessThan(q, vec2(0.0))) || any(greaterThan(q, vec2(1.0)))) continue; // off screen: nothing known there
|
||||
vec3 v = viewPos(q) - p;
|
||||
float vv = dot(v, v);
|
||||
float falloff = max(0.0, 1.0 - vv / (pc.radius * pc.radius));
|
||||
occlusion += max(0.0, dot(v, n) - 0.01 * p.z) / (vv + 1.0) * falloff;
|
||||
}
|
||||
float ao = max(0.0, 1.0 - 2.0 * occlusion / float(kSamples) * pc.radius * 0.6);
|
||||
o_ao = vec4(mix(1.0, ao * ao, pc.strength));
|
||||
}
|
||||
34
engine/shaders/ssao_blur.frag
Normal file
34
engine/shaders/ssao_blur.frag
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
#version 460
|
||||
// Smooths the occlusion (4×4, weighted by how close each sample's depth is to this pixel's).
|
||||
#define KEEL_POST // screen-space: render targets, sampled clamped
|
||||
#include "backend.glsl"
|
||||
PUSH_BLOCK {
|
||||
uint ao, depth;
|
||||
uint w, h; // depth size
|
||||
uint outW, outH; // this target's (= the occlusion's)
|
||||
float pad0, pad1;
|
||||
vec4 proj;
|
||||
} pc;
|
||||
layout(location = 0) out vec4 o_ao;
|
||||
|
||||
float linearDepth(vec2 uv) {
|
||||
ivec2 p = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1));
|
||||
float d = fetchTex(pc.depth, p).r;
|
||||
return pc.proj.z * pc.proj.w / (pc.proj.w - d * (pc.proj.w - pc.proj.z));
|
||||
}
|
||||
|
||||
void main() {
|
||||
ivec2 o = ivec2(gl_FragCoord.xy);
|
||||
vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH);
|
||||
float z = linearDepth(uv);
|
||||
float sum = 0.0, weight = 0.0;
|
||||
for (int y = -2; y < 2; ++y)
|
||||
for (int x = -2; x < 2; ++x) {
|
||||
ivec2 q = clamp(o + ivec2(x, y), ivec2(0), ivec2(pc.outW - 1, pc.outH - 1));
|
||||
float zq = linearDepth((vec2(q) + 0.5) / vec2(pc.outW, pc.outH));
|
||||
float wgt = max(0.0, 1.0 - abs(zq - z) / (0.05 * z + 1.0));
|
||||
sum += fetchTex(pc.ao, q).r * wgt;
|
||||
weight += wgt;
|
||||
}
|
||||
o_ao = vec4(weight > 0.0 ? sum / weight : 1.0);
|
||||
}
|
||||
82
engine/shaders/ssr.frag
Normal file
82
engine/shaders/ssr.frag
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
#version 460
|
||||
// Screen-space reflections (half resolution): from each pixel, the view ray mirrored about its
|
||||
// normal (the mesh pass's G-buffer) is marched through view space against the depth buffer;
|
||||
// on a hit (refined by bisection) it takes this frame's lit scene there. Fades with roughness,
|
||||
// distance, the screen's edges and rays towards the camera, so it blends into the sky and
|
||||
// probe reflections the mesh pass already gave. TAA (or the tonemap) adds it to the scene.
|
||||
// Rows are the targets' own (uv from gl_FragCoord), on both backends.
|
||||
#define KEEL_POST // screen-space: render targets, sampled clamped
|
||||
#include "backend.glsl"
|
||||
PUSH_BLOCK {
|
||||
uint depth, gbuf, scene;
|
||||
uint w, h; // full size
|
||||
uint outW, outH; // this target's
|
||||
float strength;
|
||||
vec4 proj; // tan(half fov x), tan(half fov y), near, far
|
||||
} pc;
|
||||
layout(location = 0) out vec4 o_ssr;
|
||||
|
||||
float viewZ(vec2 uv) {
|
||||
ivec2 p = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1));
|
||||
float d = fetchTex(pc.depth, p).r;
|
||||
return d >= 1.0 ? 1e9 : pc.proj.z * pc.proj.w / (pc.proj.w - d * (pc.proj.w - pc.proj.z));
|
||||
}
|
||||
vec3 viewPos(vec2 uv) { // across the screen from the centre of the pixel its depth came from (as ssao.frag)
|
||||
float z = viewZ(uv);
|
||||
ivec2 p = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1));
|
||||
vec2 at = (vec2(p) + 0.5) / vec2(pc.w, pc.h);
|
||||
return vec3((at * 2.0 - 1.0) * pc.proj.xy * z, z);
|
||||
}
|
||||
vec2 toUv(vec3 v) { return v.xy / (v.z * pc.proj.xy) * 0.5 + 0.5; }
|
||||
vec3 octDecode(vec2 e) {
|
||||
e = e * 2.0 - 1.0;
|
||||
vec3 n = vec3(e, 1.0 - abs(e.x) - abs(e.y));
|
||||
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 normalize(n);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH);
|
||||
o_ssr = vec4(0.0);
|
||||
vec4 g = fetchTex(pc.gbuf, clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1)));
|
||||
float rough = g.z, weight = g.w;
|
||||
if (weight <= 0.01 || rough > 0.65) return;
|
||||
vec3 p = viewPos(uv);
|
||||
if (p.z > 1e8) return;
|
||||
vec3 n = octDecode(g.xy);
|
||||
vec3 v = normalize(p);
|
||||
vec3 r = reflect(v, n);
|
||||
if (r.z < -0.2) return; // back at the camera: nothing on screen to see there
|
||||
// March: steps grow with distance; thickness grows with them.
|
||||
float jitter = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
|
||||
float t = 4.0 + 6.0 * jitter, stepLen = 6.0;
|
||||
const int STEPS = 40;
|
||||
vec2 hit = vec2(-1.0);
|
||||
float hitT = 0.0;
|
||||
for (int i = 0; i < STEPS; ++i) {
|
||||
vec3 q = p + r * t;
|
||||
if (q.z <= pc.proj.z) break;
|
||||
vec2 quv = toUv(q);
|
||||
if (any(lessThan(quv, vec2(0.0))) || any(greaterThan(quv, vec2(1.0)))) break;
|
||||
float sceneZ = viewZ(quv);
|
||||
float behind = q.z - sceneZ;
|
||||
if (behind > 0.0 && behind < stepLen * 2.0 + 8.0) {
|
||||
float lo = t - stepLen, hi = t; // refine between the last two steps
|
||||
for (int k = 0; k < 5; ++k) {
|
||||
float mid = (lo + hi) * 0.5;
|
||||
vec3 m = p + r * mid;
|
||||
if (m.z - viewZ(toUv(m)) > 0.0) hi = mid; else lo = mid;
|
||||
}
|
||||
hitT = hi;
|
||||
hit = toUv(p + r * hi);
|
||||
break;
|
||||
}
|
||||
t += stepLen;
|
||||
stepLen *= 1.12;
|
||||
}
|
||||
if (hit.x < 0.0) return;
|
||||
vec2 edge = smoothstep(vec2(0.0), vec2(0.12), hit) * smoothstep(vec2(0.0), vec2(0.12), 1.0 - hit);
|
||||
float fade = edge.x * edge.y * (1.0 - smoothstep(0.3, 0.65, rough)) * (1.0 - smoothstep(1500.0, 3000.0, hitT)) * smoothstep(-0.2, 0.1, r.z + 0.1);
|
||||
vec3 c = min(sampleTexLod(pc.scene, hit, 0.0).rgb, vec3(16.0));
|
||||
o_ssr = vec4(c * weight * fade * pc.strength, fade);
|
||||
}
|
||||
69
engine/shaders/taa.frag
Normal file
69
engine/shaders/taa.frag
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#version 460
|
||||
// Temporal anti-aliasing (as id Tech 6's TSSAA, in spirit): the scene is drawn a sub-pixel off
|
||||
// each frame; each pixel finds where it was last frame (its depth, last frame's camera) and
|
||||
// blends with what was there, after clamping that to the colours around it now (variance
|
||||
// clipping in YCoCg) so moved things don't leave ghosts. Weighted by brightness so highlights
|
||||
// don't flicker. Rows are the targets' own (uv from gl_FragCoord), on both backends.
|
||||
#define KEEL_POST // screen-space: render targets, sampled clamped
|
||||
#include "backend.glsl"
|
||||
#include "upsample.glsl"
|
||||
PUSH_BLOCK {
|
||||
mat4 reproject; // this frame's clip space → last frame's
|
||||
uint scene, history, depth;
|
||||
uint w, h;
|
||||
uint reset; // no usable history (first frame, resized, teleported)
|
||||
uint ssr; // reflections to add first (half size), 0 = none
|
||||
float pad1;
|
||||
} pc;
|
||||
layout(location = 0) out vec4 o_color;
|
||||
|
||||
// The scene with the half-size reflections / volumetric light on it: depth-aware for the pixel
|
||||
// itself (no lines along edges), a plain read for its neighbours (only the colour clamp uses them).
|
||||
vec3 sceneAt(ivec2 q) {
|
||||
vec3 c = fetchTex(pc.scene, q).rgb;
|
||||
if (pc.ssr != 0u) c += sampleTexLod(pc.ssr, (vec2(q) + 0.5) / vec2(pc.w, pc.h), 0.0).rgb;
|
||||
return c;
|
||||
}
|
||||
vec3 sceneAtCentre(ivec2 q) {
|
||||
vec3 c = fetchTex(pc.scene, q).rgb;
|
||||
if (pc.ssr != 0u) c += upsampleDepthAware(pc.ssr, pc.depth, q).rgb;
|
||||
return c;
|
||||
}
|
||||
|
||||
vec3 toYCoCg(vec3 c) { return vec3(dot(c, vec3(0.25, 0.5, 0.25)), dot(c, vec3(0.5, 0.0, -0.5)), dot(c, vec3(-0.25, 0.5, -0.25))); }
|
||||
vec3 fromYCoCg(vec3 c) { return vec3(c.x + c.y - c.z, c.x + c.z, c.x - c.y - c.z); }
|
||||
float weight(vec3 c) { return 1.0 / (1.0 + dot(c, vec3(0.2126, 0.7152, 0.0722))); }
|
||||
|
||||
void main() {
|
||||
ivec2 p = ivec2(gl_FragCoord.xy);
|
||||
ivec2 last = ivec2(pc.w - 1, pc.h - 1);
|
||||
vec3 cur = sceneAtCentre(p).rgb;
|
||||
if (pc.reset != 0u) { o_color = vec4(cur, 1.0); return; }
|
||||
// The colours around this pixel now (mean ± deviation), and the nearest depth among them
|
||||
// (so an edge's history comes from the thing in front).
|
||||
vec3 m1 = vec3(0.0), m2 = vec3(0.0);
|
||||
float nearest = 1.0;
|
||||
ivec2 at = p;
|
||||
for (int y = -1; y <= 1; ++y)
|
||||
for (int x = -1; x <= 1; ++x) {
|
||||
ivec2 q = clamp(p + ivec2(x, y), ivec2(0), last);
|
||||
vec3 c = toYCoCg(sceneAt(q).rgb);
|
||||
m1 += c;
|
||||
m2 += c * c;
|
||||
float d = fetchTex(pc.depth, q).r;
|
||||
if (d < nearest) nearest = d, at = q;
|
||||
}
|
||||
vec3 mu = m1 / 9.0, sigma = sqrt(abs(m2 / 9.0 - mu * mu));
|
||||
vec3 lo = mu - 1.25 * sigma, hi = mu + 1.25 * sigma;
|
||||
vec2 uv = (vec2(p) + 0.5) / vec2(pc.w, pc.h);
|
||||
vec4 prev = pc.reproject * vec4(((vec2(at) + 0.5) / vec2(pc.w, pc.h)) * 2.0 - 1.0, nearest, 1.0);
|
||||
vec2 puv = prev.xy / prev.w * 0.5 + 0.5 + (uv - (vec2(at) + 0.5) / vec2(pc.w, pc.h));
|
||||
if (any(lessThan(puv, vec2(0.0))) || any(greaterThan(puv, vec2(1.0)))) { o_color = vec4(cur, 1.0); return; }
|
||||
vec3 hist = sampleTex(pc.history, puv).rgb;
|
||||
hist = fromYCoCg(clamp(toYCoCg(hist), lo, hi));
|
||||
// Less history when things move a lot on screen (it smears), more when still.
|
||||
float moved = length((puv - uv) * vec2(pc.w, pc.h));
|
||||
float keep = mix(0.92, 0.7, clamp(moved / 24.0, 0.0, 1.0));
|
||||
float wc = (1.0 - keep) * weight(cur), wh = keep * weight(hist);
|
||||
o_color = vec4((cur * wc + hist * wh) / (wc + wh), 1.0);
|
||||
}
|
||||
78
engine/shaders/tonemap.frag
Normal file
78
engine/shaders/tonemap.frag
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
#version 460
|
||||
#define KEEL_POST // screen-space: render targets, sampled clamped
|
||||
#include "backend.glsl"
|
||||
#include "upsample.glsl"
|
||||
// HDR scene → display: ambient occlusion, bloom, exposure, ACES filmic curve, a light grade
|
||||
// (saturation, vignette), then sRGB encoding.
|
||||
PUSH_BLOCK {
|
||||
uint scene;
|
||||
float exposure;
|
||||
uint ao; // 0 = none
|
||||
uint bloom; // 0 = none
|
||||
float bloomStrength;
|
||||
float saturation;
|
||||
float vignette;
|
||||
float sharpen; // after TAA: give back a little crispness (0 = none)
|
||||
uint depth; // scene depth (1 = sky)
|
||||
uint ssr; // reflections to add (when TAA didn't), 0 = none
|
||||
uint adapt; // eye adaptation (1×1 exposure multiplier), 0 = none
|
||||
uint pad2;
|
||||
vec4 shafts; // sun shafts: the sun's uv, strength (0 = none)
|
||||
vec4 sunColor;
|
||||
} pc;
|
||||
|
||||
// Sun shafts (screen-space light scattering, GPU Gems 3 ch. 13): march towards the sun's spot
|
||||
// on screen, adding up how much open sky lies on the way; air lit by the sun glows there,
|
||||
// fading with distance from the sun. Started at a per-pixel offset so the steps don't band.
|
||||
vec3 sunShafts(vec2 uv) {
|
||||
const int N = 32;
|
||||
vec2 toSun = pc.shafts.xy - uv;
|
||||
vec2 stepUv = toSun * (0.85 / float(N));
|
||||
float jitter = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
|
||||
vec2 p = uv + stepUv * jitter;
|
||||
vec3 lit = vec3(0.0);
|
||||
float w = 1.0, total = 0.0;
|
||||
for (int i = 0; i < N; ++i, p += stepUv) {
|
||||
bool inside = all(greaterThanEqual(p, vec2(0.0))) && all(lessThanEqual(p, vec2(1.0)));
|
||||
if (inside && sampleTexLod(pc.depth, p, 0.0).r >= 1.0) // open sky: its light, the sun's brightest
|
||||
lit += max(sampleTexLod(pc.scene, p, 0.0).rgb - 1.0, vec3(0.0)) * w;
|
||||
total += w;
|
||||
w *= 0.97;
|
||||
}
|
||||
vec2 aspect = vec2(float(texSize(pc.scene).x) / float(texSize(pc.scene).y), 1.0);
|
||||
float fall = exp(-length(toSun * aspect) * 1.5);
|
||||
return min(lit / total, vec3(8.0)) * fall * pc.shafts.z;
|
||||
}
|
||||
|
||||
layout(location = 0) in vec2 v_uv;
|
||||
layout(location = 0) out vec4 o_color;
|
||||
|
||||
vec3 aces(vec3 x) {
|
||||
return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);
|
||||
}
|
||||
|
||||
vec3 toSrgb(vec3 c) {
|
||||
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c));
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 hdr = sampleTex(pc.scene, v_uv).rgb;
|
||||
const ivec2 px = ivec2(v_uv * vec2(texSize(pc.depth)));
|
||||
if (pc.ssr != 0u) hdr += upsampleDepthAware(pc.ssr, pc.depth, px).rgb;
|
||||
if (pc.sharpen > 0.0) { // unsharp mask against the 4 neighbours, limited so edges don't ring
|
||||
vec2 t = 1.0 / vec2(texSize(pc.scene));
|
||||
vec3 n = sampleTex(pc.scene, v_uv + vec2(t.x, 0)).rgb + sampleTex(pc.scene, v_uv - vec2(t.x, 0)).rgb
|
||||
+ sampleTex(pc.scene, v_uv + vec2(0, t.y)).rgb + sampleTex(pc.scene, v_uv - vec2(0, t.y)).rgb;
|
||||
hdr = max(hdr + clamp((hdr - n * 0.25) * pc.sharpen, -0.1 * (hdr + 0.05), 0.1 * (hdr + 0.05)), 0.0);
|
||||
}
|
||||
if (pc.ao != 0u) hdr *= upsampleDepthAware(pc.ao, pc.depth, px).r;
|
||||
if (pc.bloom != 0u) hdr = mix(hdr, sampleTex(pc.bloom, v_uv).rgb, pc.bloomStrength);
|
||||
if (pc.shafts.z > 0.0) hdr += sunShafts(v_uv);
|
||||
float exposure = pc.exposure * (pc.adapt != 0u ? sampleTexLod(pc.adapt, vec2(0.5), 0.0).r : 1.0);
|
||||
vec3 c = aces(hdr * exposure);
|
||||
float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
|
||||
c = max(mix(vec3(l), c, pc.saturation), 0.0);
|
||||
vec2 d = v_uv - 0.5;
|
||||
c *= 1.0 - pc.vignette * dot(d, d) * 2.0;
|
||||
o_color = vec4(toSrgb(c), 1.0);
|
||||
}
|
||||
28
engine/shaders/upsample.glsl
Normal file
28
engine/shaders/upsample.glsl
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Depth-aware ("bilateral") upsampling of a half-size target (ambient occlusion, reflections,
|
||||
// volumetric light): the four nearest texels, each weighted by how close its depth is to this
|
||||
// pixel's, so what's behind an edge doesn't bleed onto it (a plain bilinear read leaves dark or
|
||||
// bright lines along corners and silhouettes).
|
||||
float linearDepth(float d) {
|
||||
const float n = 2.0, f = 16384.0; // the camera's near and far planes (render/renderer.cpp)
|
||||
return d >= 1.0 ? f : n * f / (f - d * (f - n));
|
||||
}
|
||||
vec4 upsampleDepthAware(uint tex, uint depthTex, ivec2 px) {
|
||||
ivec2 full = texSize(depthTex), half_ = texSize(tex);
|
||||
vec2 hp = (vec2(px) + 0.5) * vec2(half_) / vec2(full) - 0.5;
|
||||
ivec2 b = ivec2(floor(hp));
|
||||
vec2 f = hp - vec2(b);
|
||||
float z = linearDepth(fetchTex(depthTex, clamp(px, ivec2(0), full - 1)).r);
|
||||
vec4 sum = vec4(0.0);
|
||||
float wsum = 0.0;
|
||||
for (int k = 0; k < 4; ++k) {
|
||||
ivec2 o = ivec2(k & 1, k >> 1);
|
||||
ivec2 t = clamp(b + o, ivec2(0), half_ - 1);
|
||||
ivec2 tf = clamp((t * full) / half_ + (full / half_) / 2, ivec2(0), full - 1); // the full-size texel it stands for
|
||||
float zt = linearDepth(fetchTex(depthTex, tf).r);
|
||||
float bil = (o.x == 1 ? f.x : 1.0 - f.x) * (o.y == 1 ? f.y : 1.0 - f.y);
|
||||
float w = bil / (0.002 + abs(zt - z) / max(z, 1.0));
|
||||
sum += fetchTex(tex, t) * w;
|
||||
wsum += w;
|
||||
}
|
||||
return wsum > 0.0 ? sum / wsum : fetchTex(tex, clamp(b, ivec2(0), half_ - 1));
|
||||
}
|
||||
56
engine/shaders/volumetric.frag
Normal file
56
engine/shaders/volumetric.frag
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
#version 460
|
||||
// Volumetric light (half resolution): the sun and the dynamic lights scattered towards the eye
|
||||
// by the air along each view ray, where they reach it (12 jittered steps between the eye and the
|
||||
// depth buffer; each takes the sun's shadow and, for every light in that spot's cluster, its
|
||||
// falloff, cone and a one-tap shadow; Henyey-Greenstein phase), so window frames cut shafts out of
|
||||
// the sun and lamps glow in the haze (like Source 2's volumetric fog). Once per pixel, whatever overdraw the scene had. Added onto the reflections
|
||||
// target (TAA, or the tonemap, adds that to the scene). Rows: the targets' own, both backends.
|
||||
#define KEEL_POST // screen-space: render targets, sampled clamped
|
||||
#include "backend.glsl"
|
||||
#define KEEL_FRAME_ONLY
|
||||
#include "model.glsl"
|
||||
PUSH_BLOCK {
|
||||
#ifndef KEEL_GL
|
||||
FrameData frame;
|
||||
#endif
|
||||
uint depth;
|
||||
uint w, h, outW, outH;
|
||||
float pad0, pad1, pad2;
|
||||
vec4 proj; // tan(half fov x), tan(half fov y), near, far
|
||||
} pc;
|
||||
#ifndef KEEL_GL
|
||||
#define FRAME pc.frame
|
||||
#endif
|
||||
layout(location = 0) out vec4 o_light;
|
||||
|
||||
#include "airlight.glsl"
|
||||
|
||||
void main() {
|
||||
o_light = vec4(0.0);
|
||||
if (FRAME.volumetric.x <= 0.0) return;
|
||||
const bool sun = FRAME.sunColor.w > 0.5 && FRAME.volumetric.w > 0.5;
|
||||
vec2 uv = gl_FragCoord.xy / vec2(pc.outW, pc.outH);
|
||||
ivec2 px = clamp(ivec2(uv * vec2(pc.w, pc.h)), ivec2(0), ivec2(pc.w - 1, pc.h - 1));
|
||||
float d = fetchTex(pc.depth, px).r;
|
||||
float z = d >= 1.0 ? 1e9 : pc.proj.z * pc.proj.w / (pc.proj.w - d * (pc.proj.w - pc.proj.z));
|
||||
// The view ray in world space (view: 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);
|
||||
vec2 s = (uv * 2.0 - 1.0) * pc.proj.xy;
|
||||
vec3 rd = normalize(fwd + right * s.x - up * s.y);
|
||||
float dist = z * length(vec3(s, 1.0));
|
||||
float len = min(dist, FRAME.volumetric.y);
|
||||
float jitter = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
|
||||
float lit = 0.0;
|
||||
vec3 lamps = vec3(0.0);
|
||||
const int N = 12;
|
||||
for (int i = 0; i < N; ++i) {
|
||||
vec3 w = FRAME.camPos.xyz + rd * len * ((float(i) + jitter) / float(N));
|
||||
if (sun) lit += sunlitAt(w);
|
||||
lamps += lightsAt(w, rd, uv);
|
||||
}
|
||||
float scattered = 1.0 - exp(-len * FRAME.volumetric.z);
|
||||
vec3 sunPart = sun ? FRAME.sunColor.rgb * (lit / float(N)) * phaseHG(dot(rd, FRAME.sunDir.xyz), 0.6) : vec3(0.0);
|
||||
// Lamps: each step's light over the stretch it stands for (the air between them is lit too).
|
||||
vec3 lampPart = lamps / float(N) * scattered * 7.0;
|
||||
o_light = vec4((sunPart * scattered + lampPart) * FRAME.volumetric.x, 0.0);
|
||||
}
|
||||
176
engine/ui/ui.cpp
Normal file
176
engine/ui/ui.cpp
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
#include "ui/ui.h"
|
||||
#include "core/log.h"
|
||||
#include "fs/vfs.h"
|
||||
|
||||
#include <imgui.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace keel::ui {
|
||||
namespace {
|
||||
ImFont* g_fonts[3] = {};
|
||||
float g_scale = 1.0f;
|
||||
|
||||
ImFont* loadFont(const char* path, float size) {
|
||||
auto data = fs::read(path);
|
||||
if (!data) {
|
||||
log::warn("Font {} not found, using the built-in one", path);
|
||||
return nullptr;
|
||||
}
|
||||
void* owned = IM_ALLOC(data->size()); // the atlas frees it
|
||||
std::memcpy(owned, data->data(), data->size());
|
||||
return ImGui::GetIO().Fonts->AddFontFromMemoryTTF(owned, int(data->size()), size);
|
||||
}
|
||||
|
||||
void theme() {
|
||||
ImGuiStyle& s = ImGui::GetStyle();
|
||||
s = ImGuiStyle();
|
||||
s.WindowRounding = 2;
|
||||
s.FrameRounding = 1;
|
||||
s.GrabRounding = 1;
|
||||
s.TabRounding = 1;
|
||||
s.ScrollbarRounding = 1;
|
||||
s.WindowBorderSize = 1;
|
||||
s.FrameBorderSize = 1;
|
||||
s.WindowPadding = ImVec2(14, 12);
|
||||
s.FramePadding = ImVec2(8, 5);
|
||||
s.ItemSpacing = ImVec2(10, 8);
|
||||
s.WindowTitleAlign = ImVec2(0.0f, 0.5f);
|
||||
ImVec4* c = s.Colors;
|
||||
auto rgb = [](int r, int g, int b, float a = 1.0f) { return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, a); };
|
||||
// Source 2013 GameUI's charcoal panels, grey frames and near-white text, with a red
|
||||
// where Source has its orange.
|
||||
const ImVec4 red = rgb(kAccent[0], kAccent[1], kAccent[2]), redLight = rgb(245, 70, 76), redDark = rgb(150, 24, 32);
|
||||
c[ImGuiCol_Text] = rgb(235, 230, 220);
|
||||
c[ImGuiCol_TextDisabled] = rgb(140, 136, 128);
|
||||
c[ImGuiCol_WindowBg] = rgb(40, 40, 38, 0.94f);
|
||||
c[ImGuiCol_ChildBg] = rgb(30, 30, 29, 0.6f);
|
||||
c[ImGuiCol_PopupBg] = rgb(36, 36, 34, 0.98f);
|
||||
c[ImGuiCol_Border] = rgb(90, 88, 82, 0.8f);
|
||||
c[ImGuiCol_FrameBg] = rgb(28, 28, 27);
|
||||
c[ImGuiCol_FrameBgHovered] = rgb(50, 49, 46);
|
||||
c[ImGuiCol_FrameBgActive] = rgb(60, 58, 54);
|
||||
c[ImGuiCol_TitleBg] = rgb(52, 51, 48);
|
||||
c[ImGuiCol_TitleBgActive] = rgb(70, 30, 32);
|
||||
c[ImGuiCol_Button] = rgb(70, 68, 63);
|
||||
c[ImGuiCol_ButtonHovered] = rgb(95, 92, 84);
|
||||
c[ImGuiCol_ButtonActive] = red;
|
||||
c[ImGuiCol_Header] = rgb(70, 68, 63);
|
||||
c[ImGuiCol_HeaderHovered] = rgb(95, 92, 84);
|
||||
c[ImGuiCol_HeaderActive] = red;
|
||||
c[ImGuiCol_Tab] = rgb(55, 54, 50);
|
||||
c[ImGuiCol_TabHovered] = rgb(95, 92, 84);
|
||||
c[ImGuiCol_TabSelected] = redDark;
|
||||
c[ImGuiCol_TabSelectedOverline] = red;
|
||||
c[ImGuiCol_SliderGrab] = red;
|
||||
c[ImGuiCol_SliderGrabActive] = redLight;
|
||||
c[ImGuiCol_CheckMark] = redLight;
|
||||
c[ImGuiCol_TextSelectedBg] = rgb(kAccent[0], kAccent[1], kAccent[2], 0.45f);
|
||||
c[ImGuiCol_NavCursor] = red;
|
||||
c[ImGuiCol_Separator] = rgb(90, 88, 82, 0.6f);
|
||||
c[ImGuiCol_ScrollbarBg] = rgb(28, 28, 27, 0.6f);
|
||||
c[ImGuiCol_ScrollbarGrab] = rgb(80, 78, 72);
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void init() {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
g_fonts[0] = loadFont("fonts/DejaVuSans.ttf", 16.0f);
|
||||
g_fonts[1] = loadFont("fonts/DejaVuSans-Bold.ttf", 16.0f);
|
||||
g_fonts[2] = loadFont("fonts/DejaVuSans-Bold.ttf", 48.0f);
|
||||
if (g_fonts[0]) io.FontDefault = g_fonts[0];
|
||||
theme();
|
||||
}
|
||||
|
||||
uint32_t accent(float alpha, float brightness) {
|
||||
return ImGui::ColorConvertFloat4ToU32(ImVec4(std::min(kAccent[0] / 255.0f * brightness, 1.0f), std::min(kAccent[1] / 255.0f * brightness, 1.0f),
|
||||
std::min(kAccent[2] / 255.0f * brightness, 1.0f), alpha));
|
||||
}
|
||||
|
||||
ImFont* font(Font f) {
|
||||
ImFont* r = g_fonts[int(f)];
|
||||
return r ? r : ImGui::GetFont();
|
||||
}
|
||||
|
||||
float scale() { return g_scale; }
|
||||
|
||||
void beginFrame(float windowHeight) {
|
||||
g_scale = std::clamp(windowHeight / 1080.0f, 0.75f, 2.5f);
|
||||
ImGui::GetStyle().FontScaleMain = g_scale;
|
||||
}
|
||||
|
||||
namespace {
|
||||
struct Popup {
|
||||
std::string title, message;
|
||||
std::vector<PopupButton> buttons;
|
||||
};
|
||||
std::vector<Popup> g_popups;
|
||||
|
||||
void finish(size_t button) {
|
||||
if (g_popups.empty()) return;
|
||||
Popup p = std::move(g_popups.front()); // the action may queue another popup
|
||||
g_popups.erase(g_popups.begin());
|
||||
if (button < p.buttons.size() && p.buttons[button].action) p.buttons[button].action();
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void popup(std::string title, std::string message, std::vector<PopupButton> buttons) {
|
||||
if (buttons.empty()) buttons.push_back({"OK", {}});
|
||||
g_popups.push_back({std::move(title), std::move(message), std::move(buttons)});
|
||||
}
|
||||
|
||||
bool popupOpen() { return !g_popups.empty(); }
|
||||
|
||||
void closePopup() {
|
||||
if (!g_popups.empty()) finish(g_popups.front().buttons.size() - 1);
|
||||
}
|
||||
|
||||
void drawPopups() {
|
||||
if (g_popups.empty()) return;
|
||||
const Popup& p = g_popups.front();
|
||||
const ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
const float s = scale();
|
||||
// Dim everything behind it, and swallow clicks meant for it.
|
||||
ImGui::SetNextWindowPos(vp->Pos);
|
||||
ImGui::SetNextWindowSize(vp->Size);
|
||||
// Keep the dim layer and then the popup on top — but never while a button is held: moving
|
||||
// focus cancels the press, so OK would never click.
|
||||
const bool refocus = !ImGui::IsAnyMouseDown();
|
||||
if (refocus) ImGui::SetNextWindowFocus();
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(0, 0, 0, 120));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
|
||||
ImGui::Begin("##popupdim", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoMove);
|
||||
ImGui::End();
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + vp->Size.x * 0.5f, vp->Pos.y + vp->Size.y * 0.5f), ImGuiCond_Always, ImVec2(0.5f, 0.5f));
|
||||
ImGui::SetNextWindowSize(ImVec2(460 * s, 0));
|
||||
if (refocus) ImGui::SetNextWindowFocus();
|
||||
ImGui::Begin("##popup", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_AlwaysAutoResize);
|
||||
const ImVec2 pos = ImGui::GetWindowPos();
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(pos, ImVec2(pos.x + ImGui::GetWindowWidth(), pos.y + 4 * s), accent());
|
||||
ImGui::Dummy(ImVec2(0, 4 * s));
|
||||
ImGui::PushFont(font(Font::Bold), 0.0f);
|
||||
ImGui::TextUnformatted(p.title.c_str());
|
||||
ImGui::PopFont();
|
||||
ImGui::Separator();
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextUnformatted(p.message.c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::Dummy(ImVec2(0, 6 * s));
|
||||
// Buttons on the right; the first is the default (Enter).
|
||||
float width = 0;
|
||||
for (const PopupButton& b : p.buttons) width += ImGui::CalcTextSize(b.label.c_str()).x + 40 * s;
|
||||
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), ImGui::GetWindowWidth() - width - ImGui::GetStyle().WindowPadding.x));
|
||||
size_t pressed = SIZE_MAX;
|
||||
for (size_t i = 0; i < p.buttons.size(); ++i) {
|
||||
if (i) ImGui::SameLine();
|
||||
if (ImGui::Button(p.buttons[i].label.c_str(), ImVec2(ImGui::CalcTextSize(p.buttons[i].label.c_str()).x + 32 * s, 0))) pressed = i;
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter)) pressed = 0;
|
||||
ImGui::End();
|
||||
if (pressed != SIZE_MAX) finish(pressed);
|
||||
}
|
||||
|
||||
} // namespace keel::ui
|
||||
38
engine/ui/ui.h
Normal file
38
engine/ui/ui.h
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
#pragma once
|
||||
// Game UI look: fonts from the game folder and a Source-like theme (dark
|
||||
// translucent panels, flat buttons, light text, red highlights) on top of Dear ImGui.
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct ImFont;
|
||||
|
||||
namespace keel::ui {
|
||||
|
||||
enum class Font { Regular, Bold, Title };
|
||||
|
||||
// After the renderer (ImGui context) and the VFS are up.
|
||||
void init();
|
||||
ImFont* font(Font f);
|
||||
// Sizes in pixels; scaled with the window height so 1080p looks like Source at 1080p.
|
||||
float scale();
|
||||
void beginFrame(float windowHeight);
|
||||
|
||||
// The highlight colour: highlights, the logo.
|
||||
inline constexpr uint8_t kAccent[3] = {227, 34, 45};
|
||||
uint32_t accent(float alpha = 1.0f, float brightness = 1.0f); // ImU32
|
||||
|
||||
// ── Popups: modal messages over everything (disconnects, errors, questions) ──
|
||||
struct PopupButton {
|
||||
std::string label;
|
||||
std::function<void()> action; // may be empty: just closes
|
||||
};
|
||||
// Queued; shown one at a time. No buttons = a single "OK".
|
||||
void popup(std::string title, std::string message, std::vector<PopupButton> buttons = {});
|
||||
bool popupOpen();
|
||||
void closePopup(); // Escape: like pressing the last button
|
||||
// The engine calls this each frame after the game's UI.
|
||||
void drawPopups();
|
||||
|
||||
} // namespace keel::ui
|
||||
Loading…
Reference in a new issue