210 lines
11 KiB
C++
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
|