Global Illumination - Aukio 3D

Table of Contents

<- Back to index

1. What global illumination adds

Plain per-polygon shading lights every polygon with one flat color: no shadows, and surfaces that receive no direct light stay uniformly dark. A room corner reads as a flat silhouette instead of a corner.

gi-flat.png

Aukio 3D can optionally compute global illumination on background CPU threads: shadows appear, light pools under lamps with smooth falloff, and colored light bleeds — a red sofa tints the floor next to it red. The lighting is baked generation by generation over the first seconds of a scene, then the workers idle and the result stays on screen.

Same camera, same house: flat shading (top) versus baked GI (below). Note the soft shadow of the partition wall, the lamp glow on the ceiling, and the subtle color variation across the floor.

gi-converged.png

2. The big idea: GI off the render path

Ray tracing is far too slow to run per frame in a software renderer, so it doesn't: the render loop never traces a single ray. Painting a lightmapped triangle is an ordinary texture lookup, exactly as fast as any textured polygon.

All the expensive work happens on dedicated low-priority worker threads that bake per-surface lighting values. At a fixed cadence the workers regenerate each triangle's composite texture (baseColor x total lighting) into a back buffer and swap it in atomically — painters never see a half-updated texture.

scene snapshot triangles + lights + BVH GI workers bounce generations lightmaps per-texel indirect + shadow bits composite baseColor x light double-buffered swap painter plain texture lookup generation k reads generation k-1 zero ray casting on render threads

Because painters only read finished textures, frame rate is completely decoupled from GI quality: you can crank lightmap resolution up and the only cost is CPU time on the worker threads, not frame time.

3. Progressive global illumination

The render loop never traces a ray. All ray work happens in GlobalIllumination, a set of dedicated low-priority daemon threads (e3d-gi-0, e3d-gi-1, …; two by default, started with ViewPanel.enableGlobalIllumination()). The workers bake lighting into per-surface caches; render threads only read finished results, so frame rate stays decoupled from GI cost. This section walks through what gets baked, in what order, and when it stops.

3.1. Polygons are illuminated in the following order

The bake progresses by spatial resolution, coarse to fine. Each stage walks all snapshot entries near-to-far from the camera and ends at a global barrier — no entry starts stage k+1 before every entry has finished stage k. After each stage, what is on screen is a complete lighting solution at that stage's resolution; later stages only add detail.

The bake works against a snapshot of the scene. Whenever the global render-list version or the light signature (positions, colors, intensities) changes, one worker collects every render triangle and mesh block, builds a TriangleBvh over them (top-down median split along the longest axis; two-sided Möller–Trumbore intersection: nearest-hit queries for bounce rays, any-hit with early out for shadow rays), copies the light list and the ambient color, and publishes the result atomically. Rebuilds are single-flighted — one worker builds, the rest keep sampling the old snapshot — and rate-limited to one per e3d.gi.minRebuildMs (default 1000 ms), because streaming worlds bump the render-list version per loaded cell and back-to-back BVH rebuilds would starve the actual ray work. Every rebuild clears all progressive state and restarts the bake from stage 1.

The snapshot's entries form a work queue sorted near-to-far from the camera, workers grab entries with an atomic cursor, and the barrier between passes waits for the cursor to drain and every in-flight entry to finish: nothing starts pass k+1 before everything has finished pass k.

AITODO: describe stage rings, how they follow observer and where is their radius specified.

3.1.1. Stage 0: polygons are unlit and appear as-is

Before the bake produces anything, every surface renders exactly as it would without GI:

  • Mesh blocks paint their plain texture: the giLight array is null, and starts at 255 (full brightness) once allocated.
  • Plain shaded polygons get ordinary Lambert shading with every lamp treated as visible — the GI shadow bits answer "visible" until computed, and the indirect add is zero.
  • Lightmapped triangles show composite textures initialized at e3d.gi.initialIrradiance (default 255): their true base color, unlit.

The stage costs nothing and lasts only until the first snapshot is built and the bake starts landing — but it means enabling GI never produces a dark or gray flash: the world looks identical to a no-GI run, and the lighting refines on top of it.

3.1.2. Stage 1: per-polygon centroid, direct light

  1. One sample point per polygon: the centroid, nudged 0.5 world units off the surface along the normal.
  2. From it, one shadow ray toward each tracked lamp (any-hit against the BVH; lamps behind the surface rejected without spending a ray).
  3. Illumination = ambient + the sum over reached lamps of cosine x attenuation x intensity x light color — the same formula the flat-shading path uses.
  4. The result is one RGB triplet per polygon: its uniform direct illumination.

No lightmap texture is generated at this stage. The painter multiplies the polygon's existing texture (or flat color) by the triplet per pixel — the mechanism mesh blocks already use (giLight stamped per triangle, span writers multiply each sampled texel by triplet / 255). A texture premultiplied with a uniform value would cost memory for zero visual difference: multiplying after sampling commutes with mip averaging.

This stage costs at most e3d.gi.maxLights short rays per polygon, so the whole scene flips from the placeholder gray to per-polygon shading with shadows almost immediately.

Implemented as generation 0 of the bake. Every entry — lightmapped or not — gets one centroid evaluation against every tracked lamp:

  • Plain polygons and mesh-block triangles: the centroid is their final sampling resolution, so this pass IS their direct-light state (recorded in their per-polygon floats / the block's giLight array).
  • Lightmapped triangles: the triplet is stamped on the triangle (setGiShade) and the painters multiply every sampled texel of the composite — which still holds the initial base-color fill — by triplet / 255. No lightmap texel work happens in this pass, so the whole scene shows per-polygon direct light with shadows within the first seconds. The stamp is cleared the moment the triangle's first real composite lands.

A lightmap that has already shown traced lighting (Lightmap.everComposited) is not stamped: on rebake the old composite stays on screen until the new per-texel direct lands, so a moving lamp never flashes the scene back to the flat preview.

3.1.3. Stage 2: per-polygon centroid, indirect light (first bounce)

  1. Same centroid point. e3d.gi.samples cosine-weighted bounce rays.
  2. A ray landing at Q reads Q's stage-1 illumination (the frozen stage-1 field — no new shadow rays), multiplied by Q's albedo and the 1/pi diffuse gain.
  3. A ray that hits nothing escapes to the sky dome: it contributes the scene's sky color (LightingManager.skyColor) times the diffuse gain and e3d.gi.skyScale (default 0.5 — display-picked sky colors read far brighter than lamp-lit surfaces, so the raw color floods shadows; ~0.65+ starts to look foggy at aerial poses). Sky unset/black or scale 0 = the historical behavior, escapes contribute zero and shadowed faces fall back to ambient. -De3d.gi.sky=RRGGBB overrides the scene's sky color.
  4. The samples average to the polygon's uniform first-bounce indirect light.

Display: texture x (ambient + direct + first bounce). Color bleeding appears, uniformly per polygon.

Implemented, exactly as described. Runs as generation 2 of the bake: every entry — lightmapped or not — bounces at the centroid, and rays read the frozen stage-1 per-polygon field at all hits. The combined centroid value is the mean of e3d.gi.samples (default 8) cosine-weighted rays. Sky sampling is implemented as described above: the miss paths of gatherTarget and gatherTotal emit the dome radiance, and lightSignature folds in the sky color so changing it rebakes. Verified headlessly on the House scene: every lightmapped triangle's stamp is exactly direct + first-bounce indirect, and bleed shows at centroid resolution (indirect near the red sofa is far redder than far away: R/B 2.77 vs 1.69).

3.1.4. Stage 3: low-resolution lightmaps

Lightmapped triangles allocate their composite textures at coarse resolution: 4x the final units-per-texel (1/16 of the final texel count, rounded to the nearest texel count per axis).

3.1.4.1. Texel size: the square-texel rule

Texel density is proportional to the triangle's size in world space, measured after all rotations and transforms — the lightmap is built from the same post-transform world vertices the painter renders (TriangleMeshBlock.triangleWorld).

The lightmap grid is an ORTHOGONAL frame on the triangle's plane, not the triangle's own edge frame: the longest edge becomes the u-axis, the v-axis is the in-plane perpendicular, and the texture covers the triangle's axis-aligned bounding rectangle in that frame (corners (0,0), (width,0), (apexU*width, height)). One texel therefore covers unitsPerTexel x unitsPerTexel world units on BOTH axes of EVERY triangle — square in world space, never sheared into diamonds by an acute corner. (The edge frame was the bugreport-20261005-192002 failure: on sliver/fan triangles it sheared texels into long diagonal streaks.) The bounding rect on the longest edge is exactly 2x the triangle's area, so squareness costs nothing extra: the old half-empty edge-frame texture had the same 2x overhead.

Square is the efficiency optimum, not an aesthetic choice: an elongated texel wastes RAM and bake rays along its long axis (resolution that carries no new information) while aliasing along its short axis, so for any fixed texel budget the square shape delivers the best possible image. Dimensions are fitted JOINTLY: the ideal aspect is scaled into the 1..128 bounds and the 4-texel long-axis floor BEFORE integer fitting (bounds change density, never shape), then a small neighborhood search picks the integer (width, height) pair with the best aspect, tie-broken toward the ideal texel count. Stage 4 inherits the same frame at the final density (e3d.gi.meshUnitsPerTexel).

Residual deviations, all small or unavoidable:

  1. Integer rounding. The width's own rounding error is the only anisotropy left: at most half a texel, i.e. <= ~1.1:1 above 5 texels and shrinking as counts grow.
  2. Doubly extreme slivers. A strip that is both far thinner than one texel AND longer than ~128 x aspect can't get enough texels under the 128 cap; its single row stretches (huge + thin is pathological at any RAM budget).
  3. The 128-texel cap on huge triangles lowers DENSITY there (both axes proportionally, so texels stay square) — a RAM guard, not a shape choice.
3.1.4.2. Per-texel direct (generation 1)

Scheduled after the stage-2 centroid bounce, at coarse resolution. Every lightmap texel casts one shadow ray toward each tracked lamp, nudged 0.5 world units off the surface along the normal so the ray does not self-intersect, and terminating 1.0 world units before the lamp so geometry touching the light does not count as occluding it. The ray is an any-hit query against the BVH: if any triangle intersects the segment, the lamp is occluded and the texel lies in its shadow. Lamps behind the surface are rejected outright, without spending a ray. The outcome is cached as the texel's visibility bytes. Shadows gain edges inside polygons for the first time.

Direct light is never stored as a value: whenever it is needed — by the compositor, or by bounce rays — it is recomputed from the cached visibility as ambient + the usual cosine × attenuation × intensity sum over visible lamps, the same formula the flat-shading path uses (see Shading & Lighting). That keeps the direct term exactly consistent with the shadow cache at all times.

Plain polygons and mesh-block triangles skip this pass: they were already centroid-evaluated in stage 1, which is their final sampling resolution.

AITODO: Plain polygons and mesh block triangles should not skip this pass. They should get lightmap too. It must be possible to go GI against plain polygons.

3.1.4.3. Per-texel bounces (generation 3+)

AITODO: Get rid of generations. When shooting rays all around in Stage 3, they can bounce against sky or when they bounce against polygon (textured or not) it should use that polygon pixel value/brightness as can be deducted from Stage 2. When computing Stage 3, all nearby polygons are anyway lit using Stage 2 algorithm. Note, if ray baunces against polygon really really far away, and Stage 2 was not computed for it, it can then at least use stage 1 brightness. Let's make it so that Stage is the generation. In essence Stage 3 kind of resolves almost 3 bounces already, because Stage 2 already accumulates indirect light that is 2 bounces. And Stage 1 is direct light only, so Stage 1 is one bounce. So what happens is that each stage in general keeps at the same time increasing resolution of the texel and mounce counts bucause it bounces rays against textures/polygonsn whose color was computed already at the prewious stage. Do you get my idea ?

--–— Old Stage 3 documentation, adjust or remove as appropriate:

Every texel casts e3d.gi.samples (default 8) bounce rays in random cosine-weighted directions around the normal, and averages them as a plain running mean. The first per-texel bounce (generation 3) gathers the stage-2 total at the hit — direct + stage-2 indirect — folding bounces 1 and 2 into one coarse generation. Every further generation reads only the hit surface's previous-generation delta, so each generation adds exactly one more bounce: generation k's result is light that has bounced exactly k-1 times.

The gathered value is multiplied by the hit surface's albedo — the lightmap's base color, the polygon's flat color, or the average color of a mesh triangle's texture — and by the 1/pi diffuse gain. The gain keeps the loop gain below one, so each generation is smaller than the last and bounce light fades geometrically with depth. The readout is also clamped to display range: light near a lamp can sum far beyond 255, and feeding unbounded energy into the bounce loop would saturate the scene.

The strict barrier is what makes this well-defined: generation k only ever reads completed generation-(k-1) output, never a half-written current value. And because the sampled field is frozen for the whole generation, the samples are independent — a plain average is the right estimator, with no exponential moving average and no blend factors to tune.

surface P normal cosine-weighted hemisphere Q light shadow ray: clear occluder blocked at Q: direct light (cached shadow bits) + Q's current indirect estimate P's target = (albedo / π) x (direct + indirect) blended in with an exponential moving average one sample per texel per visit: one shadow ray + one bounce ray — bounce light ripples deeper every sweep

Display: composite = baseColor x (per-texel direct + accumulated indirect). The uniform stage 1-2 triplet is discarded for these triangles; the coarse field replaces it both on screen and as the gather source of the next stage.

3.1.5. Stage 4: high-resolution lightmaps, then converge

AITODO: review in similar spirit as Stage 3


When the coarse bake terminates (the same mean-delta rule as below), every radius-eligible lightmap =refine()=s to the final units-per-texel — 4x finer per axis than stage 3, 16x the texels — and the shown composite is upscaled, so the display does not regress at the transition. The solver itself restarts from zero at generation 1: the coarse field seeds the DISPLAY only, never the solver — carrying it into the accumulated indirect would count the Neumann series twice.

The fine bake then runs the plain generation chain at full resolution: generation 1 per-texel direct, generation 2 reading the fine direct at hits (plain-polygon hits contribute their frozen stage-2 total once), generations k >= 3 reading the previous delta — the same bounce estimator as stage 3. Lightmaps beyond the stage-4 radius finalize at coarse resolution: the coarse composite is their terminal display (re-armed for a fine catch-up if the camera approaches later).

3.1.5.1. Termination and teardown

At the barrier the finished delta folds into the texel's accumulated total (what the composite texture displays), the delta buffers swap so the finished generation becomes the next one's gather source, and mesh-block triangles receive their updated light total (direct + accumulated indirect) in the block's paint array.

Each phase's bake ends when a generation's mean delta — the largest channel change per texel, averaged over all texels and polygon states — falls below e3d.gi.deltaThreshold (default 0.5 light units; later generations can only be smaller, so they cannot move the picture) or when e3d.gi.generations generations have run (default 6 = direct + up to 5 bounce generations). The coarse bake's termination triggers the refine above; the fine bake's termination composites every lightmap one final time and frees all per-texel arrays scene-wide. The workers then idle, polling for scene changes four times a second.

The generation cap is an honest trade: a hard stop at 6 generations discards the remaining series tail — single-digit percent of the indirect energy in a typical interior, but up to a fifth in a bright white room. Raise the knob for such scenes; the cost is one more full-scene pass per extra generation.

3.1.6. Radius gating: bounding the stages around the camera

In a huge streaming world (millions of triangles), the global barrier between stages makes later stages unreachable in practice: stage 2 must bounce every triangle before any lightmap work starts. Radius gating bounds each stage to a distance bubble around the camera:

Key (config.yaml e3d section) System property Stage bounded
giStage2Radius -De3d.gi.stage2Radius centroid bounce
giStage3Radius -De3d.gi.stage3Radius coarse lightmaps
giStage4Radius -De3d.gi.stage4Radius fine refinement

A radius of 0 (the default) means unlimited — the historical global bake. Stage 1 is deliberately never gated: bounce rays read the hit surface's stage-1 visibility bits everywhere, so every polygon keeps its correct direct light and only indirect detail falls off with distance.

Eligibility is re-evaluated from the live camera position at every generation barrier — the bubble follows the camera through the bake. Entries that become eligible only after the bake converged (you walked up to a new building) get a local catch-up on the idle GI threads: their own direct trace plus one bounce against the frozen converged field, never a global generation restart (re-running a generation would double-count its energy in entries that already received it).

The catch-up preserves the staged look wherever the camera goes: a mesh-block triangle entering the stage-3 ring gets a COARSE lightmap (the stage3TexelFactor density, upgraded to fine by the refine catch-up if you approach into the stage-4 disc), while a triangle entering the stage-4 disc directly joins at fine resolution. The same applies to standalone lightmapped triangles that sat outside the stage-3 radius for the whole bake — they hold only the stage-1 shade until the bubble reaches them, then trace their direct + bounce at their constructed resolution. Only fully-baked composites enter the lightmap cache, so a shade-only fill is never resurrected as "final".

Mesh-block triangles gain lightmaps inside the stage-3 bubble. Lean TriangleMeshBlock geometry (the only kind streaming game worlds use) otherwise finishes at the per-triangle stage-2 shade. A block triangle inside the bubble gets a regular per-triangle Lightmap whose composite resamples the block's photo texture (not a flat albedo) so texture detail survives, at giMeshUnitsPerTexel units per texel (default 2.1) for the final resolution. Block lightmapping is enabled by a positive stage-3 radius only — at 0, blocks behave exactly as before. Triangles with alpha textures (cutout foliage) never get lightmaps: a composite is opaque and would render the card as a solid slab — they keep the per-triangle shade.

Baked composites are kept in a RAM cache (LightmapCache, capped by giLightmapCacheEntries, default 65536) keyed by a hash of the triangle's world geometry. When a streamed-out cell streams back in, its lightmaps restore bit-exactly without a rebake — invalidation rides the same light signature that triggers snapshot rebuilds.

The stage-1/stage-2 CENTROID values (per-triangle direct color and first-bounce color — just two RGB triples per triangle) get the same treatment through GiCentroidCache (capped by giCentroidCacheEntries, default 4M — ~120 bytes per entry, sized to hold a full streamed FO4 world). A snapshot rebuild — triggered by every cell stream-in/out — used to re-run stage 1 (millions of shadow rays) and stage 2 (100k+ centroid bounces) for geometry that had not changed at all. Now the old snapshot's values are harvested before the state map drops, and entries whose geometry hash and light signature match are restored before the new snapshot is published: stage 1 and the stage-2 bounce skip them, and their display colors are written back immediately, so a re-entering cell never shows an unlit frame (bugreport-20261005-220333). Restored shadows may briefly reflect occluders that streamed away — the same tradeoff the lightmap cache takes; the per-texel stages (3+) still recompute normally.

3.2. Lightmaps: a texture per triangle

Flat shading can only color a polygon uniformly — shadows and gradients need resolution inside the polygon. Every lightmapped triangle therefore owns a small generated texture, its Lightmap, whose texels map onto the triangle surface by the affine rule p(u,v) = origin + edge1·u + edge2·v. The frame is ORTHOGONAL: the longest edge is the u-axis, the v-axis is the in-plane perpendicular, and the texture covers the triangle's bounding rectangle in that frame, so the UVs pin to the corners (0,0), (width,0), (apexU·width, height):

invalid half (u+v > 1) filled from neighbors so sampling near the hypotenuse stays clean one texel = one surface patch, sampled at center a (u=0, v=0) b (u=1, v=0) c (u=0, v=1) e1 = b − a e2 = c − a world(u,v) = a + e1·u + e2·v texels = edge / unitsPerTexel every texel owns a fixed patch of the triangle — shadows and gradients live INSIDE the surface

(The figure shows the conceptual edge1/edge2 mapping; in the shipped frame edge2 is the perpendicular remainder, and the third corner sits at (apexU, 1) rather than (0, 1).)

The valid texel region is the inside of the triangle in that frame (right of the left edge, left of the right edge, above the base). The rest of the rectangle is never traced; at composite time it is flood-filled from valid neighbors so that bilinear sampling and mip resampling near the edges never pick up garbage. The bounding rect on the longest edge is exactly 2x the triangle's area, and texels are square in world space on EVERY triangle shape — no sheared diamonds on acute fan triangles (bugreport-20261005-192002).

Width and height are fitted JOINTLY for square texels: the ideal aspect is scaled into the 1..128 bounds and the 4-texel long-axis floor before integer fitting, then the width is fitted to the rounded height's aspect with a small neighborhood search — residual anisotropy is the width's own rounding error, <= ~1.1:1 above 5 texels and shrinking as counts grow. LightmappedCompositeShape.setLightmapUnitsPerTexel() (default 12) sets the world-unit size of one texel. A 100-unit wall cell gets an 8x8 lightmap. Halving the units quadruples the tracing work. What you trace is what you see: the composite texture the painter samples is the lightmap itself, at native texel resolution — shadow-edge smoothness comes from tracing finer, never from interpolation.

During the bake each valid texel carries four kinds of state, stored as flat arrays indexed by texel number (j * width + i) and written only by GI threads:

  • Indirect total (RGB floats) — the accumulated bounced light, the sum of all completed generations. This is what the composite texture displays.
  • Previous-generation delta (RGB floats) — the last completed generation's contribution: the gather source for the generation currently being traced.
  • Current-generation accumulator (RGB floats) — the running mean of the generation in progress, shown as a live preview.
  • Per-light visibility bytes — whether the shadow ray from this texel reached each lamp (written in generation 1; direct light is always derived from these bytes, never stored separately).

The fields, on Lightmap:

/** Accumulated indirect total and the two generation buffers
    (pre-albedo light units), width * height entries each. All arrays
    are null before the bake reaches this lightmap and after the bake
    completes — a finished lightmap keeps only its final texture. */
public volatile float[] indirectR, indirectG, indirectB;    // total
public volatile float[] deltaPrevR, deltaPrevG, deltaPrevB; // gen k-1
public volatile float[] deltaCurR, deltaCurG, deltaCurB;    // gen k

/** Per-texel per-light visibility: texelCount * lightCount bytes,
    indexed [texel * lightCount + lampIndex]. One byte per pair:
    0 = unknown, 1 = shadow ray reached the lamp, 2 = blocked. */
public byte[] lightVisibility;
public int lightCount;

/** Bake progress: 0 = nothing, 1 = direct done, k = k generations. */
public volatile int generation;

Two footnotes for the precise reader. Visibility is a whole byte per texel-lamp pair here, not a packed bitfield — the plain-polygon path is the one using literal bits (a visibleBits / knownBits pair of long masks, one bit per lamp, 64 lamps hard maximum there). And only the first e3d.gi.maxLights lamps (default 16) are tracked at all: lamps past the cap cast no shadows and contribute no direct light in composites.

The arrays are bake scaffolding, not permanent state. They are allocated lazily by Lightmap.ensureArrays() when the bake first reaches a lightmap — a lightmapped scene that never enables GI pays only the two composite textures per triangle — and freed scene-wide by Lightmap.freeArrays() the moment the bake completes. What remains per triangle is exactly the two double-buffered textures (one shown, one spare). A scene or light change restarts the bake and re-allocates them.

3.3. What you see while it bakes

The world is visible from the very first frame, exactly as without GI: lightmap textures start at their true base color (e3d.gi.initialIrradiance, default 255), and mesh-block light arrays start at 255 (full texture brightness). Stage 1 then stamps every polygon with its uniform direct light — near-to-far, so per-polygon shading with shadows sweeps across the view within the first seconds — and each lightmap upgrades to per-texel traced direct lighting when generation 1 reaches it (the uniform stamp hands over to the first composite). Bounce light deepens generation by generation after that.

During the bake the composite textures regenerate at most every 500 ms, always into the spare buffer, swapped in atomically — painters never see a half-updated texture. The displayed indirect term is the accumulated total plus the in-progress generation's mean. There is deliberately no display smoothing: within a generation the values only get less noisy, and at the barrier they only grow toward the final solution, so steps are small and always in the right direction.

  • Despeckle: at composite time the indirect channel is blended 50/50 with the mean of its valid 4-neighbors, killing single-texel Monte Carlo spikes without blurring real gradients (-De3d.gi.despeckle=false disables it).
  • Scene changes: any geometry or light change rebuilds the snapshot and restarts the bake from stage 1. The old textures stay on screen until the new direct pass lands — a visible step, not a gradual fade (the price of freeing all solver state between bakes). Lightmaps that have already shown traced lighting keep their old composite: the uniform preview only plays on the very first bake.

3.4. Plain polygons get GI too

Surfaces that are not lightmapped (ordinary SolidPolygon with shading enabled) still benefit, at per-polygon resolution, through the GiLightProvider interface that GlobalIllumination installs into the LightingManager:

  • isLightVisible(polygon, light) answers from the cached shadow bits — direct-light shadows fade in on flat-shaded geometry.
  • addIndirectLight(polygon, baseColor, result) adds the polygon's accumulated bounced-light estimate into the flat-shaded color, scaled by the polygon's own albedo.

Both are called from parallel render-pool threads, so they only read volatile caches — never trace, never allocate. Unlike the lightmap texel arrays, these few per-polygon floats are permanent: the renderer reads them every frame.

3.5. Mesh blocks get GI too

Lean TriangleMeshBlock shapes (bulk static geometry: streamed game maps, imported models) participate without lightmaps. When a block first enters a GI snapshot it allocates one float triplet per triangle (giLight, 255 = full texture brightness, so enabling GI shows no dark age). Generation 1 stamps the centroid's direct light; each later barrier adds the bounce delta on top, so the array always holds ambient + direct + accumulated indirect. Before painting, the triangle's handle copies its triplet into the span-writer fields, and the span loops multiply every sampled texel by triplet / 255 — per-triangle baked shading at three multiplies and a clamp per pixel. While no GI system has stamped the block, the array is null and painting is bit-identical to the unlit path.

On the tracing side a mesh-block triangle is a TriangleBvh.Entry whose polygon is null; the entry instead carries the block reference and the triangle index, and its progressive state lives in the same per-state map, keyed by the entry rather than the polygon. Albedo for bounce coloring comes from the average color of the triangle's texture, cached per texture — so warm walls warm up the shade near them.

4. Two-sided surfaces

Polygons rendered with backface culling off are visible from both sides, and each side must show its own lighting — otherwise the camera sees the front side's light through the surface (undersides glowing with the top's light):

  • SolidPolygon computes a second shaded color with the negated normal every frame; the painter picks front or reverse color by the signed screen area, the same test backface culling uses.
  • LightmappedCompositeShape wraps a two-sided source into TWO lightmapped triangles — front, plus a reversed-winding back whose negated normal gives it its own lightmap — and turns culling on for both halves (2026-10-04; previously the back was a plain solid triangle with per-polygon light only). Each side's pixels are owned by exactly one surface, so nothing z-fights and no holes appear even when the source model's winding is inconsistent, and both sides bake their own lighting: exteriors catch street lamps and moonlight instead of borrowing the interior's light.

5. Enabling GI

// Per-texel lightmaps on composite geometry (the House demo setup):
LightmappedCompositeShape house = new LightmappedCompositeShape();
house.setLightmappingEnabled(true);
house.setLightmapUnitsPerTexel(3.0);   // fine texels: quality from traced rays

// Start the workers (2 threads by default; more bake faster):
viewPanel.enableGlobalIllumination(4);

Tuning knobs (system properties):

  Property Default Effect  
  -----------------------------------------------------------------------------  
  e3d.gi.generations 6 total generations: 1 = direct, each more = +1 bounce  
  e3d.gi.samples 8 bounce rays per texel per generation, averaged  
  e3d.gi.deltaThreshold 0.5 early stop: generation mean delta (light units)  
  e3d.gi.initialIrradiance 255 initial un-baked brightness (255 = true colors)  
  e3d.gi.despeckle true neighbor-smoothing of indirect at composite time  
  e3d.gi.minRebuildMs 1000 snapshot rebuild rate limit (streaming worlds)  
  e3d.gi.stage3TexelFactor 4 stage-3 lightmap coarsening: units-per-texel multiplier  
  e3d.gi.debug false generation statistics to stdout  
  e3d.gi.dumpLightmaps (unset) dump composite lightmaps as PNGs to the given dir  

6. Coordinate spaces: lights are world, geometry is local

Direct lighting (SolidPolygon) and GI both read polygon centers, normals and lightmap coordinates in the shape's LOCAL space, while LightSource positions, markers and GI rays live in WORLD space. For shapes with identity transforms the two coincide — but a composite with a non-identity transform (scaled/rotated OBJ model) silently shifts every effective light position into the wrong place (mirrored, scaled, offset — the classic symptom is light apparently coming from a spot where no source exists, e.g. from below the ground).

Until the engine reconciles the two spaces, demos must BAKE static model transforms into the vertex coordinates at load time (see ObjLoaderDemo.buildScene) and leave the model transform identity. Animated transforms above a lightmapped composite were already unsupported; this is the same rule, one level up.

7. Limitations

  • Diffuse light only — no specular bounce, no caustics.
  • Polygon vertices are traced in composite-local space; scenes that put non-identity transforms on composites are traced incorrectly.
  • The generation cap truncates the bounce series: with the default 6 generations, bright interiors lose up to ~20% of the theoretical indirect energy (less in typical rooms). Raise e3d.gi.generations to recover it.
  • Any scene or light change restarts the whole bake from generation 1 (the old result stays on screen until the new direct pass lands). There is no incremental update for a moved lamp.
  • Mesh-block triangles shade per-triangle from the winding normal, so two-sided (culling-off) foliage shows the same light from both sides.
  • Rays that miss all geometry return no radiance; outdoors the sky ambient term carries the fill instead.

Created: 2026-10-09 pe 00:26

Validate