feat(e3d): GI for mesh blocks, frustum far-plane and subpixel cull fixes
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Mon, 28 Sep 2026 20:53:42 +0000 (23:53 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Mon, 28 Sep 2026 20:53:42 +0000 (23:53 +0300)
Global illumination now traces lean TriangleMeshBlock geometry:
each block triangle is a TriangleBvh.Entry with no backing polygon
(the entry carries the block and triangle index), sampled
per-triangle like plain polygons. Phase A writes direct light with
shadow bits and phase B adds the bounce estimate straight into the
block's new per-triangle giLight array (allocated at 255 = full
texture, so enabling GI shows no dark age); MeshTriangle.paint
copies the triplet before the span loop and the span writers
multiply sampled texels by it — per-triangle baked shading at a
branch per pixel. Albedo for bounce rays comes from each texture's
cached average color. Blocks skip the texel/lightmap phase
entirely.

Because streaming worlds bump the render-list version per loaded
cell, snapshot rebuilds are single-flighted (one worker builds, the
rest keep sampling the old snapshot) and rate-limited
(-De3d.gi.minRebuildMs=, default 1000 ms) so bursts coalesce
instead of rebuilding a multi-million-triangle BVH back-to-back.

Raise the frustum far plane from 10000 to 1e9 units
(-De3d.cull.far=): the short plane wrongly culled distant cell/LOD
composites whose whole bounds lay beyond it, punching holes into
mid-distance terrain while streaming (bugreport-20260928-005318;
FO4 LOD blocks span 100k+ units). Add -De3d.cull.frustum=false to
disable composite culling and -De3d.cull.frustumTrace=true to log
culled composites' bounds.

Include paint margins in the subpixel cull span: single-vertex
shapes (Billboard/GlowingPoint) have a zero raw span and their
entire visible area IS the margin, so the raw-span test culled them
regardless of glow size — billboards rendered invisible.

Document the mesh-block GI path, the far-plane rationale, and the
new tuning properties in the Global illumination and Frustum
culling pages and AGENTS.org.

12 files changed:
AGENTS.org
Documentation/Frustum culling/index.org
Documentation/Global illumination/index.org
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/Frustum.java
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.java
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.java
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/MeshTriangle.java
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TriangleMeshBlock.java
src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java

index cd5e17b..e774822 100644 (file)
@@ -246,7 +246,7 @@ scene.addShape(clickableCube);
 | ~Point3D~ | ~Point3D.java~ | Mutable 3D point/vector. *Public fields:* ~x~, ~y~, ~z~ | ~.add()~, ~.subtract()~, ~.multiply()~, ~.rotate()~, ~.getDistanceTo()~, ~.clone()~, ~.withAdded()~ (returns new) |
 | ~Point2D~ | ~Point2D.java~ | 2D screen coordinate                                    | ~.add()~, ~.subtract()~, ~.to3D()~                                                                                |
 | ~Box~     | ~Box.java~     | Axis-aligned bounding box                               | ~.getCenter()~, ~.enlarge()~, ~.intersectsAABB()~                                                                 |
-| ~Frustum~ | ~Frustum.java~ | View frustum (6 planes)                                 | ~.update()~, ~.intersectsAABB()~                                                                                  |
+| ~Frustum~ | ~Frustum.java~ | View frustum (6 planes). Far plane default 1e9 units, ~-De3d.cull.far=~; disable with ~-De3d.cull.frustum=false~, trace culls with ~-De3d.cull.frustumTrace=true~ | ~.update()~, ~.intersectsAABB()~                                                                                  |
 | ~BspTree~ | ~BspTree.java~ | BSP tree for CSG                                        | ~.addPolygons()~, ~.clipPolygons()~, ~.invert()~, ~.allPolygons()~                                                |
 | ~Plane~   | ~Plane.java~   | Infinite plane (Hesse normal)                           | ~.fromPoints()~, ~.splitPolygon()~                                                                                |
 
@@ -532,6 +532,10 @@ System.out.println(SceneDump.dump(scene, lighting, camera, gi));
 - ~ShapeCollection.getRootComposite()~ — direct root access (GI snapshots)
 - ~RenderingContext.getImage()~ — the backing BufferedImage
 - ~GlobalIllumination.isRunning() / isConverged() / getWorkItemCount()~
+- GI traces lean ~TriangleMeshBlock~ geometry too: block triangles are
+  ~TriangleBvh.Entry~ objects with ~polygon == null~, shaded per-triangle
+  through the block's GI light array (no lightmaps). Snapshot rebuilds
+  are single-flighted + rate-limited (~-De3d.gi.minRebuildMs=, def 1000).
 
 ** House demo scene without a window (aukio-3d-demos)
 
index 9c4a941..52ed1b2 100644 (file)
@@ -168,7 +168,17 @@ The [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e
 
 - Computes planes in *view space* (camera at origin, looking along +Z)
 - FOV derived from =projectionScale = width / 3= (≈112° horizontal FOV)
-- Default clip distances: Near = 1.0, Far = 10000.0
+- Default clip distances: Near = 1.0, Far = 1 000 000 000 (configurable
+  via =-De3d.cull.far=<units>=). The far plane must reach past the
+  largest streamed world: a short far plane wrongly culls any composite
+  whose /entire/ bounds lie beyond it, punching holes into mid-distance
+  terrain when the world streams distant LOD blocks (fallout4 world
+  blocks span 100k+ units; the old Far = 10000.0 caused exactly this —
+  holes appearing and closing while spinning the camera at altitude,
+  bugreport-20260928-005318)
+- Frustum culling can be disabled for diagnostics with
+  =-De3d.cull.frustum=false=; culled composites' bounds can be traced
+  with =-De3d.cull.frustumTrace=true=
 - Planes stored in [[https://www3.svjatoslav.eu/projects/aukio-3d/apidocs/eu/svjatoslav/aukio/e3d/geometry/Plane.html][Hesse normal form]]: (normal vector, distance)
 
 The frustum is updated once per render pass in
index 5419b80..c590258 100644 (file)
@@ -238,6 +238,35 @@ interface that =GlobalIllumination= installs into the
 Both are called from parallel render-pool threads, so they only read
 volatile caches — never trace.
 
+* Mesh blocks get GI too
+:PROPERTIES:
+:CUSTOM_ID: mesh-blocks
+:END:
+
+Lean =TriangleMeshBlock= shapes (bulk static geometry: streamed game
+maps, imported models) participate without lightmaps. The block
+allocates one integer triplet per triangle (direct + indirect light,
+255 = full) on the first GI snapshot; =MeshTriangle.paint= copies its
+triangle's triplet into the painted =TexturedTriangle= before the span
+loop, and the span writers multiply every sampled texel by
+triplet / 255 — per-triangle baked shading at a branch per pixel.
+
+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. Progressive state lives in the same per-state
+map, keyed by the entry rather than the polygon. Phase A writes the
+triangle's direct light + shadow bits straight into the block's array;
+phase B adds the bounce estimate on top. Blocks skip the texel phase
+entirely — there is no lightmap to update. Albedo for bounce coloring
+comes from each block's average texture color, so warm walls warm up
+the shade near them.
+
+Because streaming worlds bump the render-list version on every loaded
+cell, snapshot rebuilds are single-flighted (one worker builds, the
+rest keep sampling the old snapshot) and rate-limited
+(=-De3d.gi.minRebuildMs=, default 1000 ms) — no back-to-back BVH
+rebuilds while the camera moves.
+
 * Two-sided surfaces
 :PROPERTIES:
 :CUSTOM_ID: two-sided
@@ -322,6 +351,10 @@ Tuning knobs (system properties):
 - The bounce estimate is one ray deep per sample — correctness comes
   from sweep-over-sweep propagation, so deeply indirect corners take
   several sweeps to brighten.
+- 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.
 
 * Related Classes
 :PROPERTIES:
index 47f7f39..e656cd5 100644 (file)
@@ -143,9 +143,16 @@ public class Frustum {
         final double cosHalfVFOV = 1.0 / Math.sqrt(1.0 + tanHalfVFOV * tanHalfVFOV);
         final double sinHalfVFOV = tanHalfVFOV * cosHalfVFOV;
 
-        // Near and far distances
+        // Near and far distances. The far plane only exists to skip
+        // truly invisible geometry, so it must reach past the largest
+        // streamed world: a shorter plane wrongly culls distant cell/LOD
+        // composites whose WHOLE bounds lie beyond it, punching holes
+        // into mid-distance terrain (bugreport-20260928-005318; the
+        // world's LOD blocks span 100k+ units). Override with
+        // -De3d.cull.far=<units>.
         nearDistance = 1.0;
-        farDistance = 10000.0;
+        farDistance = Double.parseDouble(
+                System.getProperty("e3d.cull.far", "1000000000"));
 
         // All side planes pass through origin (camera position in view space)
         // Plane equation: dot(normal, point) >= distance means inside
index da63431..1f614af 100755 (executable)
@@ -159,6 +159,18 @@ public class ShapeCollection {
         rootComposite.collectRenderTriangles(out);
     }
 
+    /**
+     * Collects the {@code TriangleMeshBlock}s currently rendered for this
+     * collection (render lists of all composites, recursively). Used by
+     * the global-illumination scene snapshot to trace lean mesh geometry.
+     * Same lazy-cache contract as {@link #collectRenderTriangles}.
+     *
+     * @param out list receiving the blocks
+     */
+    public void collectMeshBlocks(final List<eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TriangleMeshBlock> out) {
+        rootComposite.collectMeshBlocks(out);
+    }
+
     /**
      * Adds a shape to this collection with a group identifier for visibility control. This method is thread-safe.
      *
@@ -293,10 +305,16 @@ public class ShapeCollection {
         renderingContext.transformCycleId = ++transformCycleCounter;
 
         // Update frustum for this frame (used for frustum culling)
-        if (renderingContext.frustum == null) {
-            renderingContext.frustum = new Frustum();
+        // e3d.cull.frustum=false disables composite frustum culling
+        // (diagnostics for wrongly-culled geometry, e.g. stale bounds).
+        if (Boolean.parseBoolean(System.getProperty("e3d.cull.frustum", "true"))) {
+            if (renderingContext.frustum == null) {
+                renderingContext.frustum = new Frustum();
+            }
+            renderingContext.frustum.update(camera, renderingContext.stereoViewportWidth, renderingContext.height);
+        } else {
+            renderingContext.frustum = null;
         }
-        renderingContext.frustum.update(camera, renderingContext.stereoViewportWidth, renderingContext.height);
 
         // Initialize culling statistics for this frame
         if (renderingContext.cullingStatistics == null) {
index 0e2d921..de6bf25 100644 (file)
@@ -82,6 +82,13 @@ import java.util.function.Supplier;
  *       phase B); the result feeds the flat-shading path through
  *       {@link GiLightProvider} (shadow tests + indirect add). Polygons
  *       stay single-colored.</li>
+ *   <li><b>Mesh blocks</b> ({@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TriangleMeshBlock},
+ *       lean flat-array game geometry): per-triangle sampling like plain
+ *       polygons, complete after phase B. Instead of the GiLightProvider
+ *       path (which needs per-triangle objects), light totals are written
+ *       straight into the block's {@code giLight} array; the paint path
+ *       multiplies texels by them. Blocks never enter phase C (no
+ *       lightmaps).</li>
  * </ul>
  *
  * <p><b>Estimator:</b> each bounce sample casts one cosine-weighted
@@ -218,6 +225,16 @@ public class GlobalIllumination implements GiLightProvider {
     private static final double CALM_THRESHOLD =
             Double.parseDouble(System.getProperty("e3d.gi.calmThreshold", "1.0"));
 
+    /**
+     * Minimum milliseconds between scene snapshot rebuilds: streaming
+     * worlds bump the render-list version per loaded cell, and rebuilding
+     * a multi-million-triangle BVH back-to-back would starve the actual
+     * ray work. Bursts coalesce to one rebuild per interval.
+     * {@code -De3d.gi.minRebuildMs}, 0 disables.
+     */
+    private static final long MIN_REBUILD_MS =
+            Long.parseLong(System.getProperty("e3d.gi.minRebuildMs", "1000"));
+
     /** Progressive phases, strict global order. */
     private enum Phase {
         /** Direct light at polygon centroids, near-to-far, one visit each. */
@@ -243,11 +260,16 @@ public class GlobalIllumination implements GiLightProvider {
 
     private volatile Snapshot snapshot;
 
-    /** Per-polygon state for PLAIN solid polygons, read by render threads. */
-    private final ConcurrentHashMap<AbstractCoordinateShape, GiState> states = new ConcurrentHashMap<>();
+    /** Progressive per-polygon GI state, keyed by polygon or by entry (mesh blocks). */
+    private final ConcurrentHashMap<Object, GiState> states = new ConcurrentHashMap<>();
 
     private int lastSeenRenderListVersion = -1;
     private double lastLightSignature = Double.NaN;
+    /** Wall time of the last completed snapshot build (rebuild coalescing). */
+    private long lastRebuildTime;
+    /** Single-flight guard for snapshot rebuilds (one worker builds at a time). */
+    private final java.util.concurrent.atomic.AtomicBoolean rebuildInFlight =
+            new java.util.concurrent.atomic.AtomicBoolean();
     private final AtomicInteger workIndex = new AtomicInteger();
     private volatile int calmSweeps;
     private volatile long lastCompositeUpdate;
@@ -306,12 +328,24 @@ public class GlobalIllumination implements GiLightProvider {
     /** Progressive per-polygon GI state for plain solid polygons. */
     private static class GiState {
         volatile float indirectR, indirectG, indirectB; // irradiance, light units
+        /** Phase-A direct stamp (ambient + direct), the block write-back base. */
+        volatile float directR, directG, directB;
         volatile long visibleBits;   // per-light: shadow ray says visible
         volatile long knownBits;     // per-light: visibility computed at least once
         int nextLight;               // round-robin cursor (GI threads only)
         int samples;                 // adaptive EMA counter (GI threads only)
     }
 
+    /**
+     * The {@link #states} key for an entry: the polygon for object-backed
+     * triangles (render threads query by it through the GiLightProvider),
+     * the entry itself for mesh-block triangles (no per-triangle object
+     * exists; entries are snapshot-unique).
+     */
+    private static Object stateKey(final TriangleBvh.Entry entry) {
+        return entry.polygon != null ? entry.polygon : entry;
+    }
+
     /**
      * Creates the GI system. Call {@link #start()} to begin tracing.
      * Distance ordering is disabled (uniform order, no camera re-sort).
@@ -831,7 +865,7 @@ public class GlobalIllumination implements GiLightProvider {
         final double oy = entry.centroidY + ny * ORIGIN_EPSILON;
         final double oz = entry.centroidZ + nz * ORIGIN_EPSILON;
 
-        final GiState state = states.computeIfAbsent(entry.polygon, p -> new GiState());
+        final GiState state = states.computeIfAbsent(stateKey(entry), p -> new GiState());
         final int lightCount = snap.lights.size();
         if (withShadowRays && lightCount > 0) {
             final int lightIdx = state.nextLight++ % lightCount;
@@ -918,7 +952,7 @@ public class GlobalIllumination implements GiLightProvider {
             lightmap.seedDisplay(lightmap.centroidR, lightmap.centroidG, lightmap.centroidB);
             lightmap.centroidReady = true;
         } else {
-            final GiState state = states.computeIfAbsent(entry.polygon, p -> new GiState());
+            final GiState state = states.computeIfAbsent(stateKey(entry), p -> new GiState());
             synchronized (state) {
                 for (int i = 0; i < tracked; i++) {
                     final long bit = 1L << i;
@@ -926,7 +960,18 @@ public class GlobalIllumination implements GiLightProvider {
                             ? (state.visibleBits | bit) : (state.visibleBits & ~bit);
                     state.knownBits |= bit;
                 }
+                state.directR = (float) Math.min(255, r);
+                state.directG = (float) Math.min(255, g);
+                state.directB = (float) Math.min(255, b);
             }
+            if (entry.block != null)
+                // The visible wave lands on the block immediately: the
+                // triangle flips from the full-texture start to its true
+                // flat lighting (bounce adds on top in phase B).
+                entry.block.setGiLight(entry.blockTri,
+                        (float) Math.min(255, r),
+                        (float) Math.min(255, g),
+                        (float) Math.min(255, b));
         }
     }
 
@@ -967,7 +1012,7 @@ public class GlobalIllumination implements GiLightProvider {
                     (lightmap.centroidIndirectR + lightmap.centroidIndirectG
                             + lightmap.centroidIndirectB) / 3.0);
         }
-        final GiState state = states.computeIfAbsent(entry.polygon, p -> new GiState());
+        final GiState state = states.computeIfAbsent(stateKey(entry), p -> new GiState());
         synchronized (state) {
             final float dR = (float) (target[0] - state.indirectR);
             final float dG = (float) (target[1] - state.indirectG);
@@ -975,6 +1020,13 @@ public class GlobalIllumination implements GiLightProvider {
             state.indirectR += alpha * dR;
             state.indirectG += alpha * dG;
             state.indirectB += alpha * dB;
+            if (entry.block != null)
+                // Block paint reads the TOTAL light: phase-A direct stamp
+                // plus the converging bounce estimate.
+                entry.block.setGiLight(entry.blockTri,
+                        state.directR + state.indirectR,
+                        state.directG + state.indirectG,
+                        state.directB + state.indirectB);
             return graduationSignal(
                     Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha,
                     (state.indirectR + state.indirectG + state.indirectB) / 3.0);
@@ -1016,7 +1068,7 @@ public class GlobalIllumination implements GiLightProvider {
             hiG = hit.entry.lightmap.indirectG[hitTexel];
             hiB = hit.entry.lightmap.indirectB[hitTexel];
         } else {
-            final GiState hitState = states.get(hit.entry.polygon);
+            final GiState hitState = states.get(stateKey(hit.entry));
             hiR = hitState == null ? 0 : hitState.indirectR;
             hiG = hitState == null ? 0 : hitState.indirectG;
             hiB = hitState == null ? 0 : hitState.indirectB;
@@ -1064,7 +1116,7 @@ public class GlobalIllumination implements GiLightProvider {
         final int lightCount = snap.lights.size();
         final int hitTexel = lightmap != null
                 ? lightmap.texelAt(hit.pointX, hit.pointY, hit.pointZ) : -1;
-        final GiState hitState = lightmap == null ? states.get(entry.polygon) : null;
+        final GiState hitState = lightmap == null ? states.get(stateKey(entry)) : null;
 
         for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++) {
             final LightSource light = snap.lights.get(i);
@@ -1103,6 +1155,8 @@ public class GlobalIllumination implements GiLightProvider {
     private static Color colorOf(final TriangleBvh.Entry entry) {
         if (entry.lightmap != null)
             return entry.lightmap.baseColor;
+        if (entry.block != null)
+            return entry.block.albedo(entry.blockTri);
         if (entry.polygon instanceof SolidPolygon)
             return ((SolidPolygon) entry.polygon).getColor();
         // Snapshot triangles are not all SolidPolygons (e.g. textured
@@ -1418,6 +1472,28 @@ public class GlobalIllumination implements GiLightProvider {
         final double lightSignature = lightSignature();
         if (version == lastSeenRenderListVersion && lightSignature == lastLightSignature)
             return;
+        // Streaming worlds bump the render-list version per loaded cell:
+        // coalesce bursts instead of rebuilding the BVH back-to-back
+        // while the camera moves. Next loop iteration retries.
+        final long now = System.currentTimeMillis();
+        if (lastSeenRenderListVersion != -1
+                && now - lastRebuildTime < MIN_REBUILD_MS)
+            return;
+        // Single-flight: without this every worker notices the version
+        // bump at once and each builds the full snapshot redundantly
+        // (multi-million-triangle worlds: seconds wasted per worker).
+        if (!rebuildInFlight.compareAndSet(false, true))
+            return; // another worker is already building; retry next loop
+        try {
+            rebuildSnapshot(version, lightSignature, now);
+        } finally {
+            rebuildInFlight.set(false);
+        }
+    }
+
+    private void rebuildSnapshot(final int version,
+                                 final double lightSignature,
+                                 final long now) {
         if (DEBUG)
             System.out.println("[GI] rebuild trigger: renderListVersion "
                     + lastSeenRenderListVersion + " -> " + version
@@ -1426,6 +1502,9 @@ public class GlobalIllumination implements GiLightProvider {
 
         final List<AbstractCoordinateShape> triangles = new ArrayList<>();
         shapes.collectRenderTriangles(triangles);
+        final List<eu.svjatoslav.aukio.e3d.renderer.raster.shapes
+                .basic.texturedpolygon.TriangleMeshBlock> meshBlocks = new ArrayList<>();
+        shapes.collectMeshBlocks(meshBlocks);
         // Torn-read guard: a nested composite's cache can rebuild between
         // the version read above and this collect, yielding a partial
         // triangle set for a version that will never trigger again (the
@@ -1446,6 +1525,12 @@ public class GlobalIllumination implements GiLightProvider {
             if (entry.lightmap != null)
                 snap.lightmaps.add(entry.lightmap);
         }
+        for (final eu.svjatoslav.aukio.e3d.renderer.raster.shapes
+                .basic.texturedpolygon.TriangleMeshBlock block : meshBlocks) {
+            block.ensureGiLight();
+            for (int t = 0; t < block.triCount(); t++)
+                snap.entries.add(buildBlockEntry(block, t));
+        }
         if (!snap.entries.isEmpty())
             snap.bvh = new TriangleBvh(snap.entries);
         snap.lights = new ArrayList<>(lightingManager.getLights());
@@ -1522,6 +1607,7 @@ public class GlobalIllumination implements GiLightProvider {
         states.clear();
         lastSeenRenderListVersion = version;
         lastLightSignature = lightSignature;
+        lastRebuildTime = now;
         calmSweeps = 0; // scene changed: back to full-speed tracing
 
         if (DEBUG) {
@@ -1558,9 +1644,41 @@ public class GlobalIllumination implements GiLightProvider {
         entry.v[0] = (float) a.x; entry.v[1] = (float) a.y; entry.v[2] = (float) a.z;
         entry.v[3] = (float) b.x; entry.v[4] = (float) b.y; entry.v[5] = (float) b.z;
         entry.v[6] = (float) c.x; entry.v[7] = (float) c.y; entry.v[8] = (float) c.z;
-        entry.centroidX = (float) ((a.x + b.x + c.x) / 3);
-        entry.centroidY = (float) ((a.y + b.y + c.y) / 3);
-        entry.centroidZ = (float) ((a.z + b.z + c.z) / 3);
+        fillEntryGeometry(entry,
+                a.x, a.y, a.z, b.x, b.y, b.z, c.x, c.y, c.z);
+        return entry;
+    }
+
+    /**
+     * An entry tracing one mesh-block triangle: geometry comes from the
+     * block's flat world array (identity transforms — block vertices are
+     * baked world space, so local space IS world space).
+     */
+    private TriangleBvh.Entry buildBlockEntry(
+            final eu.svjatoslav.aukio.e3d.renderer.raster.shapes
+                    .basic.texturedpolygon.TriangleMeshBlock block,
+            final int tri) {
+        final TriangleBvh.Entry entry = new TriangleBvh.Entry(block, tri);
+        block.triangleVertices(tri, entry.v);
+        fillEntryGeometry(entry,
+                entry.v[0], entry.v[1], entry.v[2],
+                entry.v[3], entry.v[4], entry.v[5],
+                entry.v[6], entry.v[7], entry.v[8]);
+        return entry;
+    }
+
+    /**
+     * Fills an entry's centroid, AABB and unit normal from the triangle
+     * vertices (doubles, so the object-backed path keeps its exact
+     * vertex precision; the mesh-block path passes float-read values).
+     */
+    private static void fillEntryGeometry(final TriangleBvh.Entry entry,
+                                          final double ax, final double ay, final double az,
+                                          final double bx, final double by, final double bz,
+                                          final double cx, final double cy, final double cz) {
+        entry.centroidX = (float) ((ax + bx + cx) / 3);
+        entry.centroidY = (float) ((ay + by + cy) / 3);
+        entry.centroidZ = (float) ((az + bz + cz) / 3);
         entry.minX = Math.min(entry.v[0], Math.min(entry.v[3], entry.v[6]));
         entry.maxX = Math.max(entry.v[0], Math.max(entry.v[3], entry.v[6]));
         entry.minY = Math.min(entry.v[1], Math.min(entry.v[4], entry.v[7]));
@@ -1568,8 +1686,8 @@ public class GlobalIllumination implements GiLightProvider {
         entry.minZ = Math.min(entry.v[2], Math.min(entry.v[5], entry.v[8]));
         entry.maxZ = Math.max(entry.v[2], Math.max(entry.v[5], entry.v[8]));
         // Normal: right-handed cross(b-a, c-a), matching Plane.computeNormal.
-        final double e1x = b.x - a.x, e1y = b.y - a.y, e1z = b.z - a.z;
-        final double e2x = c.x - a.x, e2y = c.y - a.y, e2z = c.z - a.z;
+        final double e1x = bx - ax, e1y = by - ay, e1z = bz - az;
+        final double e2x = cx - ax, e2y = cy - ay, e2z = cz - az;
         double nx = e1y * e2z - e1z * e2y;
         double ny = e1z * e2x - e1x * e2z;
         double nz = e1x * e2y - e1y * e2x;
@@ -1580,7 +1698,6 @@ public class GlobalIllumination implements GiLightProvider {
             nz /= len;
         }
         entry.normal = new float[]{(float) nx, (float) ny, (float) nz};
-        return entry;
     }
 
     /** Cosine-weighted hemisphere direction around a normal. */
index d7ffcb6..5211618 100644 (file)
@@ -24,7 +24,13 @@ public class TriangleBvh {
 
     /** One ray-traced triangle with cached data. */
     public static class Entry {
+        /** Backing polygon, or null for mesh-block triangles. */
         public final AbstractCoordinateShape polygon;
+        /** Owning mesh block, or null for object-backed triangles. */
+        public final eu.svjatoslav.aukio.e3d.renderer.raster.shapes
+                .basic.texturedpolygon.TriangleMeshBlock block;
+        /** Triangle index inside {@link #block}, -1 for object-backed. */
+        public final int blockTri;
         /** Lightmap when the polygon is a {@link LightmappedShape}, else null. */
         public Lightmap lightmap;
         /** Triangle vertices, world space: x0,y0,z0, x1,y1,z1, x2,y2,z2. */
@@ -49,6 +55,22 @@ public class TriangleBvh {
 
         public Entry(final AbstractCoordinateShape polygon) {
             this.polygon = polygon;
+            this.block = null;
+            this.blockTri = -1;
+        }
+
+        /**
+         * An entry tracing one triangle of a
+         * {@link eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.texturedpolygon.TriangleMeshBlock}.
+         * Geometry is filled by the GI system from the block's flat
+         * arrays; the entry carries no per-triangle object reference.
+         */
+        public Entry(final eu.svjatoslav.aukio.e3d.renderer.raster.shapes
+                     .basic.texturedpolygon.TriangleMeshBlock block,
+                     final int blockTri) {
+            this.polygon = null;
+            this.block = block;
+            this.blockTri = blockTri;
         }
     }
 
index 5d18d0a..23dac4d 100644 (file)
@@ -479,14 +479,21 @@ public abstract class AbstractCoordinateShape extends AbstractShape {
                 maxX = onScreen.x;
         }
 
-        // Subpixel culling: raw projected span (before paint margins)
-        // below the threshold on both axes means the shape cannot cover
-        // even a fraction of one pixel. Cache the verdict so frames with
-        // a barely-moved camera skip this shape's whole transform setup.
+        final double marginY = getScreenYMargin(renderingContext);
+        final double marginX = getScreenXMargin(renderingContext);
+
+        // Subpixel culling: projected span INCLUDING paint margins below
+        // the threshold on both axes means the shape cannot cover even a
+        // fraction of one pixel. Margins must be part of the test:
+        // Billboard/GlowingPoint are single-vertex shapes whose entire
+        // visible area IS the margin — the raw vertex span is 0x0 and
+        // they would be culled no matter how huge the glow is.
+        // Cache the verdict so frames with a barely-moved camera skip
+        // this shape's whole transform setup.
         final double cullThreshold = renderingContext.subpixelCullingThreshold;
         if (cullThreshold > 0
-                && maxX - minX < cullThreshold
-                && maxY - minY < cullThreshold) {
+                && maxX - minX + 2 * marginX < cullThreshold
+                && maxY - minY + 2 * marginY < cullThreshold) {
             subpixelCulled = true;
             subpixelCulledEpoch = renderingContext.subpixelCullingEpoch;
             return;
@@ -494,10 +501,8 @@ public abstract class AbstractCoordinateShape extends AbstractShape {
         subpixelCulled = false;
 
         final double z = accumulatedZ / active.size();
-        final double marginY = getScreenYMargin(renderingContext);
         final double loY = minY - marginY;
         final double hiY = maxY + marginY;
-        final double marginX = getScreenXMargin(renderingContext);
         final double loX = minX - marginX;
         final double hiX = maxX + marginX;
         if (slot == 0) {
index 391b17d..897a12b 100644 (file)
@@ -75,6 +75,18 @@ final class MeshTriangle extends TexturedTriangle {
         final Point2D[] screen = SCREEN_SCRATCH.get();
         final Point2D[] uvs = UV_SCRATCH.get();
 
+        // Per-triangle GI light (null while no GI system traces this
+        // block): the span loops multiply texels by it. Values are read
+        // racily from the GI threads — fine for converging estimates.
+        final float[] giLight = block.giLight();
+        if (giLight != null) {
+            final int i = index * 3;
+            giShadeR = giLight[i];
+            giShadeG = giLight[i + 1];
+            giShadeB = giLight[i + 2];
+            giShaded = true;
+        }
+
         final int clip = block.clipOffset(slot, index);
         if (clip >= 0) {
             // Near-plane straddler: the block clipped to a loop of 3-4
index 643a930..a6c2c38 100644 (file)
@@ -122,6 +122,33 @@ public class TexturedTriangle extends AbstractCoordinateShape {
 
     private boolean backfaceCulling = Boolean.getBoolean("e3d.backface");
 
+    // ---- per-triangle GI shading (mesh-block path only) ----
+
+    /**
+     * Per-triangle light total in light units (255 = full texture
+     * brightness), written by {@code MeshTriangle.paint} from the owning
+     * block's GI arrays before the paint core runs. Object-backed
+     * triangles never touch these: {@link #giShaded} stays false and the
+     * span loops render bit-identically to the unlit path.
+     */
+    float giShadeR, giShadeG, giShadeB;
+    /** True while {@link #giShadeR/G/B} carry a GI light total. */
+    boolean giShaded;
+
+    /**
+     * Multiplies a sampled texel by the per-triangle GI light, clamped.
+     * Called only on the {@link #giShaded} path.
+     */
+    private int shadeGi(final int texel) {
+        final int r = Math.min(255,
+                (int) (((texel >> 16) & 0xff) * giShadeR / 255f));
+        final int g = Math.min(255,
+                (int) (((texel >> 8) & 0xff) * giShadeG / 255f));
+        final int b = Math.min(255,
+                (int) ((texel & 0xff) * giShadeB / 255f));
+        return (texel & 0xFF000000) | (r << 16) | (g << 8) | b;
+    }
+
     // --- ad-hoc frame profiling (-De3d.prof=true; dead code when off)
     /** Master switch, constant-folded when false. */
     private static final boolean PROF =
@@ -368,7 +395,8 @@ public class TexturedTriangle extends AbstractCoordinateShape {
                         else if (ity > texHMinus1) ity = texHMinus1;
                     }
 
-                    final int srcPixel = texPixels[ity * texW + itx];
+                    final int sampledPixel = texPixels[ity * texW + itx];
+                    final int srcPixel = giShaded ? shadeGi(sampledPixel) : sampledPixel;
                     final int srcAlpha = (srcPixel >> 24) & 0xff;
 
                     if (srcAlpha == 255) {
@@ -1404,7 +1432,8 @@ public class TexturedTriangle extends AbstractCoordinateShape {
                     else if (ity > texHMinus1) ity = texHMinus1;
                 }
 
-                final int srcPixel = texPixels[ity * texW + itx];
+                final int sampledPixel = texPixels[ity * texW + itx];
+                final int srcPixel = giShaded ? shadeGi(sampledPixel) : sampledPixel;
                 final int srcAlpha = (srcPixel >> 24) & 0xff;
 
                 if (srcAlpha == 255) {
index 5220870..ad92dba 100644 (file)
@@ -30,8 +30,18 @@ import eu.svjatoslav.aukio.e3d.math.TransformStack;
  * while the verdict epoch holds, a culled triangle costs one integer
  * compare per frame — no vertex math at all.</p>
  *
+ * <p>Global illumination: the GI system traces block triangles like any
+ * other scene triangle and writes per-triangle light totals (ambient +
+ * direct with shadows + indirect bounce) into {@link #giLight}. The
+ * paint path multiplies texture texels by that per-triangle light; while
+ * no GI system has stamped the block the array is null and painting is
+ * bit-identical to the unlit path. Render-side cost: one array read and
+ * three multiplies per triangle — ray casting happens only on the
+ * dedicated GI threads.</p>
+ *
  * <p>Limitations vs object-backed triangles: no mouse picking, no SDF
- * textures (rejected at build), no GI/lightmap integration.</p>
+ * textures (rejected at build), no per-texel lightmaps (GI lands per
+ * triangle — the right granularity for game meshes).</p>
  */
 public final class TriangleMeshBlock extends AbstractShape {
 
@@ -44,6 +54,27 @@ public final class TriangleMeshBlock extends AbstractShape {
     private final MeshTriangle[] handles;
     private final Box boundingBox;
 
+    /**
+     * Per-triangle GI light totals in light units (3 floats per triangle:
+     * r, g, b of ambient + direct-with-shadows + indirect), written by
+     * the global illumination system on its worker threads. Null while no
+     * GI system has taken this block into a snapshot: the paint path then
+     * renders the plain texture, bit-identical to the unlit path. The
+     * array starts filled with 255 (the historical full-texture look), so
+     * enabling GI shows no dark age — estimates glide to the traced
+     * solution near-to-far. Elements are plain floats: painters read them
+     * racily, which is fine for progressively converging values.
+     */
+    private volatile float[] giLight;
+
+    /**
+     * Lazy per-texture average color (albedo source for GI bounce rays),
+     * keyed by texture because a block's triangles share few textures.
+     * Block-lifetime cache; textures are immutable once decoded.
+     */
+    private volatile java.util.IdentityHashMap<Texture,
+            eu.svjatoslav.aukio.e3d.renderer.raster.Color> giAlbedos;
+
     // Per-slot projected state: 3 screen/camera doubles per triangle per
     // slot. (Sort Z and binning bounds are published into the handles'
     // per-slot fields at transform time, not kept here.)
@@ -161,6 +192,115 @@ public final class TriangleMeshBlock extends AbstractShape {
         return triCount;
     }
 
+    // ---- global illumination integration (engine-internal) ----
+
+    /**
+     * Allocates and publishes the per-triangle GI light array if absent
+     * (called by the GI system when this block enters a scene snapshot).
+     * Fresh arrays start at 255 per channel — the historical full-texture
+     * look — so enabling GI shows no dark age before the first estimates
+     * land. Idempotent and thread-safe.
+     */
+    public void ensureGiLight() {
+        if (giLight == null) {
+            synchronized (this) {
+                if (giLight == null) {
+                    final float[] array = new float[triCount * 3];
+                    java.util.Arrays.fill(array, 255f);
+                    giLight = array;
+                }
+            }
+        }
+    }
+
+    /**
+     * Writes one triangle's GI light total (light units, may exceed 255 —
+     * the paint path clamps). Called from GI worker threads only, and only
+     * after {@link #ensureGiLight()}.
+     */
+    public void setGiLight(final int tri, final float r, final float g,
+                           final float b) {
+        final float[] array = giLight;
+        if (array == null)
+            return;
+        final int i = tri * 3;
+        array[i] = r;
+        array[i + 1] = g;
+        array[i + 2] = b;
+    }
+
+    /**
+     * The per-triangle GI light array (3 floats per triangle), or null
+     * when no GI system has stamped this block — the paint path then
+     * renders the plain texture.
+     *
+     * @return the light array or null
+     */
+    public float[] giLight() {
+        return giLight;
+    }
+
+    /**
+     * Average color of the triangle's texture (albedo for GI bounce
+     * rays), sampled from the primary bitmap on a coarse stride and
+     * cached per texture. Cost is paid once per distinct texture, not
+     * per triangle.
+     *
+     * @param tri triangle index
+     * @return the average texture color
+     */
+    public eu.svjatoslav.aukio.e3d.renderer.raster.Color albedo(final int tri) {
+        final Texture texture = textures[tri];
+        if (texture == null)
+            return new eu.svjatoslav.aukio.e3d.renderer.raster.Color(128, 128, 128);
+        java.util.IdentityHashMap<Texture,
+                eu.svjatoslav.aukio.e3d.renderer.raster.Color> cache = giAlbedos;
+        if (cache == null) {
+            synchronized (this) {
+                if (giAlbedos == null)
+                    giAlbedos = new java.util.IdentityHashMap<>();
+                cache = giAlbedos;
+            }
+        }
+        synchronized (cache) {
+            final eu.svjatoslav.aukio.e3d.renderer.raster.Color cached =
+                    cache.get(texture);
+            if (cached != null)
+                return cached;
+            final int[] pixels = texture.primaryBitmap.pixels;
+            // Coarse stride: ~2000 samples are plenty for an average.
+            final int stride = Math.max(1, pixels.length / 2048);
+            long r = 0, g = 0, b = 0;
+            int n = 0;
+            for (int i = 0; i < pixels.length; i += stride) {
+                final int px = pixels[i];
+                r += (px >> 16) & 0xff;
+                g += (px >> 8) & 0xff;
+                b += px & 0xff;
+                n++;
+            }
+            final eu.svjatoslav.aukio.e3d.renderer.raster.Color average =
+                    new eu.svjatoslav.aukio.e3d.renderer.raster.Color(
+                            (int) (r / n), (int) (g / n), (int) (b / n));
+            cache.put(texture, average);
+            return average;
+        }
+    }
+
+    /**
+     * Copies the triangle's 9 world-space vertex coordinates into
+     * {@code out} (x0,y0,z0, x1,y1,z1, x2,y2,z2). Used by the GI system
+     * at snapshot build.
+     *
+     * @param tri triangle index
+     * @param out destination array of at least 9 floats
+     */
+    public void triangleVertices(final int tri, final float[] out) {
+        final int w = tri * 9;
+        for (int i = 0; i < 9; i++)
+            out[i] = (float) world[w + i];
+    }
+
     @Override
     public Box getBoundingBox() {
         return boundingBox;
index 776d21b..d692ba3 100644 (file)
@@ -69,6 +69,10 @@ import java.util.concurrent.atomic.AtomicInteger;
  * @see eu.svjatoslav.aukio.e3d.renderer.raster.shapes.AbstractShape the base shape class
  */
 public class AbstractCompositeShape extends AbstractShape {
+
+    /** Traces frustum-culled composites' bounds ({@code -De3d.cull.frustumTrace=true}). */
+    private static final boolean FRUSTUM_TRACE =
+            Boolean.getBoolean("e3d.cull.frustumTrace");
     /**
      * Source-of-truth registry of all sub-shapes added to this composite.
      *
@@ -761,6 +765,33 @@ public class AbstractCompositeShape extends AbstractShape {
         }
     }
 
+    /**
+     * Collects every {@code TriangleMeshBlock} of this composite's
+     * triangulated render list into {@code out}, recursing into nested
+     * composites. Mesh-block triangles are not {@link AbstractCoordinateShape}
+     * objects, so {@link #collectRenderTriangles} cannot surface them; the
+     * global illumination system uses this to trace lean mesh geometry.
+     * Same lazy-cache contract as {@link #collectRenderTriangles}:
+     * composites not yet transformed (or frustum-culled) contribute
+     * nothing.
+     *
+     * @param out list receiving the blocks
+     */
+    public void collectMeshBlocks(
+            final List<eu.svjatoslav.aukio.e3d.renderer.raster.shapes
+                    .basic.texturedpolygon.TriangleMeshBlock> out) {
+        if (cachedRenderList == null)
+            return;
+        for (final AbstractShape shape : cachedRenderList) {
+            if (shape instanceof eu.svjatoslav.aukio.e3d.renderer.raster
+                    .shapes.basic.texturedpolygon.TriangleMeshBlock)
+                out.add((eu.svjatoslav.aukio.e3d.renderer.raster.shapes
+                        .basic.texturedpolygon.TriangleMeshBlock) shape);
+            else if (shape instanceof AbstractCompositeShape)
+                ((AbstractCompositeShape) shape).collectMeshBlocks(out);
+        }
+    }
+
     /**
      * Hook: post-processes the freshly rebuilt render list before it becomes
      * the rendering cache. The default implementation returns the list
@@ -876,6 +907,15 @@ public class AbstractCompositeShape extends AbstractShape {
 
             if (!visible) {
                 // Entire composite outside frustum - skip processing all children
+                if (FRUSTUM_TRACE)
+                    System.out.printf(java.util.Locale.ROOT,
+                            "[FRUSTUM-CULL] local=[%.0f..%.0f, %.0f..%.0f, %.0f..%.0f]"
+                                    + " view x=%.0f..%.0f y=%.0f..%.0f z=%.0f..%.0f%n",
+                            localBounds.getMinX(), localBounds.getMaxX(),
+                            localBounds.getMinY(), localBounds.getMaxY(),
+                            localBounds.getMinZ(), localBounds.getMaxZ(),
+                            viewMinX, viewMaxX, viewMinY, viewMaxY,
+                            viewMinZ, viewMaxZ);
                 if (context.cullingStatistics != null) {
                     context.cullingStatistics.culledComposites.incrementAndGet();
                 }