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

324 lines
14 KiB
C++

// Meshes and the components the renderer understands. Nothing here depends on raylib/OpenGL,
// so it is usable (and testable) headless. Create meshes through Resources (resources.hpp) so
// identical meshes are shared instead of duplicated.
#pragma once
#include "ecs.hpp"
#include "material.hpp"
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
namespace eng {
// Unindexed triangle list: vertices i*3 .. i*3+2 form triangle i, counter-clockwise = front face.
struct MeshData {
std::vector<Vec3> positions;
std::vector<Vec3> normals;
std::vector<Rgba> colors;
// Optional: one per vertex, or none at all (empty). (0, 0) is the top-left corner of a texture, v grows downwards.
// Without them a material maps its textures from the world instead (UvMode::Auto, see material.hpp).
std::vector<Vec2> texcoords;
[[nodiscard]] std::size_t vertexCount() const { return positions.size(); }
[[nodiscard]] std::size_t triangleCount() const { return positions.size() / 3; }
[[nodiscard]] bool empty() const { return positions.empty(); }
[[nodiscard]] bool hasTexcoords() const { return !positions.empty() && texcoords.size() == positions.size(); }
void addTriangle(Vec3 a, Vec3 b, Vec3 c, Vec3 na, Vec3 nb, Vec3 nc, Rgba ca, Rgba cb, Rgba cc) {
positions.insert(positions.end(), {a, b, c});
normals.insert(normals.end(), {na, nb, nc});
colors.insert(colors.end(), {ca, cb, cc});
if (!texcoords.empty()) texcoords.resize(positions.size()); // a mesh that has texture coordinates keeps one per vertex
}
void addTriangle(Vec3 a, Vec3 b, Vec3 c, Vec3 na, Vec3 nb, Vec3 nc, Rgba ca, Rgba cb, Rgba cc, Vec2 ta, Vec2 tb, Vec2 tc) {
texcoords.resize(positions.size()); // triangles added without them so far get (0, 0)
positions.insert(positions.end(), {a, b, c});
normals.insert(normals.end(), {na, nb, nc});
colors.insert(colors.end(), {ca, cb, cc});
texcoords.insert(texcoords.end(), {ta, tb, tc});
}
void append(const MeshData& o) {
const std::size_t had = positions.size();
positions.insert(positions.end(), o.positions.begin(), o.positions.end());
normals.insert(normals.end(), o.normals.begin(), o.normals.end());
colors.insert(colors.end(), o.colors.begin(), o.colors.end());
if (!texcoords.empty() || !o.texcoords.empty()) { // whichever side has them: the other one's vertices get (0, 0)
texcoords.resize(had);
texcoords.insert(texcoords.end(), o.texcoords.begin(), o.texcoords.end());
texcoords.resize(positions.size());
}
}
[[nodiscard]] Aabb bounds() const {
Aabb b;
for (const Vec3& p : positions) b.expand(p);
return b;
}
// Signed volume of a closed, outward-facing mesh (divergence theorem).
[[nodiscard]] double signedVolume() const {
double v = 0;
for (std::size_t i = 0; i + 2 < positions.size(); i += 3)
v += static_cast<double>(dot(positions[i], cross(positions[i + 1], positions[i + 2]))) / 6.0;
return v;
}
};
// Immutable, shareable mesh. The renderer keeps one GPU copy per Mesh no matter how many
// entities use it (and draws them with GPU instancing); physics shares collision shapes too.
class Mesh;
using MeshPtr = std::shared_ptr<const Mesh>;
class Mesh {
public:
explicit Mesh(MeshData data);
static MeshPtr create(MeshData data); // a unique mesh (not cached)
[[nodiscard]] const MeshData& data() const noexcept { return data_; }
[[nodiscard]] const Aabb& bounds() const noexcept { return bounds_; }
[[nodiscard]] std::uint64_t id() const noexcept { return id_; } // unique for the process lifetime
private:
MeshData data_;
Aabb bounds_;
std::uint64_t id_;
};
// ---- components ---------------------------------------------------------------------------
// Base of every component that draws something (or lights the scene).
//
// Hiding is purely visual: scripts keep updating, physics keeps simulating, colliders keep colliding
// (use Entity::setEntityEnabled() to switch an entity off completely). A hidden mesh also casts no
// shadow and a hidden light is off.
//
// visible this component draws nothing while false
// childrenVisible false hides every entity BELOW this one as well; this component itself is
// controlled by `visible`, so a parent can stay visible while its subtree is hidden
//
// Entity::setVisible(false) is the one-call version (hides the entity and everything below it); it works
// on plain group entities too, by giving them a Visibility component.
class Renderable : public Component {
public:
bool visible = true;
bool childrenVisible = true;
// Hides/shows this component and, by default, everything below its entity.
void setVisible(bool show, bool includeChildren = true) {
visible = show;
if (includeChildren) childrenVisible = show;
}
// `visible` and no ancestor hides its children.
[[nodiscard]] bool visibleInHierarchy() const;
};
// Draws nothing: gives a plain group entity the same visibility switches as a renderer, so it can hide
// its subtree (Entity::setVisible adds one automatically).
class Visibility : public Renderable {};
class MeshRenderer : public Renderable {
public:
MeshPtr mesh;
Rgba tint{255, 255, 255, 255}; // multiplied with the vertex colours (and with a material's albedo)
// Surface. The defaults give a matte, non-glowing material.
float roughness = 0.85f; // 0 = mirror-like highlights .. 1 = fully diffuse
float metallic = 0.0f; // 0 = plastic/stone/wood .. 1 = metal (the colour tints the reflection)
float emission = 0.0f; // self-lit: adds colour * emission regardless of lights (glowing: 1..3)
// A material (material.hpp) with textures and more properties. While it is set, it decides the surface: the three
// numbers above are not used (the tint still is). Share one material between as many renderers as you like.
MaterialPtr material;
bool castShadows = true;
bool receiveShadows = true;
bool unlit = false; // ignore all lighting and draw the plain colour
};
// run() renders with the first enabled Camera. It looks down the entity's -Z.
class Camera : public Component {
public:
float fovDeg = 60.0f; // vertical field of view (perspective)
float nearPlane = 0.1f;
float farPlane = 1000.0f;
bool orthographic = false;
float orthoSize = 10.0f; // vertical extent in world units when orthographic
};
enum class LightType : std::uint8_t {
Directional, // the sun: parallel rays along the entity's -Z, position ignored
Point, // radiates from the entity's position
Spot, // cone from the entity's position along its -Z
};
// A light source. Aim directional/spot lights with Transform::lookAt / rotateTo. A world without any
// Light gets a default sun. Every light is a Renderable: a hidden light is off.
//
// Brightness: for the sun, `intensity` 1 lights a surface facing it with its plain colour. Point and spot
// lights fall off with the inverse square of the distance (smoothly reaching zero at `range`), so give
// them more: `intensity` is the brightness one world unit away.
class Light : public Renderable {
public:
LightType type = LightType::Directional;
Vec3 color{1.0f, 0.98f, 0.92f};
float intensity = 1.0f;
float range = 20.0f; // Point/Spot: distance at which the light has faded to nothing
float spotAngleDeg = 50.0f; // Spot: full cone angle
float spotSoftness = 0.25f; // Spot: 0 = hard edge .. 1 = fades from the axis outwards
bool castShadows = true; // Directional and Spot lights (see Environment::maxShadowLights)
float shadowBias = 1.0f; // multiplier on the automatic depth bias: raise for acne, lower for floating shadows
static Light directional(Vec3 color = {1.0f, 0.98f, 0.92f}, float intensity = 1.0f) {
Light l;
l.color = color;
l.intensity = intensity;
return l;
}
static Light point(Vec3 color, float intensity = 30.0f, float range = 15.0f) {
Light l;
l.type = LightType::Point;
l.color = color;
l.intensity = intensity;
l.range = range;
l.castShadows = false;
return l;
}
static Light spot(Vec3 color, float intensity = 60.0f, float range = 25.0f, float angleDeg = 50.0f) {
Light l;
l.type = LightType::Spot;
l.color = color;
l.intensity = intensity;
l.range = range;
l.spotAngleDeg = angleDeg;
return l;
}
};
enum class ToneMapping : std::uint8_t { None, Reinhard, Aces };
// Scene-wide look: ambient light, exposure, fog and shadow quality. The first enabled Environment
// in the world is used; without one the defaults below apply.
class Environment : public Component {
public:
// Hemisphere ambient: `ambientSky` lights surfaces facing up, `ambientGround` surfaces facing down.
// AmbientZone components override this inside their region.
Vec3 ambientSky{0.42f, 0.47f, 0.56f};
Vec3 ambientGround{0.20f, 0.18f, 0.16f};
float ambientIntensity = 1.0f;
float exposure = 1.0f;
ToneMapping toneMapping = ToneMapping::Aces;
// Distance fog (exponential squared); 0 = off.
Vec3 fogColor{0.62f, 0.70f, 0.82f};
float fogDensity = 0.0f;
// Shadows
bool shadows = true;
int shadowResolution = 2048; // texels per side, rounded to a power of two in [256, 4096]
float shadowDistance = 60.0f; // how far from the camera the sun's shadow reaches
float shadowSoftness = 1.0f; // filter radius in texels (0 = hard edges)
float shadowStrength = 1.0f; // 0 = no visible shadow .. 1 = fully dark
int maxShadowLights = 3; // shadow maps per frame: the sun first, then the nearest shadowing spots
// Ambient zones are baked into a 3D grid this fine (world units per cell), coarsened automatically
// when the zones span more than `zoneMaxCells` cells.
float zoneCellSize = 0.5f;
int zoneMaxCells = 262144;
};
enum class ZoneShape : std::uint8_t {
Box, // `size`, centred on the entity
Sphere, // `radius`, centred on the entity (an ellipsoid when the entity is scaled)
Capsule, // `radius` and `height` (end to end) along local Y, centred
Cylinder, // `radius` and `height` along local Y, centred
Polygon, // `polygon` on the local XZ plane, extruded from y = 0 up to `height`
Mesh, // any closed mesh in local space (e.g. an extruded SVG shape)
};
enum class ZoneMode : std::uint8_t {
Replace, // the zone's ambient takes the place of what is below it
Add, // adds its ambient to what is below it
Multiply, // tints/darkens what is below it (1 = unchanged)
};
// A region of space with its own ambient light.
//
// The shape lives in the entity's local space, so moving, rotating or scaling the entity moves, turns
// and resizes the zone. It can be a box, sphere, capsule, cylinder, any polygon or any closed mesh, and
// fades in over `falloff` world units outside its boundary (0 = hard edge). Overlapping zones are applied
// in `priority` order (the highest ends up on top); where no zone applies the Environment's ambient is used.
// Zones are baked into a grid (see Environment::zoneCellSize); the light is evaluated per pixel, so large
// meshes crossing a boundary shade correctly. Disable the component to switch a zone off. Moving, resizing or
// recolouring a zone re-bakes only what it touches (about a millisecond on a level of ~90,000 cells), so zones
// that follow a player or breathe are fine; a bigger grid (smaller zoneCellSize, larger world) costs more.
class AmbientZone : public Component {
public:
ZoneShape shape = ZoneShape::Box;
Vec3 size{4.0f, 3.0f, 4.0f};
float radius = 2.0f;
float height = 4.0f;
std::vector<Vec2> polygon;
MeshPtr mesh;
Vec3 sky{0.42f, 0.47f, 0.56f}; // ambient from above inside the zone
Vec3 ground{0.20f, 0.18f, 0.16f}; // ambient from below inside the zone
float intensity = 1.0f;
ZoneMode mode = ZoneMode::Replace;
float falloff = 1.0f; // world units over which the zone fades in outside its boundary
int priority = 0;
static AmbientZone box(Vec3 size, Vec3 sky, Vec3 ground) {
AmbientZone z;
z.shape = ZoneShape::Box;
z.size = size;
z.sky = sky;
z.ground = ground;
return z;
}
static AmbientZone sphere(float radius, Vec3 sky, Vec3 ground) {
AmbientZone z;
z.shape = ZoneShape::Sphere;
z.radius = radius;
z.sky = sky;
z.ground = ground;
return z;
}
static AmbientZone polygonal(std::vector<Vec2> outline, float height, Vec3 sky, Vec3 ground) {
AmbientZone z;
z.shape = ZoneShape::Polygon;
z.polygon = std::move(outline);
z.height = height;
z.sky = sky;
z.ground = ground;
return z;
}
static AmbientZone fromMesh(MeshPtr closedMesh, Vec3 sky, Vec3 ground) {
AmbientZone z;
z.shape = ZoneShape::Mesh;
z.mesh = std::move(closedMesh);
z.sky = sky;
z.ground = ground;
return z;
}
};
// Screen-space text drawn on top of the 3D scene (HUDs, debug read-outs).
class Text : public Renderable {
public:
std::string value;
Vec2 position{10, 10}; // pixels from the top-left corner
float size = 20; // pixels
Rgba color{20, 20, 30, 255};
};
// Calls f(Renderable&) for every renderer-type component on `e` (the renderer's visibility rules read
// these). Add new Renderable component types here.
template <class F> void forEachRenderable(Entity e, F&& f) {
if (MeshRenderer* c = e.tryGet<MeshRenderer>()) f(static_cast<Renderable&>(*c));
if (Light* c = e.tryGet<Light>()) f(static_cast<Renderable&>(*c));
if (Text* c = e.tryGet<Text>()) f(static_cast<Renderable&>(*c));
if (Visibility* c = e.tryGet<Visibility>()) f(static_cast<Renderable&>(*c));
}
} // namespace eng