mirror of
https://github.com/Nexoniarz/Fry3DX.git
synced 2026-11-03 04:24:26 +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
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
|
||||
Loading…
Reference in a new issue