From: Svjatoslav Agejenko
Usage:
*{@code
@@ -102,6 +145,44 @@ public class GlobalIllumination implements GiLightProvider {
/** Albedo for snapshot entries that carry no flat color (textured triangles). */
private static final Color FALLBACK_ALBEDO = new Color(128, 128, 128);
+ /**
+ * Phased scheduler (centroid direct -> centroid bounce -> per-texel,
+ * near-to-far). False restores the legacy flat round-robin over all
+ * work items. {@code -De3d.gi.phases}.
+ */
+ private static final boolean PHASES =
+ Boolean.parseBoolean(System.getProperty("e3d.gi.phases", "true"));
+
+ /**
+ * Phases B/C: how many entries are sampled concurrently, nearest
+ * first; graduates are replaced by the next-nearest entry.
+ * {@code -De3d.gi.activeWindow}.
+ */
+ private static final int ACTIVE_WINDOW =
+ Integer.parseInt(System.getProperty("e3d.gi.activeWindow", "256"));
+
+ /**
+ * Camera translation (world units) that triggers a re-sort of the
+ * not-yet-activated work queue. {@code -De3d.gi.resortDistance}.
+ */
+ private static final double RESORT_DISTANCE =
+ Double.parseDouble(System.getProperty("e3d.gi.resortDistance", "25"));
+
+ /** Consecutive calm visits/updates that graduate an entry out of the active window. */
+ private static final int GRADUATE_CALM = 3;
+
+ /**
+ * Phase C display freeze: after sampling graduation, a lightmap keeps
+ * recompositing (rays stopped â cheap) until its per-texel estimate
+ * movement falls below this many light units. Graduation can freeze
+ * the outer EMA several light units short of the target (calm at 1.0
+ * with alpha 0.2 = residual distance < 5), which survives as visible
+ * per-triangle seams against neighboring stamps; the tail erodes it
+ * asymptotically. {@code -De3d.gi.freezeThreshold}.
+ */
+ private static final double FREEZE_THRESHOLD =
+ Double.parseDouble(System.getProperty("e3d.gi.freezeThreshold", "0.1"));
+
/**
* EMA policy for the inner per-sample indirect blend: "fixed" (default,
* 0.15) keeps every ray hit equally intensive forever â fading toward
@@ -127,18 +208,33 @@ public class GlobalIllumination implements GiLightProvider {
Double.parseDouble(System.getProperty("e3d.gi.compositeAlpha", "0.2"));
/**
- * Convergence: declared after five consecutive composite updates whose
- * average per-texel estimate movement falls below this many light
- * units. With a constant alpha the estimate never freezes completely
- * (Monte Carlo jitter), so this judges the VISIBLE movement, not the
- * per-sample deltas. {@code -De3d.gi.calmThreshold}.
+ * Convergence: legacy mode declares it after five consecutive composite
+ * updates whose average per-texel estimate movement falls below this
+ * many light units; phased mode uses it as the per-entry calm
+ * threshold for graduation. With a constant alpha the estimate never
+ * freezes completely (Monte Carlo jitter), so this judges the VISIBLE
+ * movement, not the per-sample deltas. {@code -De3d.gi.calmThreshold}.
*/
private static final double CALM_THRESHOLD =
Double.parseDouble(System.getProperty("e3d.gi.calmThreshold", "1.0"));
+ /** Progressive phases, strict global order. */
+ private enum Phase {
+ /** Direct light at polygon centroids, near-to-far, one visit each. */
+ CENTROID_DIRECT,
+ /** Multi-bounce indirect at polygon centroids, near-to-far windowed. */
+ CENTROID_BOUNCE,
+ /** Per-texel refinement of lightmapped triangles, near-to-far windowed. */
+ TEXEL,
+ /** Everything graduated; workers idle. */
+ DONE
+ }
+
private final ShapeCollection shapes;
private final LightingManager lightingManager;
private final int threadCount;
+ /** Camera position source for distance ordering; null disables sorting/re-sorting. */
+ private final Supplier cameraPosition;
private final List threads = new ArrayList<>();
private volatile boolean running;
@@ -155,22 +251,53 @@ public class GlobalIllumination implements GiLightProvider {
private final AtomicInteger workIndex = new AtomicInteger();
private volatile int calmSweeps;
private volatile long lastCompositeUpdate;
+
+ /** Last DEBUG heartbeat timestamp (any worker). */
+ private volatile long lastHeartbeat;
private final java.util.concurrent.atomic.AtomicBoolean compositeUpdateInFlight =
new java.util.concurrent.atomic.AtomicBoolean();
+ // --- Phased scheduler state (guarded by queueLock except where noted) ---
+
+ /** Current phase; volatile, workers read it every iteration. */
+ private volatile Phase phase = Phase.CENTROID_DIRECT;
+ private final Object queueLock = new Object();
+ /** Work queue of the current phase, sorted near-to-far. */
+ private TriangleBvh.Entry[] phaseQueue = new TriangleBvh.Entry[0];
+ /** Phase A: next entry to compute. Phases B/C: next entry to activate. */
+ private int queueCursor;
+ /** Phase A visits currently being processed by a worker. */
+ private final AtomicInteger inFlight = new AtomicInteger();
+ /** Active window entries (phases B/C). */
+ private final List activeList = new ArrayList<>();
+ /** Published snapshot of the active window for lock-free round-robin. */
+ private volatile TriangleBvh.Entry[] active = new TriangleBvh.Entry[0];
+ /** Round-robin cursor over {@link #active}. */
+ private final AtomicInteger activeCursor = new AtomicInteger();
+ /** Camera position at snapshot build / last re-sort. */
+ private double lastCameraX = Double.NaN, lastCameraY, lastCameraZ;
+
private static class Snapshot {
List entries;
TriangleBvh bvh;
List lights;
IdentityHashMap lightIndex;
double ambientR, ambientG, ambientB;
- /** Flattened work list: one item per lightmap texel / plain polygon. */
+ /** Flattened work list (legacy mode): one item per lightmap texel / plain polygon. */
WorkItem[] workItems;
/** All lightmaps in the snapshot (for composite updates). */
List lightmaps;
+ /** Phased mode: all entries sorted near-to-far from the camera. */
+ TriangleBvh.Entry[] sortedByDistance;
+ /** Phased mode: lightmapped entries only, sorted near-to-far. */
+ TriangleBvh.Entry[] lightmappedSorted;
+ /** Phased mode: lightmap back to its snapshot entry (phase C graduation). */
+ IdentityHashMap lightmapEntry;
+ /** Phase C lightmaps that graduated sampling but have not display-frozen yet. */
+ int tailCount;
}
- /** One unit of GI work: a lightmap texel, or a whole plain polygon. */
+ /** One unit of legacy GI work: a lightmap texel, or a whole plain polygon. */
private static class WorkItem {
TriangleBvh.Entry entry;
int texel; // -1 = plain polygon
@@ -187,6 +314,7 @@ public class GlobalIllumination implements GiLightProvider {
/**
* Creates the GI system. Call {@link #start()} to begin tracing.
+ * Distance ordering is disabled (uniform order, no camera re-sort).
*
* @param shapes the scene to trace
* @param lightingManager the lights to sample
@@ -195,9 +323,27 @@ public class GlobalIllumination implements GiLightProvider {
public GlobalIllumination(final ShapeCollection shapes,
final LightingManager lightingManager,
final int threadCount) {
+ this(shapes, lightingManager, threadCount, null);
+ }
+
+ /**
+ * Creates the GI system. Call {@link #start()} to begin tracing.
+ *
+ * @param shapes the scene to trace
+ * @param lightingManager the lights to sample
+ * @param threadCount dedicated worker threads (2 is a good default)
+ * @param cameraPosition supplies the camera position for near-to-far
+ * ordering and re-sort triggers; null disables
+ * distance ordering (uniform order)
+ */
+ public GlobalIllumination(final ShapeCollection shapes,
+ final LightingManager lightingManager,
+ final int threadCount,
+ final Supplier cameraPosition) {
this.shapes = shapes;
this.lightingManager = lightingManager;
this.threadCount = Math.max(1, threadCount);
+ this.cameraPosition = cameraPosition;
}
/** Registers the GI provider and starts the worker threads. */
@@ -235,26 +381,31 @@ public class GlobalIllumination implements GiLightProvider {
/**
* Returns whether the solution has converged (workers idling at a low
- * duty cycle). Convergence is declared after five consecutive composite
- * updates whose average per-texel estimate movement is below
- * {@code e3d.gi.calmThreshold} (default 1.0 light unit).
+ * duty cycle). Legacy mode: five consecutive composite updates below
+ * {@code e3d.gi.calmThreshold}. Phased mode: all phases drained
+ * (every entry graduated out of the active window).
*
* @return {@code true} when converged
*/
public boolean isConverged() {
- return calmSweeps >= 5;
+ return PHASES ? phase == Phase.DONE : calmSweeps >= 5;
}
/**
- * Returns the number of work items in the current scene snapshot
- * (one per lightmap texel plus one per plain polygon), or 0 when no
- * snapshot has been built yet.
+ * Returns the number of schedulable work units in the current scene
+ * snapshot: phased mode counts polygons, legacy mode counts one item
+ * per lightmap texel plus one per plain polygon. 0 when no snapshot
+ * has been built yet.
*
- * @return the work item count
+ * @return the work unit count
*/
public int getWorkItemCount() {
final Snapshot snap = snapshot;
- return snap == null || snap.workItems == null ? 0 : snap.workItems.length;
+ if (snap == null)
+ return 0;
+ if (PHASES)
+ return snap.sortedByDistance == null ? 0 : snap.sortedByDistance.length;
+ return snap.workItems == null ? 0 : snap.workItems.length;
}
// ------------------------------------------------------------------
@@ -301,46 +452,50 @@ public class GlobalIllumination implements GiLightProvider {
try {
maybeRebuildSnapshot();
final Snapshot snap = snapshot;
- if (snap == null || snap.workItems.length == 0) {
+ if (snap == null || snap.entries.isEmpty()) {
Thread.sleep(100);
continue;
}
- // One sweep over all work items. Convergence is judged by
- // composite-estimate movement inside updateComposites()
- // (per-sample deltas are Monte Carlo noise and, with the
- // fixed alpha, never settle).
final long sweepStart = System.currentTimeMillis();
- final int size = snap.workItems.length;
- double deltaSum = 0;
- for (int i = 0; i < size && running; i++) {
- final int index = Math.floorMod(workIndex.getAndIncrement(), size);
- deltaSum += sample(snap, snap.workItems[index], hit, pos);
- }
- final double avgDelta = deltaSum / size;
+ if (PHASES)
+ phasedStep(snap, hit, pos);
+ else
+ legacySweep(snap, hit, pos);
// Regenerate composite textures at most every
// COMPOSITE_INTERVAL_MS; a paint pass is one texture swap
- // per triangle, invisible to the render threads. Also
- // advances the convergence counter.
+ // per triangle, invisible to the render threads.
final long now = System.currentTimeMillis();
if (now - lastCompositeUpdate >= COMPOSITE_INTERVAL_MS) {
updateComposites(snap);
lastCompositeUpdate = now;
}
- if (DEBUG)
- System.out.println("[GI] sweep done, avgDelta=" + String.format("%.2f", avgDelta)
- + ", calmSweeps=" + calmSweeps);
+ // Heartbeat: where the scheduler sits; a stall (phase not
+ // DONE forever) shows up as an unchanging line.
+ if (DEBUG && PHASES && now - lastHeartbeat >= 10000) {
+ lastHeartbeat = now;
+ synchronized (queueLock) {
+ System.out.println("[GI] heartbeat: phase=" + phase
+ + " active=" + activeList.size()
+ + " queue=" + queueCursor + "/" + phaseQueue.length
+ + " tail=" + snap.tailCount);
+ }
+ }
if (isConverged()) {
- // Converged: cap duty cycle at ~50% of sweep time
- // (a big scene's sweep takes seconds; a flat 250ms
- // sleep would barely throttle it). Hard cap keeps
- // post-edit re-convergence prompt.
- final long sweepMillis = System.currentTimeMillis() - sweepStart;
- Thread.sleep(Math.min(IDLE_SLEEP_MAX_MS,
- Math.max(IDLE_SLEEP_MS, sweepMillis)));
+ // Converged: cap duty cycle. Legacy mode proportions
+ // the sleep to the sweep; phased DONE has no sweep
+ // concept and sleeps at the minimum cadence (a scene
+ // edit rebuilds the snapshot and restarts phase A).
+ if (PHASES)
+ Thread.sleep(IDLE_SLEEP_MS);
+ else {
+ final long sweepMillis = System.currentTimeMillis() - sweepStart;
+ Thread.sleep(Math.min(IDLE_SLEEP_MAX_MS,
+ Math.max(IDLE_SLEEP_MS, sweepMillis)));
+ }
}
} catch (final InterruptedException e) {
return;
@@ -355,119 +510,523 @@ public class GlobalIllumination implements GiLightProvider {
}
}
- /** One progressive sample: one shadow ray + one bounce ray. */
+ /** Legacy scheduler: one round-robin sweep over all work items. */
+ private void legacySweep(final Snapshot snap, final TriangleBvh.Hit hit, final double[] pos) {
+ if (snap.workItems.length == 0)
+ return;
+ // Convergence is judged by composite-estimate movement inside
+ // updateComposites() (per-sample deltas are Monte Carlo noise and,
+ // with the fixed alpha, never settle).
+ final int size = snap.workItems.length;
+ double deltaSum = 0;
+ for (int i = 0; i < size && running; i++) {
+ final int index = Math.floorMod(workIndex.getAndIncrement(), size);
+ deltaSum += sample(snap, snap.workItems[index], hit, pos);
+ }
+ if (DEBUG)
+ System.out.println("[GI] sweep done, avgDelta=" + String.format("%.2f", deltaSum / size)
+ + ", calmSweeps=" + calmSweeps);
+ }
+
+ // ------------------------------------------------------------------
+ // Phased scheduler
+ // ------------------------------------------------------------------
+
+ /** One phased work step: a phase-A one-shot, or one sample in the active window. */
+ private void phasedStep(final Snapshot snap, final TriangleBvh.Hit hit,
+ final double[] pos) throws InterruptedException {
+ maybeResort(snap);
+ switch (phase) {
+ case CENTROID_DIRECT: {
+ final TriangleBvh.Entry entry = grabPhaseA();
+ if (entry == null) {
+ advancePhaseIfDrained(snap);
+ Thread.sleep(10);
+ return;
+ }
+ try {
+ sampleCentroidDirect(snap, entry);
+ } finally {
+ inFlight.decrementAndGet();
+ }
+ return;
+ }
+ case CENTROID_BOUNCE: {
+ final TriangleBvh.Entry entry = grabActive();
+ if (entry == null) {
+ advancePhaseIfDrained(snap);
+ Thread.sleep(10);
+ return;
+ }
+ if (entry.graduated)
+ return; // raced with graduation: the extra visit is pointless
+ final double delta = sampleCentroidBounce(snap, entry, hit);
+ if (delta < CALM_THRESHOLD) {
+ if (++entry.calmVisits >= GRADUATE_CALM)
+ // The sample took time; a B->C transition may have
+ // happened meanwhile. Graduating now would mark the
+ // entry done without phase C ever seeing it (the
+ // window fill skips graduated entries), and would
+ // leak a freeze-tail slot: guard on the phase.
+ graduate(entry, Phase.CENTROID_BOUNCE);
+ } else {
+ entry.calmVisits = 0;
+ }
+ return;
+ }
+ case TEXEL: {
+ final TriangleBvh.Entry entry = grabActive();
+ if (entry == null) {
+ advancePhaseIfDrained(snap);
+ Thread.sleep(10);
+ return;
+ }
+ if (entry.graduated)
+ return;
+ final Lightmap lightmap = entry.lightmap;
+ final int texel = lightmap.validTexels[
+ Math.floorMod(lightmap.nextTexel++, lightmap.validTexels.length)];
+ sampleTexel(snap, entry, texel, hit, pos);
+ return;
+ }
+ default:
+ // DONE: nothing to sample; the converged sleep is in workLoop.
+ }
+ }
+
+ /** Phase A: takes the next uncomputed entry, or null when the queue is drained. */
+ private TriangleBvh.Entry grabPhaseA() {
+ synchronized (queueLock) {
+ if (queueCursor >= phaseQueue.length)
+ return null;
+ inFlight.incrementAndGet();
+ return phaseQueue[queueCursor++];
+ }
+ }
+
+ /** Phases B/C: round-robin pick from the active window, or null when empty. */
+ private TriangleBvh.Entry grabActive() {
+ final TriangleBvh.Entry[] act = active;
+ if (act.length == 0)
+ return null;
+ return act[Math.floorMod(activeCursor.getAndIncrement(), act.length)];
+ }
+
+ /**
+ * Advances the phase when the current one is drained: phase A on queue
+ * drain with zero in-flight visits, phases B/C when the window is empty
+ * and no entries remain to activate.
+ */
+ private void advancePhaseIfDrained(final Snapshot snap) {
+ synchronized (queueLock) {
+ switch (phase) {
+ case CENTROID_DIRECT:
+ if (queueCursor >= phaseQueue.length && inFlight.get() == 0) {
+ phase = Phase.CENTROID_BOUNCE;
+ phaseQueue = snap.sortedByDistance;
+ queueCursor = 0;
+ fillActiveWindowLocked();
+ if (DEBUG)
+ System.out.println("[GI] phase A drained, entering centroid bounce");
+ }
+ break;
+ case CENTROID_BOUNCE:
+ if (activeList.isEmpty() && queueCursor >= phaseQueue.length) {
+ phase = Phase.TEXEL;
+ phaseQueue = snap.lightmappedSorted;
+ queueCursor = 0;
+ // Phase B graduated every entry; phase C reuses the
+ // same Entry objects, so re-arm graduation for the
+ // per-texel convergence judgement.
+ for (final TriangleBvh.Entry entry : phaseQueue) {
+ entry.graduated = false;
+ entry.calmVisits = 0;
+ }
+ fillActiveWindowLocked();
+ if (DEBUG)
+ System.out.println("[GI] phase B converged, entering per-texel refinement ("
+ + phaseQueue.length + " lightmaps)");
+ }
+ break;
+ case TEXEL:
+ if (activeList.isEmpty() && queueCursor >= phaseQueue.length
+ && snap.tailCount == 0) {
+ phase = Phase.DONE;
+ if (DEBUG)
+ System.out.println("[GI] phase C converged, GI idling");
+ }
+ break;
+ default:
+ }
+ }
+ }
+
+ /**
+ * Moves an entry out of the active window and backfills from the queue.
+ * The graduation only applies when the engine is still in {@code expected}
+ * phase: a sample that started before a phase transition must not
+ * graduate the entry in the new phase (it would be skipped there
+ * forever, and in phase C would leak a freeze-tail slot).
+ */
+ private void graduate(final TriangleBvh.Entry entry, final Phase expected) {
+ synchronized (queueLock) {
+ if (phase != expected || entry.graduated)
+ return;
+ entry.graduated = true;
+ activeList.remove(entry);
+ final Snapshot snap = snapshot;
+ if (phase == Phase.TEXEL && snap != null)
+ snap.tailCount++; // enters the display-freeze tail
+ fillActiveWindowLocked();
+ }
+ }
+
+ /** Fills the active window from the queue up to {@link #ACTIVE_WINDOW}. */
+ private void fillActiveWindowLocked() {
+ while (activeList.size() < ACTIVE_WINDOW && queueCursor < phaseQueue.length) {
+ final TriangleBvh.Entry entry = phaseQueue[queueCursor++];
+ if (entry.graduated)
+ continue;
+ if (phase == Phase.TEXEL)
+ entry.lightmap.texelPhase = true;
+ activeList.add(entry);
+ }
+ active = activeList.toArray(new TriangleBvh.Entry[0]);
+ }
+
+ /**
+ * Re-sorts the not-yet-activated queue tail when the camera has moved
+ * more than {@link #RESORT_DISTANCE} since the snapshot build or the
+ * last re-sort. Active and graduated entries keep their state.
+ */
+ private void maybeResort(final Snapshot snap) {
+ if (cameraPosition == null)
+ return;
+ final Point3D cam = cameraPosition.get();
+ if (cam == null)
+ return;
+ if (Double.isNaN(lastCameraX)) {
+ lastCameraX = cam.x;
+ lastCameraY = cam.y;
+ lastCameraZ = cam.z;
+ return;
+ }
+ final double dx = cam.x - lastCameraX;
+ final double dy = cam.y - lastCameraY;
+ final double dz = cam.z - lastCameraZ;
+ if (dx * dx + dy * dy + dz * dz <= RESORT_DISTANCE * RESORT_DISTANCE)
+ return;
+ synchronized (queueLock) {
+ // Re-check under the lock: another worker may have re-sorted already.
+ final double dx2 = cam.x - lastCameraX;
+ final double dy2 = cam.y - lastCameraY;
+ final double dz2 = cam.z - lastCameraZ;
+ if (dx2 * dx2 + dy2 * dy2 + dz2 * dz2 <= RESORT_DISTANCE * RESORT_DISTANCE)
+ return;
+ lastCameraX = cam.x;
+ lastCameraY = cam.y;
+ lastCameraZ = cam.z;
+ final int from = queueCursor;
+ if (from >= phaseQueue.length)
+ return;
+ for (int i = from; i < phaseQueue.length; i++)
+ phaseQueue[i].distance = distanceSquared(phaseQueue[i], cam);
+ Arrays.sort(phaseQueue, from, phaseQueue.length,
+ Comparator.comparingDouble(e -> e.distance));
+ if (DEBUG)
+ System.out.println("[GI] re-sorted " + (phaseQueue.length - from)
+ + " pending entries (camera moved)");
+ }
+ }
+
+ private static float distanceSquared(final TriangleBvh.Entry entry, final Point3D cam) {
+ final double dx = entry.centroidX - cam.x;
+ final double dy = entry.centroidY - cam.y;
+ final double dz = entry.centroidZ - cam.z;
+ return (float) (dx * dx + dy * dy + dz * dz);
+ }
+
+ // ------------------------------------------------------------------
+ // Sampling
+ // ------------------------------------------------------------------
+
+ /** One legacy progressive sample: one shadow ray + one bounce ray. */
private double sample(final Snapshot snap, final WorkItem item,
final TriangleBvh.Hit hit, final double[] pos) {
- final TriangleBvh.Entry entry = item.entry;
- final Lightmap lightmap = entry.lightmap;
+ if (item.entry.lightmap != null)
+ return sampleTexel(snap, item.entry, item.texel, hit, pos);
+ return samplePlain(snap, item.entry, true, hit);
+ }
- final double ox, oy, oz, nx, ny, nz;
- if (lightmap != null) {
- lightmap.texelWorldPosition(item.texel, pos);
- nx = lightmap.normalX;
- ny = lightmap.normalY;
- nz = lightmap.normalZ;
- ox = pos[0] + nx * ORIGIN_EPSILON;
- oy = pos[1] + ny * ORIGIN_EPSILON;
- oz = pos[2] + nz * ORIGIN_EPSILON;
- } else {
- nx = entry.normal[0];
- ny = entry.normal[1];
- nz = entry.normal[2];
- ox = entry.centroidX + nx * ORIGIN_EPSILON;
- oy = entry.centroidY + ny * ORIGIN_EPSILON;
- oz = entry.centroidZ + nz * ORIGIN_EPSILON;
- }
+ /**
+ * Samples one lightmap texel: shadow ray(s) plus one bounce ray and
+ * the per-texel indirect EMA update. Used by the legacy scheduler and
+ * by phased mode's phase C.
+ *
+ * @return the EMA-weighted estimate delta (convergence signal)
+ */
+ private double sampleTexel(final Snapshot snap, final TriangleBvh.Entry entry,
+ final int texel, final TriangleBvh.Hit hit, final double[] pos) {
+ final Lightmap lightmap = entry.lightmap;
+ lightmap.texelWorldPosition(texel, pos);
+ final double nx = lightmap.normalX;
+ final double ny = lightmap.normalY;
+ final double nz = lightmap.normalZ;
+ final double ox = pos[0] + nx * ORIGIN_EPSILON;
+ final double oy = pos[1] + ny * ORIGIN_EPSILON;
+ final double oz = pos[2] + nz * ORIGIN_EPSILON;
// 1. Shadow rays. First visit per texel: test ALL lights, so direct
// light + hard shadows appear after one sweep instead of trickling
// in over lightCount sweeps. Afterwards: one light, round-robin.
final int lightCount = snap.lights.size();
+ final boolean firstVisit = lightmap.sampleCounts[texel] == 0;
+ if (firstVisit)
+ lightmap.texelsSampled++; // graduation requires full coverage
if (lightCount > 0) {
- if (lightmap != null) {
- lightmap.ensureLightCapacity(lightCount);
- final boolean firstVisit = lightmap.sampleCounts[item.texel] == 0;
- if (firstVisit) {
- for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++)
- lightmap.lightVisibility[item.texel * lightCount + i] =
- shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(i))
- ? Lightmap.VISIBILITY_VISIBLE : Lightmap.VISIBILITY_OCCLUDED;
- } else {
- final int lightIdx = lightmap.nextLight++ % lightCount;
- lightmap.lightVisibility[item.texel * lightCount + lightIdx] =
- shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(lightIdx))
+ lightmap.ensureLightCapacity(lightCount);
+ if (firstVisit) {
+ for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++)
+ lightmap.lightVisibility[texel * lightCount + i] =
+ shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(i))
? Lightmap.VISIBILITY_VISIBLE : Lightmap.VISIBILITY_OCCLUDED;
- }
} else {
- final GiState state = states.computeIfAbsent(entry.polygon, p -> new GiState());
- final int lightIdx = state.nextLight++ % lightCount;
- final boolean visible = shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(lightIdx));
- final long bit = 1L << lightIdx;
- synchronized (state) {
- state.visibleBits = visible ? (state.visibleBits | bit) : (state.visibleBits & ~bit);
- state.knownBits |= bit;
- }
+ final int lightIdx = lightmap.nextLight++ % lightCount;
+ lightmap.lightVisibility[texel * lightCount + lightIdx] =
+ shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(lightIdx))
+ ? Lightmap.VISIBILITY_VISIBLE : Lightmap.VISIBILITY_OCCLUDED;
}
}
- // 2. Bounce ray: cosine-weighted hemisphere around the normal.
- final double[] dir = cosineHemisphere(nx, ny, nz, ThreadLocalRandom.current());
+ // 2. Bounce ray + per-texel EMA update.
+ final double[] target = bounceTarget(snap, nx, ny, nz, ox, oy, oz, hit);
+
+ final int count = Math.min(32000, ++lightmap.sampleCounts[texel]);
+ final float alpha = "fixed".equals(ALPHA_MODE) ? 0.15f
+ : (float) Math.max(ALPHA_FLOOR, 2f / (2f + count));
+ final float dR = (float) (target[0] - lightmap.indirectR[texel]);
+ final float dG = (float) (target[1] - lightmap.indirectG[texel]);
+ final float dB = (float) (target[2] - lightmap.indirectB[texel]);
+ lightmap.indirectR[texel] += alpha * dR;
+ lightmap.indirectG[texel] += alpha * dG;
+ lightmap.indirectB[texel] += alpha * dB;
+ return Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha;
+ }
- double targetR = 0, targetG = 0, targetB = 0;
- if (snap.bvh.nearest(ox, oy, oz, dir[0], dir[1], dir[2], hit)) {
- // Direct irradiance at the hit point (clamped to display range)
- // plus the hit surface's current indirect estimate.
- final double[] irr = directIrradiance(snap, hit);
- final Color hitColor = colorOf(hit.entry);
- final float hiR, hiG, hiB;
- if (hit.entry.lightmap != null) {
- final int hitTexel = hit.entry.lightmap.texelAt(hit.pointX, hit.pointY, hit.pointZ);
- hiR = hit.entry.lightmap.indirectR[hitTexel];
- hiG = hit.entry.lightmap.indirectG[hitTexel];
- hiB = hit.entry.lightmap.indirectB[hitTexel];
- } else {
- final GiState hitState = states.get(hit.entry.polygon);
- hiR = hitState == null ? 0 : hitState.indirectR;
- hiG = hitState == null ? 0 : hitState.indirectG;
- hiB = hitState == null ? 0 : hitState.indirectB;
+ /**
+ * Per-polygon sample for plain solid polygons: one bounce ray and the
+ * indirect EMA update, plus shadow rays unless they were settled in
+ * phase A.
+ *
+ * @param withShadowRays true in legacy mode (one light, round-robin);
+ * false in phased mode's phase B
+ * @return the EMA-weighted estimate delta (convergence signal)
+ */
+ private double samplePlain(final Snapshot snap, final TriangleBvh.Entry entry,
+ final boolean withShadowRays, final TriangleBvh.Hit hit) {
+ final double nx = entry.normal[0];
+ final double ny = entry.normal[1];
+ final double nz = entry.normal[2];
+ final double ox = entry.centroidX + nx * ORIGIN_EPSILON;
+ 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 int lightCount = snap.lights.size();
+ if (withShadowRays && lightCount > 0) {
+ final int lightIdx = state.nextLight++ % lightCount;
+ final boolean visible = shadowTest(snap, ox, oy, oz, nx, ny, nz, snap.lights.get(lightIdx));
+ final long bit = 1L << lightIdx;
+ synchronized (state) {
+ state.visibleBits = visible ? (state.visibleBits | bit) : (state.visibleBits & ~bit);
+ state.knownBits |= bit;
}
- targetR = BOUNCE_GAIN * hitColor.r * (irr[0] + hiR) / 255.0;
- targetG = BOUNCE_GAIN * hitColor.g * (irr[1] + hiG) / 255.0;
- targetB = BOUNCE_GAIN * hitColor.b * (irr[2] + hiB) / 255.0;
}
- // Inner EMA update. "fixed" mode (default): every ray hit lands
- // with the same weight forever, so unlit areas keep fading to
- // darkness at the same rate lit areas brighten. "adaptive" mode:
- // alpha starts at ~1 and decays with sample count (floored).
- if (lightmap != null) {
- final int count = Math.min(32000, ++lightmap.sampleCounts[item.texel]);
+ final double[] target = bounceTarget(snap, nx, ny, nz, ox, oy, oz, hit);
+ synchronized (state) {
+ final int count = Math.min(32000, ++state.samples);
final float alpha = "fixed".equals(ALPHA_MODE) ? 0.15f
: (float) Math.max(ALPHA_FLOOR, 2f / (2f + count));
- final float dR = (float) (targetR - lightmap.indirectR[item.texel]);
- final float dG = (float) (targetG - lightmap.indirectG[item.texel]);
- final float dB = (float) (targetB - lightmap.indirectB[item.texel]);
- lightmap.indirectR[item.texel] += alpha * dR;
- lightmap.indirectG[item.texel] += alpha * dG;
- lightmap.indirectB[item.texel] += alpha * dB;
+ final float dR = (float) (target[0] - state.indirectR);
+ final float dG = (float) (target[1] - state.indirectG);
+ final float dB = (float) (target[2] - state.indirectB);
+ state.indirectR += alpha * dR;
+ state.indirectG += alpha * dG;
+ state.indirectB += alpha * dB;
return Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha;
+ }
+ }
+
+ /**
+ * Phase A: computes ambient + direct irradiance at the polygon
+ * centroid with shadow rays to ALL lights, then stamps the result
+ * onto the whole polygon at once. Lightmapped triangles flip their
+ * composite texture to the flat centroid value (the visible wave);
+ * plain polygons publish per-light visibility for the flat-shading
+ * path.
+ */
+ private void sampleCentroidDirect(final Snapshot snap, final TriangleBvh.Entry entry) {
+ final double nx = entry.normal[0];
+ final double ny = entry.normal[1];
+ final double nz = entry.normal[2];
+ final double ox = entry.centroidX + nx * ORIGIN_EPSILON;
+ final double oy = entry.centroidY + ny * ORIGIN_EPSILON;
+ final double oz = entry.centroidZ + nz * ORIGIN_EPSILON;
+
+ final int lightCount = snap.lights.size();
+ final int tracked = Math.min(lightCount, MAX_TRACKED_LIGHTS);
+ final boolean[] visible = new boolean[tracked];
+
+ double r = snap.ambientR, g = snap.ambientG, b = snap.ambientB;
+ for (int i = 0; i < tracked; i++) {
+ final LightSource light = snap.lights.get(i);
+ visible[i] = shadowTest(snap, ox, oy, oz, nx, ny, nz, light);
+ if (!visible[i])
+ continue;
+ final Point3D lightPos = light.getPosition();
+ final double dx = lightPos.x - ox;
+ final double dy = lightPos.y - oy;
+ final double dz = lightPos.z - oz;
+ final double dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
+ if (dist < 0.0001)
+ continue;
+ final double dot = (nx * dx + ny * dy + nz * dz) / dist;
+ if (dot <= 0)
+ continue;
+ final double attenuation = 1.0 / (1.0 + 0.0001 * dist * dist);
+ final double intensity = dot * attenuation * light.getIntensity();
+ final Color lightColor = light.getColor();
+ r += lightColor.r * intensity;
+ g += lightColor.g * intensity;
+ b += lightColor.b * intensity;
+ }
+
+ final Lightmap lightmap = entry.lightmap;
+ if (lightmap != null) {
+ lightmap.ensureLightCapacity(lightCount);
+ if (tracked > 0) {
+ final byte[] centroidVisibility = new byte[lightCount];
+ for (int i = 0; i < tracked; i++)
+ centroidVisibility[i] = visible[i]
+ ? Lightmap.VISIBILITY_VISIBLE : Lightmap.VISIBILITY_OCCLUDED;
+ lightmap.seedCentroidVisibility(centroidVisibility);
+ }
+ lightmap.centroidR = (float) Math.min(255, r);
+ lightmap.centroidG = (float) Math.min(255, g);
+ lightmap.centroidB = (float) Math.min(255, b);
+ lightmap.seedDisplay(lightmap.centroidR, lightmap.centroidG, lightmap.centroidB);
+ lightmap.centroidReady = true;
} else {
final GiState state = states.computeIfAbsent(entry.polygon, p -> new GiState());
synchronized (state) {
- final int count = Math.min(32000, ++state.samples);
- final float alpha = "fixed".equals(ALPHA_MODE) ? 0.15f
- : (float) Math.max(ALPHA_FLOOR, 2f / (2f + count));
- final float dR = (float) (targetR - state.indirectR);
- final float dG = (float) (targetG - state.indirectG);
- final float dB = (float) (targetB - state.indirectB);
- state.indirectR += alpha * dR;
- state.indirectG += alpha * dG;
- state.indirectB += alpha * dB;
- return Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha;
+ for (int i = 0; i < tracked; i++) {
+ final long bit = 1L << i;
+ state.visibleBits = visible[i]
+ ? (state.visibleBits | bit) : (state.visibleBits & ~bit);
+ state.knownBits |= bit;
+ }
}
}
}
+ /**
+ * Phase B: one bounce ray from the polygon centroid, blended into the
+ * centroid indirect estimate. Cheap enough to run scene-wide before
+ * any per-texel work starts.
+ *
+ * @return the normalized graduation signal: the EMA-weighted centroid
+ * indirect delta scaled so that "calm" means below 1 light unit
+ * or below 2% of the tracked magnitude (bright entries must be
+ * able to graduate)
+ */
+ private double sampleCentroidBounce(final Snapshot snap, final TriangleBvh.Entry entry,
+ final TriangleBvh.Hit hit) {
+ final double nx = entry.normal[0];
+ final double ny = entry.normal[1];
+ final double nz = entry.normal[2];
+ final double ox = entry.centroidX + nx * ORIGIN_EPSILON;
+ final double oy = entry.centroidY + ny * ORIGIN_EPSILON;
+ final double oz = entry.centroidZ + nz * ORIGIN_EPSILON;
+
+ final double[] target = bounceTarget(snap, nx, ny, nz, ox, oy, oz, hit);
+
+ final int count = Math.min(32000, ++entry.centroidSamples);
+ final float alpha = "fixed".equals(ALPHA_MODE) ? 0.15f
+ : (float) Math.max(ALPHA_FLOOR, 2f / (2f + count));
+ final Lightmap lightmap = entry.lightmap;
+ if (lightmap != null) {
+ final float dR = (float) (target[0] - lightmap.centroidIndirectR);
+ final float dG = (float) (target[1] - lightmap.centroidIndirectG);
+ final float dB = (float) (target[2] - lightmap.centroidIndirectB);
+ lightmap.centroidIndirectR += alpha * dR;
+ lightmap.centroidIndirectG += alpha * dG;
+ lightmap.centroidIndirectB += alpha * dB;
+ return graduationSignal(
+ Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha,
+ (lightmap.centroidIndirectR + lightmap.centroidIndirectG
+ + lightmap.centroidIndirectB) / 3.0);
+ }
+ final GiState state = states.computeIfAbsent(entry.polygon, p -> new GiState());
+ synchronized (state) {
+ final float dR = (float) (target[0] - state.indirectR);
+ final float dG = (float) (target[1] - state.indirectG);
+ final float dB = (float) (target[2] - state.indirectB);
+ state.indirectR += alpha * dR;
+ state.indirectG += alpha * dG;
+ state.indirectB += alpha * dB;
+ return graduationSignal(
+ Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB))) * alpha,
+ (state.indirectR + state.indirectG + state.indirectB) / 3.0);
+ }
+ }
+
+ /**
+ * Normalizes a graduation delta against the magnitude of the value being
+ * tracked: Monte Carlo jitter scales with brightness, so an absolute
+ * threshold can never be reached by a bright entry (it would squat in
+ * the active window forever and starve everything behind it). Calm when
+ * the raw delta is below 1.0 light unit or below 2% of the magnitude.
+ */
+ private static double graduationSignal(final double rawDelta, final double magnitude) {
+ return rawDelta / Math.max(1.0, 0.02 * magnitude);
+ }
+
+ /**
+ * One cosine-weighted bounce ray from the given surface point.
+ *
+ * @return bounce target radiance contribution {r, g, b}, zeros on a miss
+ */
+ private double[] bounceTarget(final Snapshot snap,
+ final double nx, final double ny, final double nz,
+ final double ox, final double oy, final double oz,
+ final TriangleBvh.Hit hit) {
+ final double[] dir = cosineHemisphere(nx, ny, nz, ThreadLocalRandom.current());
+ if (!snap.bvh.nearest(ox, oy, oz, dir[0], dir[1], dir[2], hit))
+ return new double[3];
+
+ // Direct irradiance at the hit point (clamped to display range)
+ // plus the hit surface's current indirect estimate.
+ final double[] irr = directIrradiance(snap, hit);
+ final Color hitColor = colorOf(hit.entry);
+ final float hiR, hiG, hiB;
+ if (hit.entry.lightmap != null) {
+ final int hitTexel = hit.entry.lightmap.texelAt(hit.pointX, hit.pointY, hit.pointZ);
+ hiR = hit.entry.lightmap.indirectR[hitTexel];
+ hiG = hit.entry.lightmap.indirectG[hitTexel];
+ hiB = hit.entry.lightmap.indirectB[hitTexel];
+ } else {
+ final GiState hitState = states.get(hit.entry.polygon);
+ hiR = hitState == null ? 0 : hitState.indirectR;
+ hiG = hitState == null ? 0 : hitState.indirectG;
+ hiB = hitState == null ? 0 : hitState.indirectB;
+ }
+ return new double[]{
+ BOUNCE_GAIN * hitColor.r * (irr[0] + hiR) / 255.0,
+ BOUNCE_GAIN * hitColor.g * (irr[1] + hiG) / 255.0,
+ BOUNCE_GAIN * hitColor.b * (irr[2] + hiB) / 255.0};
+ }
+
/** Shadow ray from a surface point toward a light. */
private boolean shadowTest(final Snapshot snap,
final double ox, final double oy, final double oz,
@@ -564,136 +1123,229 @@ public class GlobalIllumination implements GiLightProvider {
if (!compositeUpdateInFlight.compareAndSet(false, true))
return;
try {
- final int lightCount = snap.lights.size();
- final double[] pos = new double[3];
- double movementSum = 0;
- long texelTotal = 0;
- for (final Lightmap lightmap : snap.lightmaps) {
- lightmap.ensureLightCapacity(lightCount);
- final int width = lightmap.width;
- final int height = lightmap.height;
- final int texelCount = width * height;
-
- // 1. Total irradiance per valid texel (float, no clamping yet).
- final float[] irrR = new float[texelCount];
- final float[] irrG = new float[texelCount];
- final float[] irrB = new float[texelCount];
- for (final int texel : lightmap.validTexels) {
- lightmap.texelWorldPosition(texel, pos);
- double r = snap.ambientR, g = snap.ambientG, b = snap.ambientB;
- for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++) {
- if (lightmap.lightVisibility[texel * lightCount + i] == Lightmap.VISIBILITY_OCCLUDED)
- continue;
- final LightSource light = snap.lights.get(i);
- final Point3D lightPos = light.getPosition();
- final double dx = lightPos.x - pos[0];
- final double dy = lightPos.y - pos[1];
- final double dz = lightPos.z - pos[2];
- final double dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
- if (dist < 0.0001)
- continue;
- final double dot = (lightmap.normalX * dx + lightmap.normalY * dy
- + lightmap.normalZ * dz) / dist;
- if (dot <= 0)
- continue;
- final double attenuation = 1.0 / (1.0 + 0.0001 * dist * dist);
- final double intensity = dot * attenuation * light.getIntensity();
- final Color lightColor = light.getColor();
- r += lightColor.r * intensity;
- g += lightColor.g * intensity;
- b += lightColor.b * intensity;
+ if (PHASES)
+ updateCompositesPhased(snap);
+ else
+ updateCompositesLegacy(snap);
+ } finally {
+ compositeUpdateInFlight.set(false);
+ }
+ }
+
+ /**
+ * Phased composite routing: pre-refinement lightmaps composite
+ * uniformly from their centroid values; lightmaps in the texel phase
+ * get the full per-texel recomputation plus per-lightmap convergence
+ * judgement; graduated (tail) lightmaps keep recompositing without
+ * any further ray sampling until the estimate has fully glided to the
+ * frozen target; frozen lightmaps are skipped entirely.
+ */
+ private void updateCompositesPhased(final Snapshot snap) {
+ final int lightCount = snap.lights.size();
+ final double[] pos = new double[3];
+ for (final Lightmap lightmap : snap.lightmaps) {
+ if (!lightmap.texelPhase) {
+ // Phases A/B: the whole triangle shows its centroid value.
+ if (lightmap.centroidReady)
+ lightmap.compositeFromCentroid();
+ continue;
+ }
+ final TriangleBvh.Entry entry = snap.lightmapEntry.get(lightmap);
+ if (entry != null && entry.frozen)
+ continue; // fully settled: texture already final
+
+ final double movementSum = compositeTexels(snap, lightmap, lightCount, pos);
+ final double avgMovement = movementSum / ((long) lightmap.width * lightmap.height);
+ if (entry == null)
+ continue;
+ if (!entry.graduated) {
+ // Sampling graduation: every texel re-tested at least once
+ // (no centroid-seeded visibility may survive) AND a calm
+ // estimate -> stop casting rays. The calm limit is relative:
+ // Monte Carlo jitter scales with brightness, so a bright
+ // lightmap can never fall below a fixed threshold and would
+ // squat in the active window forever, starving far entries.
+ double estimateSum = 0;
+ for (final int t : lightmap.validTexels)
+ estimateSum += (lightmap.estimateR[t] + lightmap.estimateG[t]
+ + lightmap.estimateB[t]) / 3.0;
+ final double calmLimit = Math.max(CALM_THRESHOLD,
+ 0.02 * estimateSum / lightmap.validTexels.length);
+ final boolean covered =
+ lightmap.texelsSampled >= lightmap.validTexels.length;
+ if (covered && avgMovement < calmLimit) {
+ if (++lightmap.calmComposites >= GRADUATE_CALM) {
+ lightmap.calmComposites = 0; // re-used for the freeze tail
+ graduate(entry, Phase.TEXEL);
}
- // Indirect, lightly blended with valid 4-neighbors:
- // single-texel Monte Carlo spikes are smoothed without
- // blurring real gradients (texels are sub-pixel at 4K).
- final float smoothedR = DESPECKLE ? smoothedIndirect(lightmap.indirectR, lightmap, texel) : lightmap.indirectR[texel];
- final float smoothedG = DESPECKLE ? smoothedIndirect(lightmap.indirectG, lightmap, texel) : lightmap.indirectG[texel];
- final float smoothedB = DESPECKLE ? smoothedIndirect(lightmap.indirectB, lightmap, texel) : lightmap.indirectB[texel];
- irrR[texel] = (float) Math.min(255, r) + smoothedR;
- irrG[texel] = (float) Math.min(255, g) + smoothedG;
- irrB[texel] = (float) Math.min(255, b) + smoothedB;
+ } else {
+ lightmap.calmComposites = 0;
}
-
- // 2. Fill the invalid half (u+v > 1) from nearest valid
- // neighbors, so bilinear upsampling never reads garbage.
- final boolean[] filled = new boolean[texelCount];
- for (final int texel : lightmap.validTexels)
- filled[texel] = true;
- boolean progressed = true;
- while (progressed) {
- progressed = false;
- for (int t = 0; t < texelCount; t++) {
- if (filled[t])
- continue;
- final int i = t % width;
- final int j = t / width;
- final int left = i > 0 ? t - 1 : -1;
- final int right = i < width - 1 ? t + 1 : -1;
- final int up = j > 0 ? t - width : -1;
- final int down = j < height - 1 ? t + width : -1;
- final int source = left >= 0 && filled[left] ? left
- : right >= 0 && filled[right] ? right
- : up >= 0 && filled[up] ? up
- : down >= 0 && filled[down] ? down : -1;
- if (source >= 0) {
- irrR[t] = irrR[source];
- irrG[t] = irrG[source];
- irrB[t] = irrB[source];
- filled[t] = true;
- progressed = true;
+ if (DEBUG)
+ System.out.println("[GI] composite update (texel phase), avgMovement="
+ + String.format("%.2f", avgMovement));
+ } else {
+ // Tail: no new samples, the target is frozen, so movement
+ // decays monotonically (pure EMA glide) until invisible.
+ if (avgMovement < FREEZE_THRESHOLD) {
+ if (++lightmap.calmComposites >= GRADUATE_CALM) {
+ entry.frozen = true;
+ synchronized (queueLock) {
+ snap.tailCount--;
+ if (DEBUG)
+ System.out.println("[GI] frozen, tail left=" + snap.tailCount);
}
}
+ } else {
+ lightmap.calmComposites = 0;
}
+ }
+ }
+ }
- // 3. Blend the computed irradiance into the persistent
- // per-texel estimate (the outer EMA), then write the
- // composite texture 1:1 from the ESTIMATE â the texture
- // can only move COMPOSITE_ALPHA of the remaining
- // distance per update, so direct light, shadows and
- // indirect all fade in/out gradually.
- final Texture back = lightmap.backTexture();
- final int[] pixels = back.primaryBitmap.pixels;
- for (int j = 0; j < height; j++)
- for (int i = 0; i < width; i++) {
- final int t = j * width + i;
- final float dR = (float) (COMPOSITE_ALPHA * (irrR[t] - lightmap.estimateR[t]));
- final float dG = (float) (COMPOSITE_ALPHA * (irrG[t] - lightmap.estimateG[t]));
- final float dB = (float) (COMPOSITE_ALPHA * (irrB[t] - lightmap.estimateB[t]));
- lightmap.estimateR[t] += dR;
- lightmap.estimateG[t] += dG;
- lightmap.estimateB[t] += dB;
- movementSum += Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB)));
- texelTotal++;
- pixels[t] = compositePixel(lightmap,
- lightmap.estimateR[t], lightmap.estimateG[t], lightmap.estimateB[t]);
- }
+ /** Legacy composite path with global convergence judgement. */
+ private void updateCompositesLegacy(final Snapshot snap) {
+ final int lightCount = snap.lights.size();
+ final double[] pos = new double[3];
+ double movementSum = 0;
+ long texelTotal = 0;
+ for (final Lightmap lightmap : snap.lightmaps) {
+ movementSum += compositeTexels(snap, lightmap, lightCount, pos);
+ texelTotal += (long) lightmap.width * lightmap.height;
+ }
+
+ // Convergence: average per-texel movement of the on-screen
+ // estimate. With a constant alpha the estimate never fully
+ // freezes (Monte Carlo jitter), so CALM_THRESHOLD judges the
+ // VISIBLE movement; five calm updates in a row -> idle.
+ final double avgMovement = texelTotal > 0 ? movementSum / texelTotal : 0;
+ if (avgMovement < CALM_THRESHOLD)
+ calmSweeps++;
+ else
+ calmSweeps = 0;
+ if (DEBUG)
+ System.out.println("[GI] composite update, avgMovement="
+ + String.format("%.2f", avgMovement));
+ }
+
+ /**
+ * Regenerates one lightmap's composite texture from the current
+ * per-texel visibility and indirect state, blending into the
+ * persistent per-texel estimate (the outer EMA).
+ *
+ * @return the sum of per-texel estimate movement (convergence signal)
+ */
+ private double compositeTexels(final Snapshot snap, final Lightmap lightmap,
+ final int lightCount, final double[] pos) {
+ lightmap.ensureLightCapacity(lightCount);
+ final int width = lightmap.width;
+ final int height = lightmap.height;
+ final int texelCount = width * height;
+
+ // 1. Total irradiance per valid texel (float, no clamping yet).
+ final float[] irrR = new float[texelCount];
+ final float[] irrG = new float[texelCount];
+ final float[] irrB = new float[texelCount];
+ for (final int texel : lightmap.validTexels) {
+ lightmap.texelWorldPosition(texel, pos);
+ double r = snap.ambientR, g = snap.ambientG, b = snap.ambientB;
+ for (int i = 0; i < lightCount && i < MAX_TRACKED_LIGHTS; i++) {
+ if (lightmap.lightVisibility[texel * lightCount + i] == Lightmap.VISIBILITY_OCCLUDED)
+ continue;
+ final LightSource light = snap.lights.get(i);
+ final Point3D lightPos = light.getPosition();
+ final double dx = lightPos.x - pos[0];
+ final double dy = lightPos.y - pos[1];
+ final double dz = lightPos.z - pos[2];
+ final double dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
+ if (dist < 0.0001)
+ continue;
+ final double dot = (lightmap.normalX * dx + lightmap.normalY * dy
+ + lightmap.normalZ * dz) / dist;
+ if (dot <= 0)
+ continue;
+ final double attenuation = 1.0 / (1.0 + 0.0001 * dist * dist);
+ final double intensity = dot * attenuation * light.getIntensity();
+ final Color lightColor = light.getColor();
+ r += lightColor.r * intensity;
+ g += lightColor.g * intensity;
+ b += lightColor.b * intensity;
+ }
+ // Indirect, lightly blended with valid 4-neighbors:
+ // single-texel Monte Carlo spikes are smoothed without
+ // blurring real gradients (texels are sub-pixel at 4K).
+ final float smoothedR = DESPECKLE ? smoothedIndirect(lightmap.indirectR, lightmap, texel) : lightmap.indirectR[texel];
+ final float smoothedG = DESPECKLE ? smoothedIndirect(lightmap.indirectG, lightmap, texel) : lightmap.indirectG[texel];
+ final float smoothedB = DESPECKLE ? smoothedIndirect(lightmap.indirectB, lightmap, texel) : lightmap.indirectB[texel];
+ irrR[texel] = (float) Math.min(255, r) + smoothedR;
+ irrG[texel] = (float) Math.min(255, g) + smoothedG;
+ irrB[texel] = (float) Math.min(255, b) + smoothedB;
+ }
- back.resetResampledBitmapCache();
- if (lightmap.owner != null) {
- lightmap.owner.setTexture(back);
- lightmap.swapBuffers();
+ // 2. Fill the invalid half (u+v > 1) from nearest valid
+ // neighbors, so bilinear upsampling never reads garbage.
+ final boolean[] filled = new boolean[texelCount];
+ for (final int texel : lightmap.validTexels)
+ filled[texel] = true;
+ boolean progressed = true;
+ while (progressed) {
+ progressed = false;
+ for (int t = 0; t < texelCount; t++) {
+ if (filled[t])
+ continue;
+ final int i = t % width;
+ final int j = t / width;
+ final int left = i > 0 ? t - 1 : -1;
+ final int right = i < width - 1 ? t + 1 : -1;
+ final int up = j > 0 ? t - width : -1;
+ final int down = j < height - 1 ? t + width : -1;
+ final int source = left >= 0 && filled[left] ? left
+ : right >= 0 && filled[right] ? right
+ : up >= 0 && filled[up] ? up
+ : down >= 0 && filled[down] ? down : -1;
+ if (source >= 0) {
+ irrR[t] = irrR[source];
+ irrG[t] = irrG[source];
+ irrB[t] = irrB[source];
+ filled[t] = true;
+ progressed = true;
}
+ }
+ }
- // Debug: -De3d.gi.dumpLightmaps=/tmp/lm dumps composites as PNGs.
- if (DUMP_DIR != null)
- dumpLightmap(lightmap, pixels);
+ // 3. Blend the computed irradiance into the persistent
+ // per-texel estimate (the outer EMA), then write the
+ // composite texture 1:1 from the ESTIMATE â the texture
+ // can only move COMPOSITE_ALPHA of the remaining
+ // distance per update, so direct light, shadows and
+ // indirect all fade in/out gradually.
+ double movementSum = 0;
+ final Texture back = lightmap.backTexture();
+ final int[] pixels = back.primaryBitmap.pixels;
+ for (int j = 0; j < height; j++)
+ for (int i = 0; i < width; i++) {
+ final int t = j * width + i;
+ final float dR = (float) (COMPOSITE_ALPHA * (irrR[t] - lightmap.estimateR[t]));
+ final float dG = (float) (COMPOSITE_ALPHA * (irrG[t] - lightmap.estimateG[t]));
+ final float dB = (float) (COMPOSITE_ALPHA * (irrB[t] - lightmap.estimateB[t]));
+ lightmap.estimateR[t] += dR;
+ lightmap.estimateG[t] += dG;
+ lightmap.estimateB[t] += dB;
+ movementSum += Math.max(Math.abs(dR), Math.max(Math.abs(dG), Math.abs(dB)));
+ pixels[t] = compositePixel(lightmap,
+ lightmap.estimateR[t], lightmap.estimateG[t], lightmap.estimateB[t]);
}
- // Convergence: average per-texel movement of the on-screen
- // estimate. With a constant alpha the estimate never fully
- // freezes (Monte Carlo jitter), so CALM_THRESHOLD judges the
- // VISIBLE movement; five calm updates in a row -> idle.
- final double avgMovement = texelTotal > 0 ? movementSum / texelTotal : 0;
- if (avgMovement < CALM_THRESHOLD)
- calmSweeps++;
- else
- calmSweeps = 0;
- if (DEBUG)
- System.out.println("[GI] composite update, avgMovement="
- + String.format("%.2f", avgMovement));
- } finally {
- compositeUpdateInFlight.set(false);
+ back.resetResampledBitmapCache();
+ if (lightmap.owner != null) {
+ lightmap.owner.setTexture(back);
+ lightmap.swapBuffers();
}
+
+ // Debug: -De3d.gi.dumpLightmaps=/tmp/lm dumps composites as PNGs.
+ if (DUMP_DIR != null)
+ dumpLightmap(lightmap, pixels);
+ return movementSum;
}
private static final String DUMP_DIR = System.getProperty("e3d.gi.dumpLightmaps");
@@ -766,9 +1418,22 @@ public class GlobalIllumination implements GiLightProvider {
final double lightSignature = lightSignature();
if (version == lastSeenRenderListVersion && lightSignature == lastLightSignature)
return;
+ if (DEBUG)
+ System.out.println("[GI] rebuild trigger: renderListVersion "
+ + lastSeenRenderListVersion + " -> " + version
+ + ", lightSignature " + lastLightSignature + " -> " + lightSignature
+ + ", phase was " + phase);
final List triangles = new ArrayList<>();
shapes.collectRenderTriangles(triangles);
+ // 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
+ // bump already happened). Re-read: if it moved, let the next loop
+ // rebuild from a consistent state instead of converging a partial
+ // scene to DONE and idling forever.
+ if (AbstractCompositeShape.getGlobalRenderListVersion() != version)
+ return;
final Snapshot snap = new Snapshot();
snap.entries = new ArrayList<>(triangles.size());
@@ -792,25 +1457,66 @@ public class GlobalIllumination implements GiLightProvider {
snap.ambientG = ambient.g;
snap.ambientB = ambient.b;
- // Flattened work list: one item per valid lightmap texel,
- // one per plain polygon.
- final List workItems = new ArrayList<>();
- for (final TriangleBvh.Entry entry : snap.entries) {
- if (entry.lightmap != null) {
- for (final int texel : entry.lightmap.validTexels) {
+ if (PHASES) {
+ // Distance ordering from the camera position at build time.
+ final Point3D cam = cameraPosition == null ? null : cameraPosition.get();
+ for (final TriangleBvh.Entry entry : snap.entries)
+ entry.distance = cam == null ? 0f : distanceSquared(entry, cam);
+ snap.sortedByDistance = snap.entries.stream()
+ .sorted(Comparator.comparingDouble(e -> e.distance))
+ .toArray(TriangleBvh.Entry[]::new);
+ snap.lightmappedSorted = snap.entries.stream()
+ .filter(e -> e.lightmap != null)
+ .sorted(Comparator.comparingDouble(e -> e.distance))
+ .toArray(TriangleBvh.Entry[]::new);
+ snap.lightmapEntry = new IdentityHashMap<>();
+ for (final TriangleBvh.Entry entry : snap.entries)
+ if (entry.lightmap != null) {
+ entry.lightmap.resetPhasedState();
+ snap.lightmapEntry.put(entry.lightmap, entry);
+ }
+ synchronized (queueLock) {
+ phase = Phase.CENTROID_DIRECT;
+ phaseQueue = snap.sortedByDistance;
+ queueCursor = 0;
+ // inFlight must NOT be zeroed here: a worker that grabbed
+ // an entry just before the rebuild decrements it after,
+ // so set(0) would drive the counter negative and the
+ // phase-A drain condition (== 0) would never hold again.
+ // The counter is conserved on its own: pre-rebuild
+ // samples finish and decrement normally.
+ activeList.clear();
+ active = new TriangleBvh.Entry[0];
+ activeCursor.set(0);
+ }
+ if (cam != null) {
+ lastCameraX = cam.x;
+ lastCameraY = cam.y;
+ lastCameraZ = cam.z;
+ } else {
+ lastCameraX = Double.NaN;
+ }
+ } else {
+ // Flattened work list: one item per valid lightmap texel,
+ // one per plain polygon.
+ final List workItems = new ArrayList<>();
+ for (final TriangleBvh.Entry entry : snap.entries) {
+ if (entry.lightmap != null) {
+ for (final int texel : entry.lightmap.validTexels) {
+ final WorkItem item = new WorkItem();
+ item.entry = entry;
+ item.texel = texel;
+ workItems.add(item);
+ }
+ } else {
final WorkItem item = new WorkItem();
item.entry = entry;
- item.texel = texel;
+ item.texel = -1;
workItems.add(item);
}
- } else {
- final WorkItem item = new WorkItem();
- item.entry = entry;
- item.texel = -1;
- workItems.add(item);
}
+ snap.workItems = workItems.toArray(new WorkItem[0]);
}
- snap.workItems = workItems.toArray(new WorkItem[0]);
snapshot = snap;
states.clear();
@@ -818,11 +1524,17 @@ public class GlobalIllumination implements GiLightProvider {
lastLightSignature = lightSignature;
calmSweeps = 0; // scene changed: back to full-speed tracing
- if (DEBUG)
- System.out.println("[GI] snapshot: " + snap.entries.size() + " triangles, "
- + snap.workItems.length + " work items, "
- + snap.lightmaps.size() + " lightmaps, "
- + snap.lights.size() + " lights");
+ if (DEBUG) {
+ if (PHASES)
+ System.out.println("[GI] snapshot: " + snap.entries.size() + " triangles, "
+ + snap.lightmaps.size() + " lightmaps, "
+ + snap.lights.size() + " lights, phased scheduling near-to-far");
+ else
+ System.out.println("[GI] snapshot: " + snap.entries.size() + " triangles, "
+ + snap.workItems.length + " work items, "
+ + snap.lightmaps.size() + " lightmaps, "
+ + snap.lights.size() + " lights");
+ }
}
private double lightSignature() {
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java
index 4acf19e..dffa1a8 100644
--- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/gi/Lightmap.java
@@ -105,6 +105,52 @@ public class Lightmap {
public byte[] lightVisibility;
public int lightCount;
+ // --- Phased progressive GI state (-De3d.gi.phases, default on) ---
+
+ /**
+ * Total direct irradiance at the triangle centroid (ambient + direct
+ * with shadows, clamped to display range), computed in phase A. The
+ * whole triangle displays this uniform value until per-texel
+ * refinement reaches it in phase C.
+ */
+ public volatile float centroidR, centroidG, centroidB;
+
+ /**
+ * Indirect irradiance estimated at the centroid during phase B. Texel
+ * indirect arrays are seeded from this so bounce rays hitting a
+ * not-yet-refined triangle read a plausible uniform value.
+ */
+ public volatile float centroidIndirectR, centroidIndirectG, centroidIndirectB;
+
+ /**
+ * True once per-texel refinement (phase C) has reached this lightmap.
+ * Before that, composite updates fill the texture uniformly from the
+ * centroid values instead of recomputing per texel.
+ */
+ public volatile boolean texelPhase;
+
+ /** True once phase A has stamped the centroid direct value (GI threads only). */
+ public volatile boolean centroidReady;
+
+ /** Round-robin cursor over {@link #validTexels} in phase C (GI threads only). */
+ public int nextTexel;
+
+ /** Consecutive calm composite updates, drives phase C graduation (GI threads only). */
+ public int calmComposites;
+
+ /**
+ * Phase C: valid texels that have been sampled at least once. Graduation
+ * must not fire before this reaches {@code validTexels.length}: an
+ * unvisited texel still shows its centroid-seeded light visibility
+ * (e.g. "fully shadowed" if the centroid happened to sit in shadow),
+ * and a frozen seed survives forever as a dark polygon with lit detail
+ * only on the visited fraction.
+ */
+ public int texelsSampled;
+
+ /** Last composited centroid totals; skips redundant uniform recomposites. */
+ private float lastCompositeR = Float.NaN, lastCompositeG, lastCompositeB;
+
/** Double-buffered composite textures; the triangle shows one, GI fills the other. */
private final Texture[] buffers = new Texture[2];
private int shownBuffer;
@@ -159,6 +205,8 @@ public class Lightmap {
java.util.Arrays.fill(estimateG, (float) INITIAL_IRRADIANCE);
java.util.Arrays.fill(estimateB, (float) INITIAL_IRRADIANCE);
sampleCounts = new short[width * height];
+ texelsSampled = 0;
+ calmComposites = 0;
final int[] valid = new int[width * height];
int count = 0;
@@ -254,6 +302,108 @@ public class Lightmap {
}
}
+ /**
+ * Phase A: stamps the centroid direct irradiance onto the whole
+ * triangle at once â estimate arrays, composite texture and buffer
+ * swap in one go, so the polygon flips from the uniform medium start
+ * to its true flat lighting in a single visible step (the progressive
+ * "wave"). Per-texel refinement later glides away from this seed via
+ * the normal composite EMA.
+ *
+ * @param r total direct irradiance, red channel (light units)
+ * @param g green channel
+ * @param b blue channel
+ */
+ public void seedDisplay(final float r, final float g, final float b) {
+ java.util.Arrays.fill(estimateR, r);
+ java.util.Arrays.fill(estimateG, g);
+ java.util.Arrays.fill(estimateB, b);
+ lastCompositeR = r;
+ lastCompositeG = g;
+ lastCompositeB = b;
+ final Texture back = backTexture();
+ java.util.Arrays.fill(back.primaryBitmap.pixels, compositeSeedPixel(r, g, b));
+ back.resetResampledBitmapCache();
+ if (owner != null) {
+ owner.setTexture(back);
+ swapBuffers();
+ }
+ }
+
+ /** Uniform composite pixel for {@link #seedDisplay}. */
+ private int compositeSeedPixel(final float irrR, final float irrG, final float irrB) {
+ final int r = Math.min(255, (int) (irrR * baseColor.r / 255));
+ final int g = Math.min(255, (int) (irrG * baseColor.g / 255));
+ final int b = Math.min(255, (int) (irrB * baseColor.b / 255));
+ return 0xFF000000 | (r << 16) | (g << 8) | b;
+ }
+
+ /**
+ * Phase A: fills the per-texel visibility array uniformly with the
+ * centroid shadow-test results, so bounce rays hitting this triangle
+ * before per-texel refinement see plausible shadowed direct light.
+ * Assumes {@link #ensureLightCapacity} has run.
+ *
+ * @param centroidVisibility per-light visibility bytes (length = lightCount)
+ */
+ public void seedCentroidVisibility(final byte[] centroidVisibility) {
+ final int texelCount = width * height;
+ for (int t = 0; t < texelCount; t++)
+ System.arraycopy(centroidVisibility, 0, lightVisibility, t * lightCount, lightCount);
+ }
+
+ /**
+ * Phase B / pre-refinement composite: reseeds the display estimate
+ * uniformly from the centroid direct plus the current centroid
+ * indirect, and fills the texel indirect arrays from the centroid
+ * indirect so bounce-target reads stay uniform. Runs at composite
+ * cadence, not per sample.
+ */
+ public void compositeFromCentroid() {
+ final float totalR = centroidR + centroidIndirectR;
+ final float totalG = centroidG + centroidIndirectG;
+ final float totalB = centroidB + centroidIndirectB;
+ if (totalR == lastCompositeR && totalG == lastCompositeG && totalB == lastCompositeB)
+ return; // centroid values unchanged since the last stamp: nothing to do
+ lastCompositeR = totalR;
+ lastCompositeG = totalG;
+ lastCompositeB = totalB;
+ java.util.Arrays.fill(estimateR, totalR);
+ java.util.Arrays.fill(estimateG, totalG);
+ java.util.Arrays.fill(estimateB, totalB);
+ java.util.Arrays.fill(indirectR, centroidIndirectR);
+ java.util.Arrays.fill(indirectG, centroidIndirectG);
+ java.util.Arrays.fill(indirectB, centroidIndirectB);
+ final Texture back = backTexture();
+ final int[] pixels = back.primaryBitmap.pixels;
+ final int pixel = compositeSeedPixel(totalR, totalG, totalB);
+ java.util.Arrays.fill(pixels, pixel);
+ back.resetResampledBitmapCache();
+ if (owner != null) {
+ owner.setTexture(back);
+ swapBuffers();
+ }
+ }
+
+ /**
+ * Resets the phased-progression state for a new GI snapshot (scene or
+ * light change): back to the centroid phases, per-texel shadow tracing
+ * re-armed. The display estimate arrays deliberately keep their old
+ * values so the re-traced solution fades in instead of flashing.
+ */
+ public void resetPhasedState() {
+ texelPhase = false;
+ centroidReady = false;
+ nextTexel = 0;
+ calmComposites = 0;
+ texelsSampled = 0;
+ centroidIndirectR = 0;
+ centroidIndirectG = 0;
+ centroidIndirectB = 0;
+ lastCompositeR = Float.NaN;
+ java.util.Arrays.fill(sampleCounts, (short) 0);
+ }
+
/**
* The texture the triangle should show right now.
*
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 5e099ae..d7ffcb6 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
@@ -34,6 +34,19 @@ public class TriangleBvh {
/** Unit surface normal, world space. */
public volatile float[] normal;
+ // --- Phased progressive GI bookkeeping (GI threads only) ---
+
+ /** Squared distance from the camera at snapshot build / last re-sort. */
+ public float distance;
+ /** Phase B/C: true once this entry's estimate has calmed down and it left the active window. */
+ public volatile boolean graduated;
+ /** Phase C: true once the composite estimate has fully glided to the frozen target (display freeze). */
+ public volatile boolean frozen;
+ /** Consecutive calm phase-B visits (GI threads only). */
+ public int calmVisits;
+ /** Centroid bounce sample count, drives the adaptive alpha in phase B (GI threads only). */
+ public int centroidSamples;
+
public Entry(final AbstractCoordinateShape polygon) {
this.polygon = polygon;
}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java
index 3a3ca1d..e8d315d 100644
--- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/basic/solidpolygon/SolidPolygon.java
@@ -93,6 +93,17 @@ public class SolidPolygon extends AbstractCoordinateShape {
* Computed once during transform phase, used during paint phase.
*/
private final Color shadedColor = new Color();
+ /**
+ * Shaded color of the REVERSE side, computed with the negated normal.
+ * Used when backface culling is off and the camera sees the polygon's
+ * back: the back is a different surface and must not show the front
+ * side's lighting (an underside must not glow with the top's light).
+ */
+ private final Color backShadedColor = new Color();
+ /**
+ * Reusable negated normal for the reverse-side lighting pass.
+ */
+ private final Point3D cachedBackNormal = new Point3D();
/**
* Reusable point for polygon center calculation.
*/
@@ -697,17 +708,6 @@ public class SolidPolygon extends AbstractCoordinateShape {
return;
}
- // Use pre-computed shaded color (computed during transform phase)
- final Color paintColor = shadingEnabled ? shadedColor : color;
-
- // Z-buffer two-pass classification: opaque polygons paint in
- // pass 1 (depth test + write), translucent ones in pass 2
- // (depth test, no write â translucency must not occlude).
- // See RenderAggregator.paintSorted.
- final boolean alphaClass = paintColor.a != 255;
- if ((renderBuffer.depthPass == 1) == alphaClass)
- return;
-
// Get thread-local screen points array
final Point2D[] screenPoints = getScreenPoints(active.size());
final double[] cameraZ = getCameraZ(active.size());
@@ -719,13 +719,29 @@ public class SolidPolygon extends AbstractCoordinateShape {
cameraZ[i] = vertex.transformedCoordinate(renderBuffer).z;
}
+ // Facing from the signed screen area (same convention as the
+ // backface culling test below: >= 0 means the back side shows).
+ final double signedArea = (backfaceCulling || shadingEnabled)
+ ? calculateSignedArea(screenPoints, active.size())
+ : -1;
+
// Backface culling check
- if (backfaceCulling) {
- final double signedArea = calculateSignedArea(screenPoints, active.size());
- if (signedArea >= 0) {
- return;
- }
- }
+ if (backfaceCulling && signedArea >= 0)
+ return;
+
+ // Two-sided lighting: a visible back side paints with the
+ // reverse-lit color, never the front side's light.
+ final Color paintColor = shadingEnabled
+ ? (signedArea >= 0 ? backShadedColor : shadedColor)
+ : color;
+
+ // Z-buffer two-pass classification: opaque polygons paint in
+ // pass 1 (depth test + write), translucent ones in pass 2
+ // (depth test, no write â translucency must not occlude).
+ // See RenderAggregator.paintSorted.
+ final boolean alphaClass = paintColor.a != 255;
+ if ((renderBuffer.depthPass == 1) == alphaClass)
+ return;
// Mouse interaction
if (mouseInteractionController != null && renderBuffer.getMouseEvent() != null) {
@@ -818,6 +834,16 @@ public class SolidPolygon extends AbstractCoordinateShape {
);
renderingContext.lightingManager.computeLighting(
this, cachedCenter, cachedNormal, color, shadedColor);
+ if (!backfaceCulling) {
+ // Two-sided lighting: when the camera sees the polygon's
+ // back (culling off), that side is lit by its own facing
+ // direction, so relight with the negated normal.
+ cachedBackNormal.x = -cachedNormal.x;
+ cachedBackNormal.y = -cachedNormal.y;
+ cachedBackNormal.z = -cachedNormal.z;
+ renderingContext.lightingManager.computeLighting(
+ this, cachedCenter, cachedBackNormal, color, backShadedColor);
+ }
}
}
}
\ No newline at end of file
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.java
index 9aba6d4..b03e1e7 100644
--- a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.java
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/LightmappedCompositeShape.java
@@ -126,7 +126,7 @@ public class LightmappedCompositeShape extends AbstractCompositeShape {
private void wrapLightmapped(final SolidPolygon polygon, final List out) {
final int vertexCount = polygon.getVertexCount();
if (vertexCount == 3) {
- out.add(wrapTriangle(polygon));
+ emitWrapped(polygon, out);
return;
}
// Fan: anchor vertex 0, then consecutive pairs
@@ -140,15 +140,46 @@ public class LightmappedCompositeShape extends AbstractCompositeShape {
triangle.setShadingEnabled(polygon.isShadingEnabled());
triangle.setBackfaceCulling(polygon.isBackfaceCullingEnabled());
triangle.setMouseInteractionController(polygon.mouseInteractionController);
- out.add(wrapTriangle(triangle));
+ emitWrapped(triangle, out);
}
}
+ /**
+ * Emits one lightmapped triangle for the front side. When the source
+ * polygon is two-sided (backface culling off), also emits a plain
+ * solid triangle with reversed winding for the back side, and turns
+ * culling on for both halves: a lightmap only knows its front side,
+ * so without a real back surface the camera would see the front
+ * side's light through the polygon (undersides glowing with the
+ * top's light). The reversed solid renders with ordinary one-sided
+ * direct lighting â correctly dark where the front is lit â and each
+ * side's pixels are owned by exactly one surface, so nothing
+ * z-fights.
+ */
+ private void emitWrapped(final SolidPolygon triangle, final List out) {
+ final LightmappedTriangle front = wrapTriangle(triangle);
+ if (triangle.isBackfaceCullingEnabled()) {
+ out.add(front);
+ return;
+ }
+ front.setBackfaceCulling(true);
+ final SolidPolygon back = new SolidPolygon(
+ triangle.vertices.get(0).coordinate,
+ triangle.vertices.get(2).coordinate,
+ triangle.vertices.get(1).coordinate,
+ triangle.getColor());
+ back.setShadingEnabled(triangle.isShadingEnabled());
+ back.setBackfaceCulling(true);
+ back.setMouseInteractionController(triangle.mouseInteractionController);
+ out.add(front);
+ out.add(back);
+ }
+
/**
* Wraps one triangle into a lightmapped textured triangle with the
* same geometry, color and culling.
*/
- private AbstractCoordinateShape wrapTriangle(final SolidPolygon polygon) {
+ private LightmappedTriangle wrapTriangle(final SolidPolygon polygon) {
final Point3D a = polygon.vertices.get(0).coordinate;
final Point3D b = polygon.vertices.get(1).coordinate;
final Point3D c = polygon.vertices.get(2).coordinate;
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjLoader.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjLoader.java
new file mode 100644
index 0000000..2da4ca2
--- /dev/null
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjLoader.java
@@ -0,0 +1,341 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.obj;
+
+import eu.svjatoslav.aukio.e3d.geometry.Point3D;
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+/**
+ * Loader for Wavefront OBJ geometry files (with MTL material libraries).
+ *
+ * Supported OBJ statements:
+ *
+ * - {@code v x y z} â vertex positions (extra components ignored)
+ * - {@code f v1 v2 v3 [v4 ...]} â faces; each index may be in any of
+ * the standard forms {@code v}, {@code v/vt}, {@code v//vn},
+ * {@code v/vt/vn}; negative indices are relative to the most recently
+ * defined vertex. Faces may reference vertices defined later in the
+ * file (batch-flushing exporters do this). Texture/normal indices are
+ * parsed past and ignored.
+ * - {@code usemtl name} â selects the material for following faces
+ * - {@code mtllib file [file ...]} â material library, resolved relative
+ * to the OBJ file (or via the caller-provided resolver)
+ * - {@code o}, {@code g}, {@code s}, {@code vt}, {@code vn}, {@code #} â
+ * parsed past and ignored
+ *
+ *
+ * Supported MTL statements: {@code newmtl}, {@code Kd} (diffuse color),
+ * {@code d} (opacity) and {@code Tr} (transparency). Everything else is
+ * ignored.
+ *
+ * Coordinates are loaded verbatim â no axis conversion. Note the engine's
+ * world convention is "+Y points down", which matches OBJ exporters that
+ * wrote scenes for y-down pipelines; for standard "+Y up" exports rotate
+ * the resulting model 180° around X via its transform.
+ *
+ * Limitation: faces are rendered as convex polygons (fan triangulation);
+ * non-convex faces may render with overlapping triangles.
+ *
+ * Usage:
+ * {@code
+ * ObjModel model = ObjLoader.load(Path.of("city1.obj"));
+ * model.setShadingEnabled(true);
+ * shapes.addShape(model);
+ * }
+ */
+public final class ObjLoader {
+
+ /** Utility class, not meant to be instantiated. */
+ private ObjLoader() {
+ }
+
+ /**
+ * Loads an OBJ file, resolving {@code mtllib} references against the
+ * file's own directory.
+ *
+ * @param objFile path to the .obj file
+ * @return the parsed model, ready to add to a scene
+ * @throws IOException on read errors, malformed statements, or a
+ * missing material library
+ */
+ public static ObjModel load(final Path objFile) throws IOException {
+ final Path directory = objFile.toAbsolutePath().getParent();
+ final Function resolver = name -> {
+ final Path direct = directory.resolve(name);
+ try {
+ if (Files.exists(direct)) {
+ return Files.newInputStream(direct);
+ }
+ // DOS-era exports often disagree with the filesystem on case.
+ try (DirectoryStream entries = Files.newDirectoryStream(directory)) {
+ for (final Path entry : entries) {
+ if (entry.getFileName().toString().equalsIgnoreCase(name)) {
+ return Files.newInputStream(entry);
+ }
+ }
+ }
+ return null;
+ } catch (final IOException e) {
+ return null;
+ }
+ };
+ try (InputStream stream = Files.newInputStream(objFile)) {
+ return load(stream, objFile.getFileName().toString(), resolver);
+ }
+ }
+
+ /**
+ * Loads an OBJ document from a stream.
+ *
+ * @param objStream the OBJ content
+ * @param sourceName name used in error messages
+ * @param resourceResolver opens referenced material libraries by name;
+ * may return {@code null} when not found
+ * @return the parsed model, ready to add to a scene
+ * @throws IOException on read errors or malformed statements
+ */
+ public static ObjModel load(final InputStream objStream, final String sourceName,
+ final Function resourceResolver)
+ throws IOException {
+ final List vertices = new ArrayList<>();
+ final Map materials = new HashMap<>();
+ final Set usedMaterials = new HashSet<>();
+ final List faces = new ArrayList<>();
+
+ ObjMaterial currentMaterial = ObjMaterial.DEFAULT;
+
+ final BufferedReader reader = new BufferedReader(
+ new InputStreamReader(objStream, StandardCharsets.UTF_8));
+ String line;
+ int lineNumber = 0;
+ // OBJ line continuation: a trailing backslash joins with the next line.
+ StringBuilder pending = new StringBuilder();
+ while ((line = reader.readLine()) != null) {
+ lineNumber++;
+ final String trimmed = line.trim();
+ if (trimmed.endsWith("\\")) {
+ pending.append(trimmed, 0, trimmed.length() - 1).append(' ');
+ continue;
+ }
+ pending.append(trimmed);
+ final String statement = pending.toString();
+ pending.setLength(0);
+
+ if (statement.isEmpty() || statement.startsWith("#")) {
+ continue;
+ }
+ final String[] tokens = statement.split("\\s+");
+ switch (tokens[0]) {
+ case "v":
+ vertices.add(new Point3D(
+ parseDouble(tokens, 1, sourceName, lineNumber),
+ parseDouble(tokens, 2, sourceName, lineNumber),
+ parseDouble(tokens, 3, sourceName, lineNumber)));
+ break;
+ case "f": {
+ if (tokens.length < 4) {
+ throw malformed(sourceName, lineNumber,
+ "face needs at least 3 vertices: " + statement);
+ }
+ // Positive indices are validated and resolved only after
+ // the whole file is parsed: exporters that flush vertices
+ // in batches (e.g. 3D Synthezier's 3dparse) legally emit
+ // faces referencing vertices defined LATER in the file.
+ // Negative (relative) indices resolve immediately â they
+ // are relative to the vertex count at this point.
+ final int[] indices = new int[tokens.length - 1];
+ for (int i = 1; i < tokens.length; i++) {
+ indices[i - 1] = resolveIndex(tokens[i], vertices.size(),
+ sourceName, lineNumber);
+ }
+ faces.add(new PendingFace(indices, currentMaterial, lineNumber));
+ usedMaterials.add(currentMaterial.getName());
+ break;
+ }
+ case "usemtl":
+ if (tokens.length < 2) {
+ throw malformed(sourceName, lineNumber, "usemtl needs a name");
+ }
+ currentMaterial = materials.getOrDefault(tokens[1], ObjMaterial.DEFAULT);
+ break;
+ case "mtllib":
+ if (tokens.length < 2) {
+ throw malformed(sourceName, lineNumber, "mtllib needs a file name");
+ }
+ for (int i = 1; i < tokens.length; i++) {
+ loadMaterialLibrary(tokens[i], sourceName, resourceResolver, materials);
+ }
+ break;
+ default:
+ // o, g, s, vt, vn, ... â not needed for flat-colored polygons.
+ break;
+ }
+ }
+
+ final ObjModel result = new ObjModel(vertices.size(), faces.size(),
+ usedMaterials.size());
+ for (final PendingFace face : faces) {
+ final Point3D[] facePoints = new Point3D[face.indices.length];
+ for (int i = 0; i < face.indices.length; i++) {
+ final int index = face.indices[i];
+ if (index >= vertices.size()) {
+ throw malformed(sourceName, face.lineNumber,
+ "face index out of range: " + (index + 1));
+ }
+ // Copy: faces must not alias one mutable Point3D.
+ facePoints[i] = new Point3D(vertices.get(index));
+ }
+ result.addShape(new SolidPolygon(facePoints, face.material.getColor()));
+ }
+ return result;
+ }
+
+ /** A face statement captured mid-parse, before vertices are complete. */
+ private static final class PendingFace {
+ private final int[] indices;
+ private final ObjMaterial material;
+ private final int lineNumber;
+
+ private PendingFace(final int[] indices, final ObjMaterial material,
+ final int lineNumber) {
+ this.indices = indices;
+ this.material = material;
+ this.lineNumber = lineNumber;
+ }
+ }
+
+ private static void loadMaterialLibrary(final String fileName, final String sourceName,
+ final Function resourceResolver,
+ final Map materials)
+ throws IOException {
+ try (InputStream stream = resourceResolver.apply(fileName)) {
+ if (stream == null) {
+ throw new IOException(sourceName + ": material library not found: " + fileName);
+ }
+ parseMaterialLibrary(stream, fileName, materials);
+ }
+ }
+
+ private static void parseMaterialLibrary(final InputStream stream, final String fileName,
+ final Map materials)
+ throws IOException {
+ final BufferedReader reader = new BufferedReader(
+ new InputStreamReader(stream, StandardCharsets.UTF_8));
+ String name = null;
+ double r = 0.5, g = 0.5, b = 0.5, opacity = 1.0;
+ String line;
+ int lineNumber = 0;
+ while ((line = reader.readLine()) != null) {
+ lineNumber++;
+ final String trimmed = line.trim();
+ if (trimmed.isEmpty() || trimmed.startsWith("#")) {
+ continue;
+ }
+ final String[] tokens = trimmed.split("\\s+");
+ switch (tokens[0]) {
+ case "newmtl":
+ if (name != null) {
+ materials.put(name, new ObjMaterial(name,
+ new Color(r, g, b, opacity)));
+ }
+ if (tokens.length < 2) {
+ throw malformed(fileName, lineNumber, "newmtl needs a name");
+ }
+ name = tokens[1];
+ r = g = b = 0.5;
+ opacity = 1.0;
+ break;
+ case "Kd":
+ r = parseDouble(tokens, 1, fileName, lineNumber);
+ g = parseDouble(tokens, 2, fileName, lineNumber);
+ b = parseDouble(tokens, 3, fileName, lineNumber);
+ break;
+ case "d":
+ opacity = parseDouble(tokens, 1, fileName, lineNumber);
+ break;
+ case "Tr":
+ opacity = 1.0 - parseDouble(tokens, 1, fileName, lineNumber);
+ break;
+ default:
+ // Ns, Ks, Ka, illum, map_*, ... â ignored.
+ break;
+ }
+ }
+ if (name != null) {
+ materials.put(name, new ObjMaterial(name, new Color(r, g, b, opacity)));
+ }
+ }
+
+ /**
+ * Resolves an OBJ face index token ({@code v}, {@code v/vt}, {@code v//vn}
+ * or {@code v/vt/vn}) to a 0-based index. Positive indices (1-based) are
+ * returned as-is minus one WITHOUT a range check â the exporter may define
+ * the referenced vertex later in the file; the range is validated against
+ * the final vertex count when the model is assembled. Negative indices are
+ * relative to the vertex count at this point and are range-checked
+ * immediately.
+ */
+ private static int resolveIndex(final String token, final int vertexCount,
+ final String sourceName, final int lineNumber)
+ throws IOException {
+ final int slash = token.indexOf('/');
+ final String indexText = slash < 0 ? token : token.substring(0, slash);
+ final int raw;
+ try {
+ raw = Integer.parseInt(indexText);
+ } catch (final NumberFormatException e) {
+ throw malformed(sourceName, lineNumber, "bad face index: " + token);
+ }
+ if (raw == 0) {
+ throw malformed(sourceName, lineNumber,
+ "face index out of range: " + token);
+ }
+ if (raw < 0) {
+ final int index = vertexCount + raw;
+ if (index < 0 || index >= vertexCount) {
+ throw malformed(sourceName, lineNumber,
+ "face index out of range: " + token);
+ }
+ return index;
+ }
+ return raw - 1;
+ }
+
+ private static double parseDouble(final String[] tokens, final int position,
+ final String sourceName, final int lineNumber)
+ throws IOException {
+ if (position >= tokens.length) {
+ throw malformed(sourceName, lineNumber,
+ "expected number at position " + (position + 1));
+ }
+ try {
+ return Double.parseDouble(tokens[position]);
+ } catch (final NumberFormatException e) {
+ throw malformed(sourceName, lineNumber, "bad number: " + tokens[position]);
+ }
+ }
+
+ private static IOException malformed(final String sourceName, final int lineNumber,
+ final String detail) {
+ return new IOException(sourceName + ":" + lineNumber + ": " + detail);
+ }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjMaterial.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjMaterial.java
new file mode 100644
index 0000000..3a00497
--- /dev/null
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjMaterial.java
@@ -0,0 +1,45 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.obj;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.Color;
+
+/**
+ * A single material from a Wavefront MTL library.
+ *
+ * Only the subset of MTL attributes relevant to the rasterizer is
+ * captured: diffuse color ({@code Kd}) and opacity ({@code d}, or
+ * {@code Tr} transparency) â combined into the engine's {@link Color}
+ * (alpha 255 = opaque, lower = translucent). Specular/ambient/texture-map
+ * attributes ({@code Ks}, {@code Ka}, {@code Ns}, {@code illum},
+ * {@code map_*}) are parsed past and ignored.
+ */
+public class ObjMaterial {
+
+ /** Fallback material for faces whose {@code usemtl} is unknown or missing. */
+ public static final ObjMaterial DEFAULT = new ObjMaterial("default",
+ new Color(160, 160, 160, 255));
+
+ private final String name;
+ private final Color color;
+
+ public ObjMaterial(final String name, final Color color) {
+ this.name = name;
+ this.color = color;
+ }
+
+ /** Material name from the {@code newmtl} statement. */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Diffuse color ({@code Kd}) with alpha taken from {@code d}
+ * (1.0 â alpha 255 opaque, lower values â translucent).
+ */
+ public Color getColor() {
+ return color;
+ }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjModel.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjModel.java
new file mode 100644
index 0000000..8e3037e
--- /dev/null
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjModel.java
@@ -0,0 +1,54 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.obj;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.LightmappedCompositeShape;
+
+/**
+ * A 3D model loaded from a Wavefront OBJ file, as a composite of
+ * solid polygons (one {@code SolidPolygon} per OBJ face, colored by the
+ * face's {@code usemtl} material).
+ *
+ * Instances are built by {@link ObjLoader}; treat the model as an
+ * ordinary composite: position it with {@link #setTransform}, toggle
+ * {@link #setShadingEnabled} / {@link #setBackfaceCulling}, and add it to
+ * the scene graph.
+ *
+ * @see ObjLoader
+ */
+public class ObjModel extends LightmappedCompositeShape {
+
+ /** Number of {@code v} statements in the source file. */
+ private final int sourceVertexCount;
+
+ /** Number of {@code f} statements (one child polygon each). */
+ private final int faceCount;
+
+ /** Number of materials actually referenced by faces. */
+ private final int usedMaterialCount;
+
+ public ObjModel(final int sourceVertexCount, final int faceCount,
+ final int usedMaterialCount) {
+ super();
+ this.sourceVertexCount = sourceVertexCount;
+ this.faceCount = faceCount;
+ this.usedMaterialCount = usedMaterialCount;
+ }
+
+ /** Number of vertices declared in the OBJ source. */
+ public int getSourceVertexCount() {
+ return sourceVertexCount;
+ }
+
+ /** Number of faces (= child polygons) in the model. */
+ public int getFaceCount() {
+ return faceCount;
+ }
+
+ /** Number of distinct materials referenced by the model's faces. */
+ public int getUsedMaterialCount() {
+ return usedMaterialCount;
+ }
+}
diff --git a/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/package-info.java b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/package-info.java
new file mode 100644
index 0000000..cfd4efe
--- /dev/null
+++ b/src/main/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/package-info.java
@@ -0,0 +1,11 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+
+/**
+ * Wavefront OBJ/MTL model loading: {@link ObjLoader} parses the files and
+ * builds an {@link ObjModel} composite of solid polygons, colored per face
+ * from the material library.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.obj;
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/math/TransformStackTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/math/TransformStackTest.java
index ed5275b..eb4de15 100644
--- a/src/test/java/eu/svjatoslav/aukio/e3d/math/TransformStackTest.java
+++ b/src/test/java/eu/svjatoslav/aukio/e3d/math/TransformStackTest.java
@@ -136,4 +136,208 @@ public class TransformStackTest {
assertEquals(p.y, result.y, EPSILON);
assertEquals(p.z, result.z, EPSILON);
}
+
+ @Test
+ public void uniformScaleComposesThroughStack() {
+ // Parent scales 2x, child scales 3x, both rotated and translated:
+ // the composed stack must match sequential per-level application,
+ // where each level applies scale, then rotation, then translation.
+ final Random rnd = new Random(7);
+
+ for (int depth = 2; depth <= 5; depth++) {
+ final Transform[] chain = new Transform[depth];
+ final TransformStack stack = new TransformStack();
+ for (int i = 0; i < depth; i++) {
+ chain[i] = Transform.fromAngles(
+ (rnd.nextDouble() - 0.5) * 1000,
+ (rnd.nextDouble() - 0.5) * 1000,
+ (rnd.nextDouble() - 0.5) * 1000,
+ (rnd.nextDouble() - 0.5) * Math.PI * 2,
+ (rnd.nextDouble() - 0.5) * Math.PI,
+ (rnd.nextDouble() - 0.5) * Math.PI);
+ chain[i].setScale(0.5 + rnd.nextDouble() * 3);
+ stack.addTransform(chain[i]);
+ }
+
+ for (int k = 0; k < 50; k++) {
+ final Point3D p = new Point3D(
+ (rnd.nextDouble() - 0.5) * 100,
+ (rnd.nextDouble() - 0.5) * 100,
+ (rnd.nextDouble() - 0.5) * 100);
+
+ final Point3D expected = new Point3D(p);
+ for (int i = depth - 1; i >= 0; i--) {
+ chain[i].transform(expected);
+ }
+
+ final Point3D result = new Point3D();
+ stack.transform(p, result);
+
+ // Scaled coordinates amplify absolute error, so use a
+ // relative-ish tolerance sized by the accumulated scale.
+ assertEquals("depth " + depth + " x", expected.x, result.x, 1e-6 * magnitude(expected));
+ assertEquals("depth " + depth + " y", expected.y, result.y, 1e-6 * magnitude(expected));
+ assertEquals("depth " + depth + " z", expected.z, result.z, 1e-6 * magnitude(expected));
+ }
+ }
+ }
+
+ private static double magnitude(final Point3D p) {
+ return Math.max(1.0, Math.sqrt(p.x * p.x + p.y * p.y + p.z * p.z));
+ }
+
+ @Test
+ public void scaleAppliesAboutLocalOrigin() {
+ // Scale first, then rotation (identity here), then translation:
+ // the local point is scaled, the translation is not.
+ final Transform t = Transform.fromAngles(10, 20, 30, 0, 0, 0);
+ t.setScale(2.0);
+
+ final Point3D p = new Point3D(3, 4, 5);
+ t.transform(p);
+
+ assertEquals(16, p.x, EPSILON);
+ assertEquals(28, p.y, EPSILON);
+ assertEquals(40, p.z, EPSILON);
+ }
+
+ @Test
+ public void nestedScalesMultiply() {
+ // No rotations: point (1,0,0) -> child: 1*3 + 10 = 13
+ // -> parent: 13*2 + 100 = 126.
+ final Transform parent = Transform.fromAngles(100, 0, 0, 0, 0, 0);
+ parent.setScale(2.0);
+ final Transform child = Transform.fromAngles(10, 0, 0, 0, 0, 0);
+ child.setScale(3.0);
+
+ final TransformStack stack = new TransformStack();
+ stack.addTransform(parent);
+ stack.addTransform(child);
+
+ final Point3D result = new Point3D();
+ stack.transform(new Point3D(1, 0, 0), result);
+
+ assertEquals(126, result.x, EPSILON);
+ assertEquals(0, result.y, EPSILON);
+ assertEquals(0, result.z, EPSILON);
+ }
+
+ @Test
+ public void negativeScaleMirrors() {
+ final Transform t = Transform.fromAngles(1, 2, 3, 0, 0, 0);
+ t.setScale(-2.0);
+
+ final Point3D p = new Point3D(1, 1, 1);
+ t.transform(p);
+
+ assertEquals(-1, p.x, EPSILON);
+ assertEquals(0, p.y, EPSILON);
+ assertEquals(1, p.z, EPSILON);
+ }
+
+ @Test
+ public void setScaleRejectsZeroAndNonFinite() {
+ final Transform t = new Transform();
+ for (final double bad : new double[]{0.0, Double.NaN,
+ Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) {
+ try {
+ t.setScale(bad);
+ throw new AssertionError("expected IllegalArgumentException for " + bad);
+ } catch (final IllegalArgumentException expected) {
+ // expected
+ }
+ }
+ assertEquals(1.0, t.getScale(), 0.0);
+ }
+
+ @Test
+ public void explicitScaleOneIsBitIdenticalToDefault() {
+ // The fold multiplies by the scale at push time; with s == 1.0 that
+ // multiply must be IEEE-exact so rigid scenes stay bit-identical.
+ final Random rnd = new Random(99);
+
+ for (int depth = 1; depth <= 4; depth++) {
+ final TransformStack plain = new TransformStack();
+ final TransformStack explicit = new TransformStack();
+ for (int i = 0; i < depth; i++) {
+ final double x = (rnd.nextDouble() - 0.5) * 1000;
+ final double y = (rnd.nextDouble() - 0.5) * 1000;
+ final double z = (rnd.nextDouble() - 0.5) * 1000;
+ final double yaw = (rnd.nextDouble() - 0.5) * Math.PI * 2;
+ final double pitch = (rnd.nextDouble() - 0.5) * Math.PI;
+ final double roll = (rnd.nextDouble() - 0.5) * Math.PI;
+
+ plain.addTransform(Transform.fromAngles(x, y, z, yaw, pitch, roll));
+ explicit.addTransform(Transform.fromAngles(x, y, z, yaw, pitch, roll)
+ .setScale(1.0));
+ }
+
+ for (int k = 0; k < 20; k++) {
+ final Point3D p = new Point3D(
+ (rnd.nextDouble() - 0.5) * 2000,
+ (rnd.nextDouble() - 0.5) * 2000,
+ (rnd.nextDouble() - 0.5) * 2000);
+ final Point3D a = new Point3D();
+ final Point3D b = new Point3D();
+ plain.transform(p, a);
+ explicit.transform(p, b);
+
+ assertEquals(0.0, Double.compare(a.x, b.x), 0.0);
+ assertEquals(0.0, Double.compare(a.y, b.y), 0.0);
+ assertEquals(0.0, Double.compare(a.z, b.z), 0.0);
+ }
+ }
+ }
+
+ @Test
+ public void scaleMutationAfterPushHasNoEffect() {
+ final Transform transform = Transform.fromAngles(10, 0, 0, 0, 0, 0);
+ transform.setScale(3.0);
+
+ final TransformStack stack = new TransformStack();
+ stack.addTransform(transform);
+
+ // Push-time snapshot: later scale changes must not leak in.
+ transform.setScale(100.0);
+
+ final Point3D result = new Point3D();
+ stack.transform(new Point3D(1, 0, 0), result);
+
+ assertEquals(13, result.x, EPSILON);
+ }
+
+ @Test
+ public void topTransformCarriesScale() {
+ // getTopTransform is the bulk path used by TriangleMeshBlock; it must
+ // agree with transform() bit-for-bit, scale included.
+ final Transform parent = Transform.fromAngles(50, -20, 70, 0.4, -0.2, 0.1);
+ parent.setScale(2.5);
+ final Transform child = Transform.fromAngles(-5, 8, 3, -0.3, 0.7, 0.2);
+ child.setScale(0.75);
+
+ final TransformStack stack = new TransformStack();
+ stack.addTransform(parent);
+ stack.addTransform(child);
+
+ final double[] m = new double[12];
+ stack.getTopTransform(m);
+
+ final Random rnd = new Random(5);
+ for (int k = 0; k < 50; k++) {
+ final double x = (rnd.nextDouble() - 0.5) * 100;
+ final double y = (rnd.nextDouble() - 0.5) * 100;
+ final double z = (rnd.nextDouble() - 0.5) * 100;
+
+ final Point3D viaStack = new Point3D();
+ stack.transform(new Point3D(x, y, z), viaStack);
+
+ final double bx = m[0] * x + m[1] * y + m[2] * z + m[9];
+ final double by = m[3] * x + m[4] * y + m[5] * z + m[10];
+ final double bz = m[6] * x + m[7] * y + m[8] * z + m[11];
+
+ assertEquals(0.0, Double.compare(viaStack.x, bx), 0.0);
+ assertEquals(0.0, Double.compare(viaStack.y, by), 0.0);
+ assertEquals(0.0, Double.compare(viaStack.z, bz), 0.0);
+ }
+ }
}
diff --git a/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjLoaderTest.java b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjLoaderTest.java
new file mode 100644
index 0000000..eb677ea
--- /dev/null
+++ b/src/test/java/eu/svjatoslav/aukio/e3d/renderer/raster/shapes/composite/obj/ObjLoaderTest.java
@@ -0,0 +1,241 @@
+/*
+ * Aukio 3D engine. Author: Svjatoslav Agejenko.
+ * This project is released under Creative Commons Zero (CC0) license.
+ */
+package eu.svjatoslav.aukio.e3d.renderer.raster.shapes.composite.obj;
+
+import eu.svjatoslav.aukio.e3d.renderer.raster.shapes.basic.solidpolygon.SolidPolygon;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Unit tests for {@link ObjLoader}: statement parsing, face index forms,
+ * material resolution, alpha, and error reporting.
+ */
+public class ObjLoaderTest {
+
+ private static final String MTL = """
+ # comment
+ newmtl red
+ Kd 1.0 0.0 0.0
+ d 1.0
+
+ newmtl glass
+ Kd 0.0 0.0 1.0
+ d 0.5
+ """;
+
+ private static Map mtlFiles() {
+ final Map files = new HashMap<>();
+ files.put("test.mtl", MTL);
+ return files;
+ }
+
+ private static ObjModel load(final String obj, final Map files)
+ throws IOException {
+ return ObjLoader.load(
+ new ByteArrayInputStream(obj.getBytes(StandardCharsets.UTF_8)),
+ "test.obj",
+ name -> {
+ final String content = files.get(name);
+ return content == null ? null
+ : new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8));
+ });
+ }
+
+ @Test
+ public void parsesQuadAndTriangleWithMaterials() throws IOException {
+ final ObjModel model = load("""
+ mtllib test.mtl
+ v 0 0 0
+ v 10 0 0
+ v 10 10 0
+ v 0 10 0
+ v 20 0 0
+ usemtl red
+ f 1 2 3 4
+ usemtl glass
+ f 2 5 3
+ """, mtlFiles());
+
+ assertEquals(5, model.getSourceVertexCount());
+ assertEquals(2, model.getFaceCount());
+ assertEquals(2, model.getUsedMaterialCount());
+
+ final List polygons = model.extractSolidPolygons();
+ assertEquals(2, polygons.size());
+
+ // Winding/vertex order must be preserved verbatim.
+ final SolidPolygon quad = polygons.get(0);
+ assertEquals(4, quad.vertices.size());
+ assertEquals(10.0, quad.vertices.get(2).coordinate.x, 1e-9);
+ assertEquals(10.0, quad.vertices.get(2).coordinate.y, 1e-9);
+ assertEquals(255, quad.getColor().r);
+ assertEquals(255, quad.getColor().a);
+
+ final SolidPolygon triangle = polygons.get(1);
+ assertEquals(3, triangle.vertices.size());
+ assertEquals(255, triangle.getColor().b);
+ // d 0.5 -> alpha ~127/128 (0.5*255 truncation is allowed either way)
+ assertTrue(Math.abs(triangle.getColor().a - 127) <= 1);
+ }
+
+ @Test
+ public void acceptsAllFaceIndexForms() throws IOException {
+ final ObjModel model = load("""
+ v 0 0 0 1.0 0.5 0.5
+ v 1 0 0
+ v 1 1 0
+ v 0 1 0
+ vt 0 0
+ vn 0 0 1
+ f 1 2 3
+ f 1/1 2/1 3/1
+ f 1//1 2//1 3//1
+ f 1/1/1 2/1/1 3/1/1
+ f -4 -3 -2 -1
+ """, mtlFiles());
+ assertEquals(5, model.getFaceCount());
+ // Negative indices are relative: -4..-1 == vertices 1..4 (0,0,0)..(0,1,0).
+ final SolidPolygon last = model.extractSolidPolygons().get(4);
+ assertEquals(0.0, last.vertices.get(0).coordinate.x, 1e-9);
+ assertEquals(0.0, last.vertices.get(3).coordinate.x, 1e-9);
+ assertEquals(1.0, last.vertices.get(3).coordinate.y, 1e-9);
+ }
+
+ @Test
+ public void unknownMaterialFallsBackToDefault() throws IOException {
+ final ObjModel model = load("""
+ v 0 0 0
+ v 1 0 0
+ v 1 1 0
+ usemtl nope
+ f 1 2 3
+ """, mtlFiles());
+ final SolidPolygon polygon = model.extractSolidPolygons().get(0);
+ assertEquals(160, polygon.getColor().r);
+ assertEquals(255, polygon.getColor().a);
+ }
+
+ @Test
+ public void faceWithoutAnyMaterialUsesDefault() throws IOException {
+ final ObjModel model = load("""
+ v 0 0 0
+ v 1 0 0
+ v 1 1 0
+ f 1 2 3
+ """, mtlFiles());
+ assertEquals(160, model.extractSolidPolygons().get(0).getColor().r);
+ }
+
+ @Test
+ public void forwardVertexReferencesResolve() throws IOException {
+ // Batch-flushing exporters (3D Synthezier's 3dparse) emit faces that
+ // reference vertices defined later in the file â valid OBJ.
+ final ObjModel model = load("""
+ f 1 2 3
+ v 0 0 0
+ v 1 0 0
+ v 1 1 0
+ """, mtlFiles());
+ assertEquals(1, model.getFaceCount());
+ final SolidPolygon polygon = model.extractSolidPolygons().get(0);
+ assertEquals(1.0, polygon.vertices.get(2).coordinate.x, 1e-9);
+ assertEquals(1.0, polygon.vertices.get(2).coordinate.y, 1e-9);
+ }
+
+ @Test
+ public void rejectsOutOfRangeFaceIndex() throws IOException {
+ try {
+ load("""
+ v 0 0 0
+ v 1 0 0
+ v 1 1 0
+ f 1 2 4
+ """, mtlFiles());
+ fail("expected IOException for out-of-range face index");
+ } catch (final IOException e) {
+ assertTrue(e.getMessage(), e.getMessage().contains("test.obj:4"));
+ }
+ }
+
+ @Test(expected = IOException.class)
+ public void rejectsDegenerateFace() throws IOException {
+ load("""
+ v 0 0 0
+ v 1 0 0
+ f 1 2
+ """, mtlFiles());
+ }
+
+ @Test
+ public void missingMaterialLibraryIsAnError() throws IOException {
+ try {
+ load("""
+ mtllib absent.mtl
+ v 0 0 0
+ v 1 0 0
+ v 1 1 0
+ f 1 2 3
+ """, mtlFiles());
+ fail("expected IOException for missing material library");
+ } catch (final IOException e) {
+ assertTrue(e.getMessage(), e.getMessage().contains("absent.mtl"));
+ }
+ }
+
+ @Test
+ public void trTransparencyComplementsOpacity() throws IOException {
+ final Map files = new HashMap<>();
+ files.put("tr.mtl", "newmtl half\nKd 0 1 0\nTr 0.25\n");
+ final ObjModel model = load("""
+ mtllib tr.mtl
+ v 0 0 0
+ v 1 0 0
+ v 1 1 0
+ usemtl half
+ f 1 2 3
+ """, files);
+ assertEquals(191, model.extractSolidPolygons().get(0).getColor().a);
+ assertEquals(255, model.extractSolidPolygons().get(0).getColor().g);
+ }
+
+ @Test
+ public void lineContinuationJoinsStatements() throws IOException {
+ final ObjModel model = load("v 0 0 \\\n0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n", mtlFiles());
+ assertEquals(3, model.getSourceVertexCount());
+ assertEquals(1, model.getFaceCount());
+ }
+
+ @Test
+ public void pathLoadResolvesMtllibCaseInsensitively() throws IOException {
+ final Path dir = Files.createTempDirectory("objloader-test");
+ final Path obj = dir.resolve("scene.obj");
+ Files.writeString(obj, """
+ mtllib materials.mtl
+ v 0 0 0
+ v 1 0 0
+ v 1 1 0
+ usemtl green
+ f 1 2 3
+ """);
+ // Filesystem has a different case than the mtllib reference.
+ Files.writeString(dir.resolve("MATERIALS.MTL"), "newmtl green\nKd 0 1 0\nd 1\n");
+
+ final ObjModel model = ObjLoader.load(obj);
+ assertEquals(1, model.getFaceCount());
+ assertEquals(255, model.extractSolidPolygons().get(0).getColor().g);
+ }
+}
diff --git a/udev/99-rayneo-glasses.rules b/udev/99-rayneo-glasses.rules
new file mode 100644
index 0000000..8e716cf
--- /dev/null
+++ b/udev/99-rayneo-glasses.rules
@@ -0,0 +1 @@
+ACTION=="add|change", SUBSYSTEM=="hidraw", SUBSYSTEMS=="usb", ATTRS{idVendor}=="1bbb", ATTRS{idProduct}=="af50", MODE="0666"
diff --git a/udev/99-spacenavigator.rules b/udev/99-spacenavigator.rules
new file mode 100644
index 0000000..524cfde
--- /dev/null
+++ b/udev/99-spacenavigator.rules
@@ -0,0 +1 @@
+SUBSYSTEM=="hidraw", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="c626", MODE="0666"