From: Svjatoslav Agejenko
Date: Mon, 28 Sep 2026 20:53:42 +0000 (+0300)
Subject: feat(e3d): GI for mesh blocks, frustum far-plane and subpixel cull fixes
X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;h=27deab957f9071f2139957cc00c9c6a7c61382b4;p=aukio-3d.git
feat(e3d): GI for mesh blocks, frustum far-plane and subpixel cull fixes
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.
---
diff --git a/AGENTS.org b/AGENTS.org
index cd5e17b..e774822 100644
--- a/AGENTS.org
+++ b/AGENTS.org
@@ -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)
diff --git a/Documentation/Frustum culling/index.org b/Documentation/Frustum culling/index.org
index 9c4a941..52ed1b2 100644
--- a/Documentation/Frustum culling/index.org
+++ b/Documentation/Frustum culling/index.org
@@ -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==). 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
diff --git a/Documentation/Global illumination/index.org b/Documentation/Global illumination/index.org
index 5419b80..c590258 100644
--- a/Documentation/Global illumination/index.org
+++ b/Documentation/Global illumination/index.org
@@ -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:
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/Frustum.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/Frustum.java
index 47f7f39..e656cd5 100644
--- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/Frustum.java
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/Frustum.java
@@ -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=.
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
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.java
index da63431..1f614af 100755
--- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.java
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/ShapeCollection.java
@@ -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 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) {
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java
index 0e2d921..de6bf25 100644
--- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/GlobalIllumination.java
@@ -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.
+ *
Mesh blocks ({@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).
*
*
*
Estimator: 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 states = new ConcurrentHashMap<>();
+ /** Progressive per-polygon GI state, keyed by polygon or by entry (mesh blocks). */
+ private final ConcurrentHashMap
*
+ *
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.
+ *
*
Limitations vs object-backed triangles: no mouse picking, no SDF
- * textures (rejected at build), no GI/lightmap integration.
+ * textures (rejected at build), no per-texel lightmaps (GI lands per
+ * triangle â the right granularity for game meshes).
*/
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 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 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;
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java
index 776d21b..d692ba3 100644
--- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/base/AbstractCompositeShape.java
@@ -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 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();
}