![]() |
vkmEngine 1.0.0
A C++ game engine · vkmengine.com
|
The engine supports five light types: Directional, Point, Spot, Rect (rectangular area light), and Disk (disk area light). All are data-only ECS components; light evaluation lives in the PBR shader. Shadows go through a shared shadow atlas (2D, for directional and spot) and a cube map per shadowed point light. Image-based lighting is baked by a persistent GLIBLBaker helper (not a pass) from an HDR environment map or the procedural sky, and sampled by the shader through irradiance, prefilter, and BRDF LUT maps.
src/engine/ecs/component/render/light.h:
Position comes from the entity's Transform for Point, Spot, and area lights. Direction is the entity's forward, its rotated -Z axis. A spot shines along direction; an area light emits from the opposite face, along -direction - toward the rotation's local +Z.
The PBR fragment shader (shaders/forward/pbr/) implements:
Rect and Disk are evaluated using two industry-standard tricks:
intensity is the emitter's point-equivalent intensity: its radiance is intensity / area, so far away an area light lights like a point light of the same intensity and near it the form factor takes over. radius only windows the light to zero; the falloff is the emitter's own geometry.
twoSided enables emission from both faces. By default an area light only emits from the side its -direction normal points along; which side a point is on is a plane test, not the sign of the integral.
Area lights cast no shadow: the shadow planner fits cascades, spot tiles and cube faces, and nothing for Rect or Disk.
The shadow system has two depth stores sized by engine_config.h:
Shadow rendering goes through GLShadowPass, which:
Draws against per-caster lightVP matrices, biases, and atlas tile rects that GLShadowData::uploadAndBind writes into the ShadowBlock UBO before the passes run. The forward pass samples a single tiled sampler2DShadow atlas (u_shadowAtlas) - mapping each caster's UV into its tile rect and taking a 3x3 kernel of hardware depth compares, each of them bilinear over a 2x2 texel neighbourhood, so one tap returns a fraction rather than 0 or 1 - plus a small array of samplerCubeShadow maps (u_shadowCube[MAX_SHADOW_CASTERS_CUBE]), one per point-light slot. A cube face is an ordinary perspective depth map, drawn by the same programs as the atlas tiles, and a lookup rebuilds the face's projected depth from the major axis of the direction and takes one hardware compare over 2x2 texels of the face - a fraction, like a 2D tap.
A spot's tile and a point light's cube are both perspective maps, whose depth unit grows with the square of the distance from the light, so both read shadowBias one way (rayBias in shaders/shadows.glsl): the point slides toward the light along its own ray by that fraction of the range, twice it at grazing light and two fifths of it head-on, and the compare is against the depth stored there. A spot's normal offset is in texels of the map at the receiver's own distance rather than at the range. The sun's cascades are orthographic, and their bias is a depth, grown with the slope.
Soft shadows. The sun and spot lights cast percentage-closer soft shadows (Fernando 2005), sized by the light's own sourceRadius - the one control, with no switch beside it: a source of zero is a hard shadow and takes the 3x3 path. A 16-tap blocker search through the raw atlas, each tap a gather of four texels weighted bilinearly so the result does not step from texel to texel, finds the average depth of what shadows the point; the distance behind it, scaled by the source's size, is the penumbra; and a Vogel disk of hardware compares that wide filters it. A point far behind its blocker gets a wide, soft edge and a contact stays sharp, which is what a light of that size does. The taps grow with the disk - 12 for a narrow penumbra, up to 64 for the widest - so they stay SOFT_TAP_SPACING (1.25) texels apart and each tap's 2x2 compare overlaps its neighbours'. The disk is the same at every pixel: turned per pixel on noise it would be grain that only a temporal filter averages away, and this engine has none, so neighbouring pixels read nearly the same taps and the edge is a smooth ramp. Each of the sun's taps compares against the receiver's own plane at that tap rather than its depth at the point (receiverSlope), so a surface the light grazes neither finds itself in the blocker search, which would pull every penumbra toward zero, nor shadows itself under a wide filter. A spot's taps compare against the point's own depth: the plane is derived for an orthographic tile.
How wide a penumbra can be is derived, not set. A disk wider than SOFT_REACH (5.5) texels - the most 64 taps cover at that spacing - would spread its taps into dots, so the sun's path (sampleCSMSoft) runs its search and its filter each in the finest cascade that holds the point and fits the disk in that many texels: a penumbra too wide for the point's own cascade is drawn from a coarser one, whose texels are two to three times larger and which loses only detail narrower than the penumbra blurring it. The search reaches one cascade radius above the point, and the penumbra is capped at the radius it searched: past that rim the point reads lit, so a wider filter would tear a jagged edge into the penumbra. Whatever cascade is read, the depth bias is the point's own in metres - a depth unit is a cascade's whole span, so the coarse one's would lift the point clear of anything a few metres above it - and a search the coarse tile saw nothing in falls back to the point's own tile, so a caster too thin for the coarse map still shadows; that fallback keeps its full width, faint enough to hide the hard compare that admits it. A spot has one map and nothing coarser, so its search and filter stop at SOFT_SPOT_REACH (7) texels, its taps a little further apart. A point light's cube keeps its single filtered tap: a blocker search would need each cube bound a second time without comparison, and every tap walked over the slots to keep the index dynamically uniform. The fog's sun samples stay on the 3x3 kernel.
A directional light's highlight is the same disc as its penumbra. The sky's drawn sun is sized on its own (SkySettings::sunAngularRadius), so a stylised disc in the sky does not force a soft shadow, or the other way round.
The atlas is read both ways within a frame, so the comparison lives on a sampler object bound to a texture unit rather than on the texture. GLShadowAtlas::bind2D binds a comparing sampler on ShadowTextureSlots::ATLAS_2D; bind2DRaw binds a non-comparing one on ShadowTextureSlots::ATLAS_2D_RAW, for the soft path's blocker search and the ShadowAtlas debug view, both of which read stored depth through a plain sampler2D. Two units, because a sampler replaces every sampling parameter for the unit it is bound to - which is also why both samplers carry the atlas's own linear filtering and clamp.
shadowDistance controls how far the directional cascades cover in world units. It is ignored for spot, point, and area lights, which use radius as their cutoff.
A cascade hands over to the next across the last tenth of its depth (CASCADE_BLEND in shadows.glsl): a point there is read in both and the two mixed, on the hard path, the soft one and the fog alike. Read alone up to its split, a cascade would end in a line across the ground where the texel size steps, and with no temporal filter that line stands still on screen. Only the band pays for the second read.
The four cascades are split logarithmically, anchored at a fixed world distance (CASCADE_NEAR, 1 unit) rather than at the camera's near plane. The anchor is what stops shadowDistance from trading away foreground sharpness: every boundary then grows as a fixed power of the distance - the first as its fourth root - so the near cascade stays small as the dial rises: its far edge moves 2.5 -> 4.9 units across a shadowDistance of 40 -> 600. Anchored at the camera's near plane (0.2) instead, the split would put its first boundary about a metre out and spend a whole cascade on the ground at the viewer's feet.
Pulling the near cascade in leaves the outer three covering more range each, so the far field loses density as shadowDistance grows: total coverage against texel density is a fixed budget, and the logarithmic split spends it evenly instead of concentrating the shortfall in the foreground.
A persistent GLIBLBaker re-bakes when the environment changes (a helper invoked from GLBackend::render, not a pass): when Environment.sky.hdrPath (an equirectangular HDR image) is swapped, or - with the procedural sky enabled - when a sky parameter changes or the sun or the moon moves by more than about half a degree (SkyParams::SAME_DIRECTION), so a sun animated a fraction of a degree a frame is not a bake a frame. A scene that names neither, and an HDR that fails to load, leave no environment baked: the ambient term and the skybox then draw what a scene with no sky draws, rather than the last scene's sky. The reflection probes and the irradiance volume are captures of the scene under the sky they were baked with, and a sky change does not bake them again; bumping their bakeVersion does. It produces:
Where the sun is is authored on the Environment, as sunElevation and sunAzimuth in degrees, and nowhere else. The sky is scene-global and has to work whether or not a scene has a directional light, so it cannot read one; and a sky disagreeing with the light casting the shadows looks broken in a way that is hard to diagnose. SkySystem (Simulation stage) resolves that by pointing the scene's key light - findKeyLight, the lowest-slot entity carrying an enabled directional Light, which is the one definition of that rule - from those same angles. A disabled light is not the key: it lights nothing, and the frame keys its shadows by the next directional, so that is the one the sky aims.
So with the procedural sky on, the key light's rotation, colour and intensity are the sky's: all three are written every frame, the rotation from sunElevation / sunAzimuth and the other two from sky.lightColor / sky.lightIntensity by day and the night.moonlight* pair after dark.
The sunlight crosses the sky's air. By day the colour written is Atmosphere::sunlight, sky.lightColor times Atmosphere::sunTransmittance (system/sky/atmosphere.h): the sun's transmittance from the eye through the same Rayleigh and Mie layers the sky bake scatters, scaled by the scene's rayleigh and mie, divided by the transmittance straight up. A sun overhead is the authored colour; at the default 50 degrees it has lost a few per cent, blue most; at five degrees blue is down to a tenth and red to three fifths, so the light turns orange with the sky rather than staying white under it. The skybox draws its sun disc in the same sunlight, so the disc and the light it stands for agree. The atmosphere is stated once, in that header: its geometry reaches shaders/ibl/sky through the prelude as ATMOSPHERE_*, and its coefficients under the scene's scales - Atmosphere::coefficients, Mie's absorption folded into its extinction - as uniforms, so the sky bake and the CPU integration share every term of the extinction they integrate. Over the twilight band the intensity still fades to nothing at the horizon, where the light swaps to the moon. The direction is the world's, so a key light parented under something turned has its parent's world rotation divided out of the local rotation written. Author those, not the Light - an edit typed into the light is gone before the next frame draws, and the value the scene saves is the sky's. Shadow settings, the type and the enabled flag stay the light's. The editor's Light card says this on the card itself and greys the two fields it does not own, because the same findKeyLight tells it which light the sky is driving.
Below the horizon is night: the atmosphere is nearly black there, so a skyglow floor plus a moon lobe take over across a twilight band (shaders/sky.glsl, shared with the skybox so the two cannot disagree about the time of day). The band is NightSkySettings::TWILIGHT_DEGREES either side of the horizon, and the key light fades across the same band, so the sky darkens as its light does. The moon is derived - opposite the sun, tilted by moonTilt - so dropping the sun raises it. Stars are drawn by the skybox only: at 512 with prefiltered mips the env cube would smear them into a uniform glow.
These are bound to the dedicated IBL texture slots (see the binding note in Rendering). The baker is skipped when nothing changed (same env-map path; procedural sun/params unmoved): a comparison and an early-out.
The view frustum is diced into CLUSTER_X x CLUSTER_Y screen tiles by CLUSTER_Z exponential depth slices. A compute pass (ClusterCull) culls the scene's lights into each cluster's list, capped at MAX_LIGHTS_PER_CLUSTER; the forward pass then shades a pixel against its own cluster's handful rather than the whole MAX_LIGHTS upload. That is why the light cap can be generous.
The 32 x 18 split puts a tile at roughly 60px on a 1080p-class viewport. Coarser tiles make each pixel iterate lights that only clip a far corner of its tile; finer ones cost grid memory without shortening the lists, because the lights left in a cluster already overlap it. The cull pass itself is insensitive to the split.
Cross-cutting limits live in engine_config.h, and GLSL cannot read a C++ header. GLBackend::shaderConstants writes them out as GLSL declarations from the C++ constants themselves, and Vkm::GL::setShaderPrelude puts that text under the #version of every stage the loader compiles - graphics and compute alike. So a shader simply uses MAX_LIGHTS, and there is no file to include and nothing to keep in step. The binding points and texture units of gl_bindings.h arrive the same way, as #defines a layout qualifier can take (rendering.md).
The same holds for constants that are not limits: the twilight band (SKY_TWILIGHT) and the procedural atmosphere's geometry (ATMOSPHERE_*, from system/sky/atmosphere.h) reach the shaders that way too.
Do not re-define a prelude constant in a shader - GLSL rejects the redefinition, which is the check that keeps a shader from quietly holding a copy of its own.
| C++ constant (engine_config.h) | Value | Consumed by |
|---|---|---|
| Config::MAX_LIGHTS | 256 | light upload cap; forward/pbr, cluster, fog/inject |
| Config::MAX_LIGHTS_PER_CLUSTER | 64 | Forward+ per-cluster light list cap |
| Config::CLUSTER_X/Y/Z | 32 x 18 x 24 | Forward+ cluster grid dimensions (see above) |
| Config::MAX_SHADOW_CASTERS_2D | 6 | 2D shadow atlas tiles; MAX_SHADOW_CASTERS_2D in shaders/shadows.glsl |
| Config::MAX_SHADOW_CASTERS_CUBE | 2 | point-light cube maps; MAX_SHADOW_CASTERS_CUBE in shaders/shadows.glsl |
| Config::NUM_CASCADES | 4 | not mirrored to GLSL; the CSM count reaches the shader via the shadow UBO (csmCount / cascadeSplits) |
| Config::SHADOW_NEAR | 0.1 | the near plane of the spot and cube projections gl_shadow_data.cpp builds; not in the prelude, but carried to the shader in the shadow UBO (a cube's params.y), which rebuilds a face's projected depth from it. A light's range is held at twice it, and a spot's cone at a degree, so neither projection can come out degenerate |