Files
Strafe/include/engine/detail/scene.hpp
T
2026-10-24 12:24:05 -03:00

210 lines
11 KiB
C++

// What the renderer needs from the ECS, gathered without touching the GPU (so all of it is unit-testable),
// plus the CPU side of lighting: light budgeting, shadow-map fitting and the ambient-zone bake.
#pragma once
#include "engine/graphics.hpp"
#include <array>
#include <cstdint>
#include <memory>
#include <string>
#include <unordered_map>
#include <vector>
namespace eng::detail {
inline constexpr int kMaxLights = 16; // shaded per frame (shader array size)
inline constexpr int kMaxShadowMaps = 3; // shadow maps per frame (shader sampler count)
inline constexpr std::uint8_t kMatReceiveShadows = 1; // MeshInstance::flags
inline constexpr std::uint8_t kMatUnlit = 2;
// ---- frustum -------------------------------------------------------------------------------------
struct Frustum {
float planes[6][4] = {}; // normalised, pointing inwards
static Frustum fromViewProj(const Mat4& viewProj);
[[nodiscard]] bool sphereVisible(Vec3 center, float radius) const;
};
// ---- what is in the scene ------------------------------------------------------------------------
struct MeshInstance {
const eng::Mesh* mesh = nullptr;
const eng::Material* material = nullptr; // null: the renderer's own roughness / metallic / emission below
Mat4 world;
Vec3 center; // world-space bounding sphere
float radius = 0;
Rgba tint{255, 255, 255, 255};
std::uint8_t roughness = 217, metallic = 0, emission = 0; // 0..255 (emission: 0..4)
std::uint8_t flags = kMatReceiveShadows;
bool castShadows = true;
};
struct LightData {
LightType type = LightType::Directional;
Vec3 position{}; // world; unused for directional lights
Vec3 direction{0, -1, 0}; // the way the light travels (unit)
Vec3 color{1, 1, 1};
float intensity = 1;
float range = 20;
float cosInner = 1, cosOuter = 0; // spot cone
bool castShadows = false;
float shadowBias = 1;
};
struct TextData {
std::string value;
Vec2 position;
float size = 20;
Rgba color;
};
struct CameraData {
Vec3 position{}, forward{0, 0, -1}, up{0, 1, 0};
float fovDeg = 60, nearPlane = 0.1f, farPlane = 1000, orthoSize = 10;
bool orthographic = false;
[[nodiscard]] Mat4 view() const { return Mat4::lookAt(position, position + forward, up); }
[[nodiscard]] Mat4 projection(float aspect) const {
if (!orthographic) return Mat4::perspective(fovDeg, aspect, nearPlane, farPlane);
const float top = 0.5f * orthoSize;
return Mat4::ortho(-top * aspect, top * aspect, -top, top, nearPlane, farPlane);
}
};
struct ZoneData {
ZoneShape shape = ZoneShape::Box;
Mat4 world, inverse; // entity -> world, and back
Vec3 size{};
float radius = 0, height = 0;
std::vector<Vec2> polygon;
MeshPtr mesh;
Vec3 sky{}, ground{};
float intensity = 1;
ZoneMode mode = ZoneMode::Replace;
float falloff = 0;
int priority = 0;
};
struct SceneSnapshot {
std::vector<MeshInstance> meshes; // visible renderers (hidden/disabled ones are absent)
std::vector<LightData> lights; // visible lights; a default sun when the world has none at all
std::vector<ZoneData> zones; // enabled ambient zones
std::vector<TextData> texts; // visible HUD text, in hierarchy order
bool hasCamera = false;
CameraData camera;
Environment env; // the first enabled Environment, else the defaults
};
// Walks the hierarchy once, applying the visibility rules of Renderable (see graphics.hpp).
[[nodiscard]] SceneSnapshot gatherScene(World& world);
// ---- lights and shadows --------------------------------------------------------------------------
// The lights shaded this frame: suns first (strongest, at most 4), then the point/spot lights that matter
// most to the camera, up to kMaxLights. Lights with no brightness are dropped.
[[nodiscard]] std::vector<LightData> selectLights(const std::vector<LightData>& all, Vec3 cameraPosition);
// Shadow slot (0..kMaxShadowMaps-1) of every selected light, or -1: the first shadow-casting sun gets slot 0,
// then the shadow-casting spots in order, up to env.maxShadowLights. All -1 when env.shadows is off.
[[nodiscard]] std::vector<int> assignShadowSlots(const std::vector<LightData>& selected, const Environment& env);
struct ShadowView {
Mat4 viewProj;
Frustum frustum; // for culling shadow casters
bool perspective = false; // spot lights
float texelWorld = 0; // sun: world size of one texel. Spot: size at distance 1 (scale by distance).
float depthRange = 1; // sun: world length the depth range covers. Spot: the light's range.
};
// A square orthographic view along `lightDir` that covers the camera's view up to `shadowDistance`, with the
// depth range tightened to the casters in front of it. The window is snapped to whole texels so shadows do not
// shimmer while the camera moves or turns.
[[nodiscard]] ShadowView fitSunShadow(Vec3 lightDir, const CameraData& camera, float aspect, float shadowDistance, int resolution,
const std::vector<MeshInstance>& casters);
[[nodiscard]] ShadowView fitSpotShadow(const LightData& light, int resolution);
// ---- ambient zones -------------------------------------------------------------------------------
[[nodiscard]] bool zoneContains(const ZoneData& zone, Vec3 worldPoint); // meshes are voxelised on demand (see AmbientBaker)
// Colours are authored in the usual "screen" values (0..1) and lit in linear light, as pow(c, 2.2).
inline constexpr float kGamma = 2.2f;
[[nodiscard]] Vec3 toLinear(Vec3 c);
[[nodiscard]] Vec3 toGamma(Vec3 c);
// The 8-bit texel of a linear value in [0, 1]: exactly the byte toGamma() gives it (rounded, clamped), table-driven.
[[nodiscard]] std::uint8_t encodeLinearToByte(float linear);
// A box of grid cells, both ends included, in the grid's own cell coordinates (0 .. n - 1 on every axis).
struct CellBox {
int x0 = 0, y0 = 0, z0 = 0;
int x1 = -1, y1 = -1, z1 = -1;
};
struct AmbientGrid {
bool valid = false; // false: no zones, shade with the Environment's ambient everywhere
Vec3 origin{}; // world position of the grid's minimum corner
float cell = 1; // world units per cell
int nx = 0, ny = 0, nz = 0;
float colorScale = 1; // linear light is stored as toGamma(value / colorScale): 8 bits are spent
// where the eye needs them, and zones can go above 1
std::vector<std::uint8_t> sky, ground; // RGBA8, index (y * nz + z) * nx + x
// The same data laid out for a 2D texture: one nx*nz tile per Y slice, tilesX tiles per row.
int tilesX = 1, tilesY = 1;
std::vector<std::uint8_t> atlasSky, atlasGround; // (nx * tilesX) x (nz * tilesY) RGBA8
// What the last update() rewrote, so the GPU copy can be refreshed without re-sending everything: either all of
// it (a new grid, a new layout or a new colour scale), or just the boxes of cells whose texels changed (a zone
// that walks with the player touches a few thousand cells, not the whole grid).
bool updateAll = false;
std::vector<CellBox> updated;
[[nodiscard]] int atlasWidth() const { return nx * tilesX; }
[[nodiscard]] int atlasHeight() const { return nz * tilesY; }
// Reference sampling of the baked field, in linear light: trilinear on the stored (encoded) texels and decoded
// afterwards, exactly what the pixel shader does on the atlas.
void sample(Vec3 worldPoint, Vec3& sky, Vec3& ground) const;
};
// What the last AmbientBaker::update() did. A bake costs what changed, not the size of the scene: moving one zone
// recomputes that zone's layer and rewrites the texels it touched, nothing else.
struct BakeStats {
int zones = 0; // usable zones blended
int layersComputed = 0; // zones whose reach (inside test + fade-out distances) had to be computed
int layersReused = 0; // zones that kept their cached reach
std::size_t cellsBlended = 0; // grid cells whose colour was worked out again (the part of the grid a changed zone can reach)
std::size_t cellsEncoded = 0; // grid cells whose texels (sky and ground) were rewritten
bool full = false; // every texel was rewritten: the first bake, a different grid, or a new colour scale
};
// Bakes AmbientZone components into a grid and re-bakes only when their inputs change.
//
// The grid lies on a lattice fixed in the world (cell i covers [i * cell, (i + 1) * cell)) and every zone keeps its own
// cached layer (where it is and how its fade-out falls off), keyed by its shape and transform. A bake therefore
// * recomputes the layers of the zones that changed (an unchanged zone is a cache hit, even when the grid grows
// or shrinks around it),
// * works out the colour again only where a changed zone was or is (the cells its old and its new layer cover; every
// other cell keeps the colour of the previous bake, which is what a bake from scratch would give it too), and
// * rewrites only the texels whose colour came out different from the previous bake.
// So the cost of moving one zone follows the size of that zone, not the size of the grid.
// The grid is always exactly what a bake from scratch would give: its extent is a function of the zones alone (their
// bounds, rounded out to blocks of cells so that a zone that walks does not resize the grid on every step), and the
// colour scale is a function of the brightest cell alone (rounded up to a step of a ladder, so that a bright zone that
// overlaps another one as it moves does not re-encode every texel on every step).
class AmbientBaker {
public:
AmbientBaker();
~AmbientBaker();
// Returns true when the grid's texels (or its size) are not what they were, so a GPU copy must be refreshed. An
// input that changed without changing a single texel (a zone moving by less than it takes to flip one) returns false.
bool update(const std::vector<ZoneData>& zones, const Environment& env);
[[nodiscard]] const AmbientGrid& grid() const { return grid_; }
// Voxelisation of closed meshes is cached by mesh id; this is how many are held.
[[nodiscard]] std::size_t cachedMeshVolumes() const;
// Zone layers held for reuse: one per distinct zone (shape + transform) of the last bake, never more.
[[nodiscard]] std::size_t cachedLayers() const;
// What the most recent update() call did (all zero when it returned false: nothing had changed).
[[nodiscard]] const BakeStats& lastBake() const { return stats_; }
private:
struct Impl;
std::unique_ptr<Impl> impl_;
AmbientGrid grid_;
std::uint64_t hash_ = 0;
BakeStats stats_;
};
} // namespace eng::detail