| ~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()~ |
- ~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)
- 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
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
- 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:
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
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.
*
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) {
* 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
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. */
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;
/** 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).
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;
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;
? (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));
}
}
(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);
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);
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;
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);
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
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
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
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());
states.clear();
lastSeenRenderListVersion = version;
lastLightSignature = lightSignature;
+ lastRebuildTime = now;
calmSweeps = 0; // scene changed: back to full-speed tracing
if (DEBUG) {
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]));
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;
nz /= len;
}
entry.normal = new float[]{(float) nx, (float) ny, (float) nz};
- return entry;
}
/** Cosine-weighted hemisphere direction around a normal. */
/** 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. */
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;
}
}
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;
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) {
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
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 =
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) {
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) {
* 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 {
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.)
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;
* @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.
*
}
}
+ /**
+ * 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
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();
}