okinawaengine GitHub

Lighting

OkLighting is the scene's global lighting and atmosphere handler. Its first layer is atmosphere: a day clock driving fog and a global scene tint, so the world reads as morning, noon, sunset or night before any light source exists.

The day clock

The hour of day (0-24) lives in the config as lighting.time, advancing every frame at lighting.timescale times real speed (default 30: a full day in 48 real minutes; 0 freezes the clock). Both are plain config values, so they are scriptable and console-reachable; two commands wrap them:

time            # print the current hour
time 21.5       # jump to 21:30
timescale 0     # freeze the clock
timescale 300   # a full day in 4.8 real minutes

The atmosphere curve

A keyframe curve — night, dawn, day, sunset, dusk — interpolates per frame. The curve is data, not code: the engine ships a neutral default (clear day, blue-ish night) so any project renders sensibly out of the box, and a game supplies its own with OkLighting::setAtmosphereCurve(keys, count), which is where artistic direction belongs. Each OkAtmosphereKey carries an hour and the look at that hour:

static const OkAtmosphereKey MY_CURVE[] = {
  // hour   tint (rgb)          fog (rgb)           density
  //        sun (rgb)           zenith (rgb)        ambient
  { 0.0f, {0.30f,0.40f,0.48f}, {0.10f,0.16f,0.20f}, 0.0060f,
          {0.0f, 0.0f, 0.0f},  {0.02f,0.05f,0.09f}, 0.22f },
  { 9.0f, {1.00f,1.00f,1.00f}, {0.72f,0.78f,0.85f}, 0.0018f,
          {1.0f, 0.98f,0.92f}, {0.25f,0.48f,0.80f}, 0.55f },
  {20.0f, {1.00f,0.72f,0.52f}, {0.75f,0.50f,0.42f}, 0.0032f,
          {1.0f, 0.55f,0.30f}, {0.25f,0.22f,0.38f}, 0.60f },
  {23.0f, {0.30f,0.40f,0.48f}, {0.10f,0.16f,0.20f}, 0.0060f,
          {0.0f, 0.0f, 0.0f},  {0.02f,0.05f,0.09f}, 0.22f },
};

OkLighting::setAtmosphereCurve(MY_CURVE, 4);

Keys go in ascending hour order and the curve wraps around midnight, so the last key blends back into the first. What gets interpolated:

  • Scene tint: a colour multiplied over every world fragment. Neutral at noon, warm amber through the sunset, cold blue-teal at night — the night look's "two temperatures" starts here.
  • Fog colour and density: exponential fog whose density falls off with altitude (see below). Distance dissolves into a milky haze that thickens at night. Until a skybox exists, the frame clear colour is the fog colour, so the scene fades into the sky seamlessly.

What the shadow pass costs

Two things keep the pass from redrawing the world sixty times a second:

  • It is culled against the light's own volume. The camera frustum is the wrong test (a caster behind the viewer still casts into view), but the orthographic shadow box IS the visible area extruded along the light, so anything outside it cannot land a shadow anywhere the map covers. shadows.cull turns this off, which is useful for telling a culling bug from a shadowing one.
  • It is not rebuilt when the picture would be identical. Static geometry under a slow sun barely changes between frames. The map is redrawn when the sun has turned enough to move an edge by a texel (shadows.refresh.turn), when the box slides to a new texel, or when the scene gains or loses objects — that last one matters with streaming, or a newly arrived object would cast nothing until the sun moved.

Measured on a dense outdoor scene, the two together took the pass from 15 ms a frame to nothing measurable.

A project that can be viewed from height should drive shadows.distance from how high the viewer is: the reach a pedestrian needs is short, and spending the map on it keeps texels small, while from the air a short reach means watching shadows arrive. The engine cannot infer it, since "height" means height above the ground the project defines.

Height fog

Fog density is not uniform: it is the density at lighting.fog.base, falling off exponentially with altitude over lighting.fog.height metres. The amount along a view ray is the integral of that density over the ray rather than density times length, solved in closed form, so a ray that climbs passes through steadily thinner air.

This matters as soon as a project can be seen from above. Plain distance fog calibrated for ground level will swallow an entire landscape viewed from high up, because every pixel of ground is then far away. Setting lighting.fog.height very large makes the air uniform again, which is exactly the distance fog this replaced.

lighting.fog.base is world Y, not height above the ground: a project whose terrain sits well above zero should set it to its own ground level, or the whole scene reads as far above the dense layer and never fogs at all.

  • Sun colour and direction: elevation follows a sine over the 6h-21h daylight arc, azimuth sweeps east to west, parked below the horizon at night. Consumed every frame by the Gouraud sun (below).

  • Ambient light: the flat floor under the directional sun — higher at night (no sun: the ambient carries the whole scene and the tint does the darkening), lower by day so the sun's modelling reads.

  • Sky zenith colour: the top of the procedural skybox.

The skybox

OkSkybox draws a low-poly gradient dome first in the frame (camera- centred, depth writes off, so the whole scene paints over it): the horizon colour is the fog colour — the fogged scene always fades into the sky seamlessly — and the top is the curve's zenith colour, from petrol blue at night to clear blue at noon. The 1-D gradient texture refreshes itself when the cycle's colours drift. The dome reaches slightly below the horizon so no gap ever shows; the emissive skyline belt (distant lit windows) is a later follow-up on the same dome.

The Gouraud sun

Every OkItem carries per-vertex normals (computed at construction when the caller provides none — see the Items reference), and the world vertex shader evaluates a classic Gouraud directional light per vertex: ambient + sunColor * max(dot(normal, -sunDirection), 0) * 0.6, with the lit value interpolated across the triangle. Facades facing the sun warm up, opposite faces fall to the ambient floor, and the whole scene reads as volume instead of flat panels. Only textured surfaces are sunlit: the untextured fill/wireframe branch (debug layers, graph lines) keeps its exact requested colour. The skybox and the GUI pass run with lightingOn = 0, which makes the Gouraud stage a neutral 1.

Directional shadows

OkShadowMap renders the scene's depth once per frame from the directional light and the world pass compares against it: a fragment further from the light than what the light could see is in shadow.

Cascades

The shadow distance is split into bands, each with its own map at the same resolution. One map cannot serve both near and far: cover 200 m and shadows stop at 200 m; cover 2 km and a texel is a metre across, so the shadow of a small step becomes a staircase. Split the range and the near band gets centimetres per texel where it is looked at closely, the far band metres per texel where nobody can tell.

shadows.cascades picks how many (3 by default, 4 maximum), and shadows.cascades.blend how the splits are spaced: 0 spreads them evenly, 1 spaces them logarithmically. Even spacing wastes the near cascade on ground that is already close; purely logarithmic makes the far one enormous. The default sits most of the way towards logarithmic.

Each cascade is a square centred on the viewer, sized by its own split — concentric, so the finest box sits inside the next.

There is a shadows.snap switch to round each box's centre onto the texel grid, and it is off. That wants explaining, since snapping is the usual cure for crawling shadow edges. It cures them when the box translates with the viewer: the grid stays put in the world while the box slides over it. It can do nothing when the light rotates, because then the grid rotates with it — and it adds an artefact of its own, since the correction grows until it rolls over half a texel and the whole pattern jumps. Under a moving sun that rollover is continuous, and it reads as a shadow trembling its way across the ground.

Measured against the thing it was there to prevent: with 2048 texels over a 20 m box a texel is 2 cm, and moving the viewer with the snap off shows no crawling at all. So it buys nothing at this resolution and costs the tremble. A coarser map, or a much larger box, would want it back.

Fitting each box to the camera's own slice of the view instead is the more common arrangement, and it spends the resolution where the eye is actually looking. It is not used here, for a reason worth stating: it makes a sun shadow depend on where the camera is. What each box covers follows the cone of vision, so a wall moves from the fine box to a coarse one when the player merely turns or scrolls the wheel — and a coarser box means a bigger normal offset on the receiving side, big enough to lift the sample clear of the shadow the wall is standing in. The shadow then switches off. On the ground that is a cast shadow ending in mid-air and travelling along the surface as the player moves, which no amount of tuning makes acceptable: the sun does not care where the camera is.

Centring on the viewer costs the half of each box that falls behind them. It buys a shadow that changes only when the player moves.

The maps live in one array texture, a layer per cascade, so the world pass needs a single sampler however many there are. Each fragment starts at the finest cascade and takes the first that actually covers it — which, with concentric boxes, is the sharpest one available. Note that this is a question of coverage, not of distance to the camera.

Making neighbouring cascades agree

A changeover between two cascades happens at a fixed radius from the viewer, so it travels with them. Nothing can be done about that; what can be done is to leave nothing to see when it passes. Two cascades that disagree draw a seam on the ground that walks along with the player, and a player walking towards it watches it retreat — which is not something a sun does.

Three things have to match, and each was wrong in its own way before:

  • Where the shadow lands. The depth comparison needs a margin, and shadows.bias states it in metres. Stated in the map's own depth units it would mean different distances in different cascades, since each spans a different depth — a few centimetres in the near box and three times that in the next — so the shadow would sit in a different place either side of the changeover. No amount of blending hides two shadows that are not in the same place.
  • How wide the edge is. The filter kernel is measured in metres too, taking the finest cascade's texel as the unit. A fixed 3×3 of texels blurs over several times more ground in a coarse cascade, so the same edge arrived soft on one side and sharp on the other.
  • How far apart the cascades are. shadows.cascades.blend near 1 packs resolution into the first few metres and puts the first changeover about twenty metres out, with a threefold jump in texel size across it. Neighbours that different cannot be reconciled. The default backs off towards even spacing: less sharpness underfoot, neighbours that resemble each other.

On top of that, the last stretch of each cascade dissolves into the one behind it, so the changeover is spread over metres rather than falling on a line.

Shadowing switches off on its own when the sun is down. The map is not drawn then, but the world shader still declares its sampler, so the sampler is given a 1×1 stand-in to point at: a shader drawn with nothing bound to a declared sampler is an invalid operation in the core profile, and every draw call in the frame fails with it — which empties the screen, interface included.

Only the directional contribution is shadowed: the ambient floor and the point lights still reach a shadowed surface, which is what keeps shadows from becoming black holes. Strength follows the light's elevation and fades to nothing as it reaches the horizon, where a hard shadow would look wrong anyway.

The sun's body

OkSkybox also draws the light's visible body: a camera-facing disc with a solid core inside a soft corona, placed on the dome along the light's own direction, so what casts the shadows is what is seen in the sky. It takes the cycle's sun colour, and fades out as it sinks below the horizon.

Point lights and halos

OkLighting keeps a small registry of point lights (up to 256), in two flavours:

  • registerLight(x, y, z, r, g, b, radius) — an omni light radiating equally in every direction (a bare bulb, a window glow).
  • registerSpotLight(x, y, z, r, g, b, radius, dirX, dirY, dirZ, coneDeg, intensity) — the same light with a direction, a cone half-angle (degrees, soft-edged) and an intensity multiplier over the colour. A spot aimed downward pools its light on the surface below, the usual shape for an overhead fixture.

clearLights() empties the registry. Every item is lit by its nearest few lights (budget of 4, the era-friendly model, no shadows): the selection is cached per item and refreshed only when the registry generation changes, and the lighting itself is evaluated per fragment with a quadratic falloff inside each light's radius — with large triangles, per-vertex point light would smear a single lit vertex across the whole face.

The glow itself is a separate, composable piece: getHaloTexture() returns a shared radial falloff disc ("ok_halo"), and a light's halo is an OkBillboard with that texture plus three OkItem flags: setAdditive(true) (additive blending, no depth writes), setUnlit(true) (light sources are not tinted by the atmosphere or lit by the sun) and setProximityFade(metres) (the quad fades out as the camera approaches — without it a billboard crossing the camera plane fills the screen). Halos live in the scene like any item: frustum culled, fogged with distance, blurred by the depth of field.

Clustered forward

Point lights are selected per pixel, not per object. OkLightClusters divides the view frustum into a 3D grid (16 x 9 x 24: screen tiles by exponential depth slices) and, every frame on the CPU, assigns each light to the clusters its sphere of influence touches; the world fragment shader finds its own cluster from gl_FragCoord and the fragment depth and iterates only those lights.

This is what a scene of huge meshes needs: a ground item spanning a whole neighbourhood now gets every lamp along it, instead of the four nearest to the item's centre. Two details matter:

  • Culling is by sphere of influence, not by visibility — a lamp around the corner still lights the space it spills into.
  • Lights are sorted by view distance before assignment, because clusters have a per-cluster cap: without the ordering, a dozen distant lamps fill the budget and the lamp directly overhead is dropped.

Clustering uses its own depth range (1 m to 350 m), independent of the camera planes: a 0.1 m near plane makes the first exponential slices microscopic and blows the reference budget. Past that distance the fog has swallowed everything anyway. Assignment runs on the CPU because the engine targets OpenGL 4.1 (no compute shaders), and the data reaches the shader as buffer textures.

set lighting.clustered false falls back to the old per-item path (useful for A/B comparisons).

OkLighting::evaluate(hour, ...) exposes the pure curve for tests and tools; the interpolated values are read every frame by the render pass (getSceneTint, getFogColor, getFogDensity, getSunColor, getSunDirection, getAmbientLight).

The GUI pass resets the tint, fog and lighting uniforms: the interface is never tinted, fogged or sunlit.

Configuration keys

Key Default Meaning
lighting.time 12 Hour of day, 0-24 (wraps).
lighting.timescale 30 Clock speed vs real time; 0 freezes it.
lighting.fog true Fog on/off (the colour keeps driving the sky and clear).
lighting.fog.height 25 Metres of altitude over which fog density falls off by e; very large = uniform.
lighting.fog.base 0 World Y at which the curve's density applies; set it to the project's ground level.
lighting.clustered true Per-pixel cluster lookup; off falls back to a per-item budget.
lighting.cluster.near 1 Near end of the clustering depth range, world units.
lighting.cluster.far 350 Far end; past it point lights stop contributing.
shadows true Directional shadow pass on/off.
shadows.size 2048 Depth map resolution.
shadows.extent 90 Half-width used when fitting is off (shadows.distance 0).
shadows.distance 260 How far shadows are worth drawing, split across the cascades. 0 falls back to a fixed shadows.extent box on the viewer.
shadows.cascades 3 Bands the distance is split into (max 4).
shadows.snap false Round each box's centre onto the texel grid. Cures crawling when the box translates; under a rotating sun it only adds its own rollover jump (see above).
shadows.refresh.turn 5e-8 How far the sun must turn before the map is redrawn, as 1 - cos(angle) — about 0.018°, fine enough that a step moves a long shadow's tip less than a texel. 0 redraws on any movement at all.
shadows.cascades.blend 0.4 Split spacing: 0 even, 1 logarithmic. Nearer 1 packs resolution underfoot but makes neighbouring cascades differ sharply, which is what a travelling seam is made of.
shadows.normaloffset 1.0 Scale on the receiver's normal offset (the sample is taken slightly off the surface, to cure acne by moving the sample rather than the shadow).
shadows.normaloffset.max 0.06 Its ceiling, in metres. Past a few centimetres the offset stops curing acne and starts lifting the sample out of the shadow the surface stands in.
shadows.debug false Paint every fragment by the cascade that shadowed it — red, green, blue, yellow for 0..3, magenta where none could. A shadow artefact seen while moving cannot be read off a still: a cascade handover, a hole in a cascade's coverage and a bias washout all look like an edge going soft, and they have nothing to do with each other. Here they are three colours.
shadows.cull true Cull the shadow pass against the light's volume.
shadows.refresh.turn 4e-7 How far the sun must turn (as 1-cos) before the map is redrawn; 0 redraws every frame.
shadows.strength 0.62 How dark a fully shadowed surface goes.
shadows.bias 0.045 Depth margin for the comparison, in metres. Divided per cascade by that box's depth range, so the margin means the same thing on the ground whichever cascade answers.