From 27deab957f9071f2139957cc00c9c6a7c61382b4 Mon Sep 17 00:00:00 2001 From: Svjatoslav Agejenko Date: Mon, 28 Sep 2026 23:53:42 +0300 Subject: [PATCH] feat(e3d): GI for mesh blocks, frustum far-plane and subpixel cull fixes MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.org | 6 +- Documentation/Frustum culling/index.org | 12 +- Documentation/Global illumination/index.org | 33 ++++ .../aukio/e3d/renderer/raster/Frustum.java | 11 +- .../e3d/renderer/raster/ShapeCollection.java | 24 ++- .../raster/gi/GlobalIllumination.java | 143 ++++++++++++++++-- .../e3d/renderer/raster/gi/TriangleBvh.java | 22 +++ .../shapes/AbstractCoordinateShape.java | 21 ++- .../basic/texturedpolygon/MeshTriangle.java | 12 ++ .../texturedpolygon/TexturedTriangle.java | 33 +++- .../texturedpolygon/TriangleMeshBlock.java | 142 ++++++++++++++++- .../base/AbstractCompositeShape.java | 40 +++++ 12 files changed, 468 insertions(+), 31 deletions(-) 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 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 triangles = new ArrayList<>(); shapes.collectRenderTriangles(triangles); + final List 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. */ diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java index d7ffcb6..5211618 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/TriangleBvh.java @@ -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; } } diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.java index 5d18d0a..23dac4d 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/AbstractCoordinateShape.java @@ -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) { diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/MeshTriangle.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/MeshTriangle.java index 391b17d..897a12b 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/MeshTriangle.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/MeshTriangle.java @@ -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 diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java index 643a930..a6c2c38 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TexturedTriangle.java @@ -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) { diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TriangleMeshBlock.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TriangleMeshBlock.java index 5220870..ad92dba 100644 --- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TriangleMeshBlock.java +++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/texturedpolygon/TriangleMeshBlock.java @@ -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.

    * + *

    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(); } -- 2.20.1