Global Illumination - Aukio 3D
Table of Contents
- 1. What global illumination adds
- 2. The big idea: GI off the render path
- 3. Progressive global illumination
- 3.1. Polygons are illuminated in the following order
- 3.1.1. Stage 0: polygons are unlit and appear as-is
- 3.1.2. Stage 1: per-polygon centroid, direct light
- 3.1.3. Stage 2: per-polygon centroid, indirect light (first bounce)
- 3.1.4. Stage 3: low-resolution lightmaps
- 3.1.5. Stage 4: high-resolution lightmaps, then converge
- 3.1.6. Radius gating: bounding the stages around the camera
- 3.2. Lightmaps: a texture per triangle
- 3.3. What you see while it bakes
- 3.4. Plain polygons get GI too
- 3.5. Mesh blocks get GI too
- 3.1. Polygons are illuminated in the following order
- 4. Two-sided surfaces
- 5. Enabling GI
- 6. Coordinate spaces: lights are world, geometry is local
- 7. Limitations
- 8. Related Classes
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.
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.
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.
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
giLightarray 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
- One sample point per polygon: the centroid, nudged 0.5 world units off the surface along the normal.
- From it, one shadow ray toward each tracked lamp (any-hit against the BVH; lamps behind the surface rejected without spending a ray).
- Illumination = ambient + the sum over reached lamps of cosine x attenuation x intensity x light color — the same formula the flat-shading path uses.
- 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
giLightarray). - 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)
- Same centroid point.
e3d.gi.samplescosine-weighted bounce rays. - 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/pidiffuse gain. - A ray that hits nothing escapes to the sky dome: it contributes the
scene's sky color (
LightingManager.skyColor) times the diffuse gain ande3d.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=RRGGBBoverrides the scene's sky color. - 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:
- 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.
- 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).
- 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.
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):
(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=falsedisables 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):
SolidPolygoncomputes 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.LightmappedCompositeShapewraps 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.generationsto 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.